diff --git a/.gitignore b/.gitignore index 8116c345..59120dbe 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ playwright-report/ blob-report/ playwright/.cache/ playwright/.auth/ + +# tsc incremental build artifact +tsconfig.tsbuildinfo diff --git a/Dockerfile b/Dockerfile index 7dc6cc43..7391929b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,10 +4,11 @@ WORKDIR /app # --- Install --- FROM base AS deps +ENV BUN_CONFIG_MAX_HTTP_REQUESTS=12 COPY package.json bun.lock .npmrc ./ COPY patches/ patches/ COPY tools/ tools/ -RUN bun install --frozen-lockfile +RUN ok=0; for i in $(seq 1 12); do bun install --frozen-lockfile && ok=1 && break; echo "network retry $i"; done; [ "$ok" = 1 ] # --- Build --- FROM base AS build @@ -20,11 +21,11 @@ RUN bun run build # --- Production dependencies (patches applied, then devDeps stripped) --- FROM base AS prod-deps +ENV BUN_CONFIG_MAX_HTTP_REQUESTS=12 COPY package.json bun.lock .npmrc ./ COPY patches/ patches/ COPY tools/ tools/ -RUN bun install --frozen-lockfile \ - && bun install --frozen-lockfile --production +RUN ok=0; for i in $(seq 1 12); do bun install --frozen-lockfile && bun install --frozen-lockfile --production && ok=1 && break; echo "network retry $i"; done; [ "$ok" = 1 ] # --- Runtime --- FROM base AS runtime diff --git a/bun.lock b/bun.lock index ec864745..c9fed38c 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,10 @@ "name": "tanstack-start-app", "dependencies": { "@clickhouse/click-ui": "0.9.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@librechat/data-schemas": "^0.0.56", "@radix-ui/react-dialog": "1.1.15", "@tailwindcss/vite": "^4.3.1", @@ -178,6 +182,16 @@ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], diff --git a/package.json b/package.json index f46add31..cfd2bbb8 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,10 @@ }, "dependencies": { "@clickhouse/click-ui": "0.9.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@librechat/data-schemas": "^0.0.56", "@radix-ui/react-dialog": "1.1.15", "@tailwindcss/vite": "^4.3.1", diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index e14d01dc..cc6471fa 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -1,4 +1,5 @@ -import { Dialog } from '@clickhouse/click-ui'; +import { Dialog, Select } from '@clickhouse/click-ui'; +import { useTranslation } from 'react-i18next'; import type * as t from '@/types'; import { useTheme } from '@/contexts/ThemeContext'; import { useLocalize } from '@/hooks'; @@ -11,9 +12,26 @@ const THEME_LABEL_KEYS: Record = { dark: 'com_nav_theme_dark', }; +const LANGUAGE_OPTIONS = ['en', 'zh-Hans'] as const; +type LanguageOption = (typeof LANGUAGE_OPTIONS)[number]; +const LANGUAGE_LABEL_KEYS: Record = { + en: 'com_ui_language_english', + 'zh-Hans': 'com_ui_language_zh_hans', +}; + export function SettingsDialog({ open, onClose }: t.SettingsDialogProps) { const localize = useLocalize(); const { theme, setTheme } = useTheme(); + const { i18n } = useTranslation(); + + const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language ?? 'en'; + const currentLanguage: LanguageOption = resolvedLanguage.startsWith('zh') ? 'zh-Hans' : 'en'; + + const setLanguage = (language: LanguageOption) => { + i18n.changeLanguage(language); + localStorage.setItem('i18nextLng', language); + document.documentElement.lang = language; + }; return ( event.preventDefault()} title={localize('com_ui_settings')} showClose onClose={onClose} @@ -57,6 +76,26 @@ export function SettingsDialog({ open, onClose }: t.SettingsDialogProps) { ))} +
+
+ + {localize('com_ui_language')} + +
+
+ +
+
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ce99c1e2..1a37e722 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -31,6 +31,9 @@ const navItems: t.NavItem[] = [ capability: [SystemCapabilities.READ_ROLES, SystemCapabilities.READ_GROUPS], }, { labelKey: 'com_nav_grants', path: '/grants', icon: 'lock' }, + { labelKey: 'com_nav_tools', path: '/tools', icon: 'plug' }, + // 使用统计并入 /tools?tab=usage(2.16.0),侧栏不再单列 + { labelKey: 'com_nav_services', path: '/services', icon: 'server' }, { labelKey: 'com_nav_help', path: '/help', icon: 'question' }, ]; diff --git a/src/components/access/CreateGroupDialog.tsx b/src/components/access/CreateGroupDialog.tsx index f383777a..45b87c37 100644 --- a/src/components/access/CreateGroupDialog.tsx +++ b/src/components/access/CreateGroupDialog.tsx @@ -81,6 +81,7 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { }} > event.preventDefault()} title={localize('com_access_create_group')} showClose onClose={resetAndClose} diff --git a/src/components/access/CreateRoleDialog.tsx b/src/components/access/CreateRoleDialog.tsx index 836f3534..8e9a4de8 100644 --- a/src/components/access/CreateRoleDialog.tsx +++ b/src/components/access/CreateRoleDialog.tsx @@ -84,6 +84,7 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { }} > event.preventDefault()} title={localize('com_access_create_role')} showClose onClose={resetAndClose} diff --git a/src/components/access/EditGroupDialog.tsx b/src/components/access/EditGroupDialog.tsx index 8c0d6cfb..b726d129 100644 --- a/src/components/access/EditGroupDialog.tsx +++ b/src/components/access/EditGroupDialog.tsx @@ -131,6 +131,7 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog }} > event.preventDefault()} title={localize('com_access_edit_group')} showClose onClose={onClose} diff --git a/src/components/access/EditRoleDialog.tsx b/src/components/access/EditRoleDialog.tsx index 891c1ac2..0d48aece 100644 --- a/src/components/access/EditRoleDialog.tsx +++ b/src/components/access/EditRoleDialog.tsx @@ -177,6 +177,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro }} > event.preventDefault()} title={localize('com_access_edit_role')} showClose onClose={onClose} diff --git a/src/components/configuration/ConfirmSaveDialog.tsx b/src/components/configuration/ConfirmSaveDialog.tsx index 6946b968..f8470525 100644 --- a/src/components/configuration/ConfirmSaveDialog.tsx +++ b/src/components/configuration/ConfirmSaveDialog.tsx @@ -29,6 +29,7 @@ export function ConfirmSaveDialog({ }} > event.preventDefault()} title={localize('com_config_confirm_save_title')} showClose onClose={onCancel} diff --git a/src/components/configuration/DeleteProfileValueModal.tsx b/src/components/configuration/DeleteProfileValueModal.tsx index f67ce196..178022ce 100644 --- a/src/components/configuration/DeleteProfileValueModal.tsx +++ b/src/components/configuration/DeleteProfileValueModal.tsx @@ -21,6 +21,7 @@ export function DeleteProfileValueModal({ }} > event.preventDefault()} title={localize('com_scope_confirm_remove')} showClose onClose={onCancel} diff --git a/src/components/configuration/ImportYamlDialog.tsx b/src/components/configuration/ImportYamlDialog.tsx index 0cdd5ae9..3d841668 100644 --- a/src/components/configuration/ImportYamlDialog.tsx +++ b/src/components/configuration/ImportYamlDialog.tsx @@ -199,6 +199,7 @@ export function ImportYamlDialog({ }} > event.preventDefault()} title={localize('com_config_import_yaml_title')} showClose onClose={handleClose} diff --git a/src/components/configuration/ProfileIndicator.tsx b/src/components/configuration/ProfileIndicator.tsx index c7a499a9..6ebdcaa2 100644 --- a/src/components/configuration/ProfileIndicator.tsx +++ b/src/components/configuration/ProfileIndicator.tsx @@ -86,6 +86,7 @@ export function ProfileIndicator({ }} > event.preventDefault()} title={localize('com_scope_cascade_title')} showClose onClose={handleClose} diff --git a/src/components/configuration/ProfileValueModal.tsx b/src/components/configuration/ProfileValueModal.tsx index 560bbf65..52e6b152 100644 --- a/src/components/configuration/ProfileValueModal.tsx +++ b/src/components/configuration/ProfileValueModal.tsx @@ -37,7 +37,8 @@ export function ProfileValueModal({ if (!isOpen) onCancel(); }} > - + event.preventDefault()} title={title} showClose onClose={onCancel} className="modal-frost">
{scopeConfig && ( diff --git a/src/components/configuration/ResetBaseConfigDialog.tsx b/src/components/configuration/ResetBaseConfigDialog.tsx index c53dca8f..16f60c31 100644 --- a/src/components/configuration/ResetBaseConfigDialog.tsx +++ b/src/components/configuration/ResetBaseConfigDialog.tsx @@ -19,6 +19,7 @@ export function ResetBaseConfigDialog({ }} > event.preventDefault()} title={localize('com_config_reset_base_title')} showClose onClose={onCancel} diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index 36507312..b1f1e24a 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -26,6 +26,18 @@ const QUICK_LINKS: (t.NavItem & { descKey: string })[] = [ icon: 'lock', descKey: 'com_dash_grants_desc', }, + { + labelKey: 'com_nav_tools', + path: '/tools', + icon: 'plug', + descKey: 'com_dash_tools_desc', + }, + { + labelKey: 'com_nav_services', + path: '/services', + icon: 'server', + descKey: 'com_dash_services_desc', + }, { labelKey: 'com_nav_help', path: '/help', diff --git a/src/components/grants/AuditLogDetailDrawer.tsx b/src/components/grants/AuditLogDetailDrawer.tsx index 9c42eea2..a03644a9 100644 --- a/src/components/grants/AuditLogDetailDrawer.tsx +++ b/src/components/grants/AuditLogDetailDrawer.tsx @@ -223,6 +223,7 @@ export function AuditLogDetailDrawer({ )} /> event.preventDefault()} aria-label={localize('com_audit_detail_title')} onEscapeKeyDown={() => onClose()} className={cn( @@ -270,6 +271,7 @@ export function AuditLogDetailDrawer({ )} /> event.preventDefault()} aria-label={localize('com_audit_detail_title')} onEscapeKeyDown={() => onClose()} className={cn( @@ -322,6 +324,7 @@ export function AuditLogDetailDrawer({ )} /> event.preventDefault()} aria-label={localize('com_audit_detail_title')} onEscapeKeyDown={() => onClose()} className={cn( @@ -387,6 +390,7 @@ export function AuditLogDetailDrawer({ )} /> event.preventDefault()} aria-label={localize('com_audit_detail_title')} onEscapeKeyDown={() => onClose()} className={cn( diff --git a/src/components/grants/AuditLogTab.tsx b/src/components/grants/AuditLogTab.tsx index ce2021e5..378e9980 100644 --- a/src/components/grants/AuditLogTab.tsx +++ b/src/components/grants/AuditLogTab.tsx @@ -24,6 +24,7 @@ import { exportAuditLogServerFn, } from '@/server'; import { + DatePickerCell, EmptyState, LoadingState, Pagination, @@ -36,58 +37,20 @@ import { AuditLogDetailDrawer } from './AuditLogDetailDrawer'; import { cn } from '@/utils'; const AUDIT_ACTIONS: readonly AuditAction[] = ['grant.assigned', 'grant.removed'] as const; -const TARGET_TYPE_OPTIONS: readonly PrincipalType[] = [ +const TARGET_TYPE_OPTIONS = [ PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE, ] as const; +const TARGET_TYPE_LABEL_KEYS: Record<(typeof TARGET_TYPE_OPTIONS)[number], string> = { + [PrincipalType.USER]: 'com_audit_target_user', + [PrincipalType.GROUP]: 'com_audit_target_group', + [PrincipalType.ROLE]: 'com_audit_target_role', +}; /** Radix `Select.Item` cannot use `value=""` (Radix reserves empty string for * "no selection"). Use a non-empty sentinel and translate to `''` in state. */ const TARGET_TYPE_ALL = '__all__'; -/** - * Wraps a click-ui DatePicker so only the trigger button is tab-focusable. - * click-ui renders both a PopoverTrigger button AND an inner readonly input, - * which produces two stops in the tab order. The input has no exposed `tabIndex` - * prop, so we reach for the DOM node and set it to -1. The class hooks the - * CSS rule that rounds the trigger's focus outline to match the wrapper border. - * - * `resetKey` is the caller's signal that the inner DatePicker has been keyed - * to remount (e.g. the Clear button bumping a nonce): the inner `` is - * replaced and the previous patch is lost, so the effect must re-run and - * re-apply `tabIndex = -1` against the fresh DOM node. - */ -function DatePickerCell({ - children, - resetKey, - inputId, -}: { - children: React.ReactNode; - resetKey?: unknown; - /** - * Stamps the click-ui-rendered `` with an `id` so an external - * `
+ + + ); +} diff --git a/src/components/tools/ImportToolsDialog.tsx b/src/components/tools/ImportToolsDialog.tsx new file mode 100644 index 00000000..dbc7e58e --- /dev/null +++ b/src/components/tools/ImportToolsDialog.tsx @@ -0,0 +1,409 @@ +import { useRef, useState } from 'react'; +import { Button, Dialog, Switch, Tabs } from '@clickhouse/click-ui'; +import type { TerraVoxTool, ImportResult } from '@/server'; +import { importToolsFn } from '@/server'; +import { useLocalize } from '@/hooks'; +import { cn } from '@/utils'; + +type Step = 'input' | 'review' | 'done'; + +/** Live count of manifests in the paste box (0 until it parses). */ +const countParsed = (text: string): number => { + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + return parsed.length; + } + if ( + parsed && + typeof parsed === 'object' && + Array.isArray((parsed as { tools?: unknown }).tools) + ) { + return (parsed as { tools: unknown[] }).tools.length; + } + return parsed && typeof parsed === 'object' ? 1 : 0; + } catch { + return 0; + } +}; + +const ACTION_STYLE: Record = { + created: + 'rounded-full bg-(--cui-color-background-success-muted) px-2 py-0.5 text-xs text-(--cui-color-text-success)', + updated: + 'rounded-full bg-(--cui-color-accent-primary-muted) px-2 py-0.5 text-xs text-(--cui-color-text-default)', + skipped: + 'rounded-full bg-(--cui-color-background-muted) px-2 py-0.5 text-xs text-(--cui-color-text-muted)', + failed: + 'rounded-full bg-(--cui-color-background-danger-muted) px-2 py-0.5 text-xs text-(--cui-color-text-danger)', +}; + +/** + * Two-step import: parse manifests (upload .json files or paste JSON) → + * dry-run preview against the gateway → optional overwrite → apply. + * The gateway validates each manifest; per-item outcomes come back in + * `results` for both the preview and the real run. + */ +export function ImportToolsDialog({ + open, + onClose, + onImported, +}: { + open: boolean; + onClose: () => void; + onImported: () => void; +}) { + const localize = useLocalize(); + const fileInputRef = useRef(null); + const [step, setStep] = useState('input'); + const [mode, setMode] = useState('upload'); + const [files, setFiles] = useState<{ name: string; manifest: unknown }[]>([]); + const [pasted, setPasted] = useState(''); + const [parseError, setParseError] = useState(null); + const [overwrite, setOverwrite] = useState(false); + const [results, setResults] = useState(null); + /** Manifests that passed local parsing and the dry run — the apply step + * re-sends exactly this list (possibly with a different overwrite flag). */ + const [pendingTools, setPendingTools] = useState[] | null>(null); + const [busy, setBusy] = useState(false); + const [requestError, setRequestError] = useState(null); + + const reset = () => { + setStep('input'); + setMode('upload'); + setFiles([]); + setPasted(''); + setParseError(null); + setOverwrite(false); + setResults(null); + setPendingTools(null); + setBusy(false); + setRequestError(null); + }; + + const close = () => { + if (busy) { + return; + } + const wasDone = step === 'done'; + reset(); + onClose(); + if (wasDone) { + onImported(); + } + }; + + const readFiles = async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) { + return; + } + setParseError(null); + const loaded: { name: string; manifest: unknown }[] = []; + const errors: string[] = []; + for (const file of Array.from(fileList)) { + try { + const manifest = JSON.parse(await file.text()); + loaded.push({ name: file.name, manifest }); + } catch (error) { + errors.push(`${file.name}: ${(error as Error).message}`); + } + } + setFiles((prev) => [...prev, ...loaded]); + if (errors.length > 0) { + setParseError(errors.join('\n')); + } + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const collectTools = (): { tools: Record[]; error?: string } => { + if (mode === 'upload') { + if (files.length === 0) { + return { tools: [], error: localize('com_tools_import_no_files') }; + } + return { tools: files.map((f) => f.manifest as Record) }; + } + const text = pasted.trim(); + if (!text) { + return { tools: [], error: localize('com_tools_import_no_paste') }; + } + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + return { tools: parsed as Record[] }; + } + if (parsed && typeof parsed === 'object') { + /* A catalog export `{tools: [...]}` is accepted too. */ + const inner = (parsed as { tools?: unknown }).tools; + if (Array.isArray(inner)) { + return { tools: inner as Record[] }; + } + return { tools: [parsed as Record] }; + } + return { tools: [], error: localize('com_tools_import_bad_json') }; + } catch (error) { + return { + tools: [], + error: `${localize('com_tools_import_bad_json')}: ${(error as Error).message}`, + }; + } + }; + + const preview = async () => { + const { tools, error } = collectTools(); + if (error || tools.length === 0) { + setParseError(error ?? localize('com_tools_import_no_files')); + return; + } + setParseError(null); + setBusy(true); + setRequestError(null); + try { + const response = await importToolsFn({ + data: { tools, overwrite, dryRun: true }, + }); + setPendingTools(tools); + setResults(response.results); + setStep('review'); + } catch (error) { + setRequestError((error as Error).message); + } finally { + setBusy(false); + } + }; + + const apply = async () => { + if (!pendingTools) { + return; + } + setBusy(true); + setRequestError(null); + try { + const response = await importToolsFn({ + data: { tools: pendingTools, overwrite, dryRun: false }, + }); + setResults(response.results); + setStep('done'); + } catch (error) { + setRequestError((error as Error).message); + } finally { + setBusy(false); + } + }; + + const skippedCount = results?.filter((r) => r.action === 'skipped').length ?? 0; + + return ( + { + if (!isOpen) { + close(); + } + }} + > + event.preventDefault()} + title={localize('com_tools_import_title')} + showClose + onClose={close} + className="modal-frost max-w-2xl!" + > + {step === 'input' && ( +
+

{localize('com_tools_import_hint')}

+ + + {localize('com_tools_import_upload')} + {localize('com_tools_import_paste')} + + + {mode === 'upload' ? ( +
+ + void readFiles(e.target.files)} + /> + {files.length > 0 && ( +
    + {files.map((f, i) => ( +
  • + {f.name} + + {(f.manifest as TerraVoxTool).tool_id ?? '—'} + + +
  • + ))} +
+ )} +
+ ) : ( +