From 5de2bb537a086c24726e571348b2b47d494c82f5 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Thu, 27 Aug 2026 14:05:35 +0000 Subject: [PATCH 1/3] feat: harden backup/restore UX, chat tool dock, SSO presets, and friendly provider errors Make backup ops resumable across refresh, dock plugin tool UIs beside chat, polish admin SSO setup, fix mobile table scrolling, and map balance/auth probe failures to localized guidance. Co-authored-by: Cursor --- dashboard/src/api/modules/backup.ts | 9 + .../src/context/BackupOperationContext.tsx | 235 ++++++ dashboard/src/hooks/useCardTableView.ts | 7 +- dashboard/src/layouts/MainLayout/index.tsx | 257 +++--- dashboard/src/layouts/PageShell.module.less | 7 +- dashboard/src/layouts/PageShell.tsx | 6 +- dashboard/src/locales/en.json | 45 +- dashboard/src/locales/zh.json | 47 +- .../src/pages/Admin/Users/SsoPanel.test.tsx | 41 +- dashboard/src/pages/Admin/Users/SsoPanel.tsx | 732 ++++++++++++++---- .../src/pages/Admin/Users/UsersListPanel.tsx | 2 +- .../src/pages/Admin/Users/index.module.less | 438 +++++++++++ .../Agent/Skills/components/SkillsTable.tsx | 30 +- .../src/pages/Chat/ChatToolDockContext.tsx | 70 ++ .../pages/Chat/chatBrowserPanel.partial.less | 33 + .../src/pages/Chat/chatMessages.partial.less | 98 +++ .../pages/Chat/components/ChatDockPanel.tsx | 53 ++ .../pages/Chat/components/ChatDockPanels.tsx | 6 + .../Chat/components/ChatDockToolUiContent.tsx | 45 ++ .../pages/Chat/components/MessageBubble.tsx | 103 ++- .../pages/Chat/hooks/useChatDockPanel.test.ts | 58 ++ .../src/pages/Chat/hooks/useChatDockPanel.ts | 55 +- .../Chat/hooks/useToolMessageByCallId.ts | 24 + .../Chat/hooks/useToolUiDockButtonStyle.ts | 73 ++ dashboard/src/pages/Chat/index.tsx | 670 ++++++++-------- .../src/pages/Chat/utils/dockToolUiTabId.ts | 4 + .../Control/CronJobs/components/columns.tsx | 9 +- .../pages/Control/CronJobs/index.module.less | 22 + .../src/pages/Control/CronJobs/index.tsx | 3 +- .../Experts/components/AgentExpertsTable.tsx | 11 +- dashboard/src/pages/Experts/index.module.less | 8 + dashboard/src/pages/KnowledgeBases/index.tsx | 7 +- .../pages/Settings/BackupRestore/index.tsx | 160 ++-- dashboard/src/pages/Settings/octop/Agents.tsx | 8 +- .../SkillPackages/PackageSkillsTable.tsx | 30 +- dashboard/src/plugins/toolRenderers/host.ts | 19 + dashboard/src/plugins/toolRenderers/index.ts | 1 + dashboard/src/plugins/toolRenderers/types.ts | 10 + dashboard/src/styles/layout.css | 15 +- dashboard/src/utils/chatStreamError.test.ts | 24 + dashboard/src/utils/chatStreamError.ts | 29 +- src/octop/api/routers/backup.py | 193 +++-- src/octop/api/routers/providers.py | 21 +- src/octop/api/routers/setup.py | 3 +- src/octop/cli/commands/backup.py | 41 +- src/octop/cli/support/embedded_ops.py | 4 +- src/octop/i18n/domains/stream.py | 23 + src/octop/i18n/en.json | 3 +- src/octop/i18n/zh.json | 3 +- src/octop/infra/agents/providers/probe.py | 32 +- src/octop/infra/backup/auto.py | 63 +- src/octop/infra/backup/store.py | 68 +- src/octop/infra/backup/system_archive.py | 374 +++++---- .../test_postgresql_control_plane.py | 6 +- .../unit/api/test_backup_restore_rehydrate.py | 86 +- tests/unit/backup/test_system_archive.py | 98 ++- tests/unit/i18n/test_stream.py | 35 + tests/unit/test_provider_fetch_models.py | 45 +- tests/unit/test_provider_probe.py | 33 +- 59 files changed, 3592 insertions(+), 1043 deletions(-) create mode 100644 dashboard/src/context/BackupOperationContext.tsx create mode 100644 dashboard/src/pages/Chat/ChatToolDockContext.tsx create mode 100644 dashboard/src/pages/Chat/components/ChatDockToolUiContent.tsx create mode 100644 dashboard/src/pages/Chat/hooks/useToolMessageByCallId.ts create mode 100644 dashboard/src/pages/Chat/hooks/useToolUiDockButtonStyle.ts create mode 100644 dashboard/src/pages/Chat/utils/dockToolUiTabId.ts diff --git a/dashboard/src/api/modules/backup.ts b/dashboard/src/api/modules/backup.ts index 712c8a01..2496ba83 100644 --- a/dashboard/src/api/modules/backup.ts +++ b/dashboard/src/api/modules/backup.ts @@ -19,9 +19,18 @@ export interface AutoBackupSettings { scheduled?: boolean; } +export type BackupOperationKind = "create" | "restore" | "auto" | "export"; + +export interface BackupStatusResponse { + busy: boolean; + operation: BackupOperationKind | null; +} + export const backupApi = { listBackups: () => request("/admin/backup/list"), + getStatus: () => request("/admin/backup/status"), + getAutoSettings: () => request("/admin/backup/auto"), updateAutoSettings: (body: { diff --git a/dashboard/src/context/BackupOperationContext.tsx b/dashboard/src/context/BackupOperationContext.tsx new file mode 100644 index 00000000..b6906767 --- /dev/null +++ b/dashboard/src/context/BackupOperationContext.tsx @@ -0,0 +1,235 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { App } from "antd"; +import { useTranslation } from "react-i18next"; + +import { + backupApi, + type BackupOperationKind, + type BackupStatusResponse, +} from "../api/modules/backup"; +import { apiErrorMessage } from "../utils/apiError"; + +export type BackupOpKind = BackupOperationKind | "upload" | null; + +interface BackupOperationContextValue { + kind: BackupOpKind; + restoreTarget: string | null; + uploadPercent: number | null; + /** True while create / restore / auto / export / upload is in flight. */ + busy: boolean; + creating: boolean; + restoring: boolean; + autoRunning: boolean; + createBackup: () => Promise; + runAutoBackup: () => Promise; + restoreBackup: (name: string, restoreConfig: boolean) => Promise; + uploadBackup: (file: File) => Promise; + /** Align UI with server lock (call from backup panel while mounted). */ + syncFromServer: () => Promise; + /** Called by the backup panel so list refresh can run after remote ops finish. */ + setOnSettled: (fn: (() => void) | null) => void; +} + +const BackupOperationContext = + createContext(null); + +function mapServerOperation( + op: BackupStatusResponse["operation"], +): BackupOpKind { + if (op === "create" || op === "auto" || op === "export") return op; + if (op === "restore") return "restore"; + return "create"; +} + +export function BackupOperationProvider({ children }: { children: ReactNode }) { + const { message } = App.useApp(); + const { t } = useTranslation(); + const [kind, setKind] = useState(null); + const [restoreTarget, setRestoreTarget] = useState(null); + const [uploadPercent, setUploadPercent] = useState(null); + /** Local request owns the kind; server poll must not clear it mid-flight. */ + const localOwnedRef = useRef(false); + const kindRef = useRef(null); + const onSettledRef = useRef<(() => void) | null>(null); + kindRef.current = kind; + + const setOnSettled = useCallback((fn: (() => void) | null) => { + onSettledRef.current = fn; + }, []); + + const notifySettled = useCallback(() => { + onSettledRef.current?.(); + }, []); + + const syncFromServer = useCallback(async () => { + try { + const status = await backupApi.getStatus(); + if (localOwnedRef.current) { + return; + } + if (status.busy) { + setKind((prev) => prev ?? mapServerOperation(status.operation)); + } else { + setKind((prev) => (prev === "upload" ? prev : null)); + setRestoreTarget(null); + } + } catch { + // Status is best-effort; ignore transient / permission errors. + } + }, []); + + const beginLocal = useCallback((next: BackupOpKind) => { + if (localOwnedRef.current || kindRef.current !== null) { + return false; + } + localOwnedRef.current = true; + setKind(next); + return true; + }, []); + + const createBackup = useCallback(async () => { + if (!beginLocal("create")) return false; + try { + await backupApi.createBackup(); + message.success(t("backup.createSuccess")); + notifySettled(); + return true; + } catch (err: unknown) { + message.error(apiErrorMessage(err, t("backup.createFailed"), t)); + return false; + } finally { + localOwnedRef.current = false; + setKind(null); + void syncFromServer(); + } + }, [beginLocal, message, notifySettled, syncFromServer, t]); + + const runAutoBackup = useCallback(async () => { + if (!beginLocal("auto")) return false; + try { + await backupApi.runAutoBackup(); + message.success(t("backup.autoRunSuccess")); + notifySettled(); + return true; + } catch (err: unknown) { + message.error(apiErrorMessage(err, t("backup.autoRunFailed"), t)); + return false; + } finally { + localOwnedRef.current = false; + setKind(null); + void syncFromServer(); + } + }, [beginLocal, message, notifySettled, syncFromServer, t]); + + const restoreBackup = useCallback( + async (name: string, restoreConfig: boolean) => { + if (!beginLocal("restore")) return false; + setRestoreTarget(name); + try { + const result = await backupApi.restoreBackup(name, restoreConfig); + message.success( + t("backup.importSuccess", { + agents: result.agents, + files: result.workspace_files, + }), + ); + notifySettled(); + return true; + } catch (err: unknown) { + message.error(apiErrorMessage(err, t("backup.importFailed"), t)); + return false; + } finally { + localOwnedRef.current = false; + setKind(null); + setRestoreTarget(null); + void syncFromServer(); + } + }, + [beginLocal, message, notifySettled, syncFromServer, t], + ); + + const uploadBackup = useCallback( + async (file: File) => { + if (!beginLocal("upload")) return false; + setUploadPercent(0); + try { + await backupApi.uploadBackup(file, (p) => setUploadPercent(p)); + setUploadPercent(100); + message.success(t("backup.uploadSuccess", { name: file.name })); + notifySettled(); + return true; + } catch (err: unknown) { + const detail = err instanceof Error ? err.message : String(err); + message.error(detail || t("backup.uploadFailed")); + return false; + } finally { + localOwnedRef.current = false; + setKind(null); + setUploadPercent(null); + } + }, + [beginLocal, message, notifySettled, t], + ); + + const creating = kind === "create" || kind === "export"; + const restoring = kind === "restore"; + const autoRunning = kind === "auto"; + const busy = kind !== null; + + const value = useMemo( + () => ({ + kind, + restoreTarget, + uploadPercent, + busy, + creating, + restoring, + autoRunning, + createBackup, + runAutoBackup, + restoreBackup, + uploadBackup, + syncFromServer, + setOnSettled, + }), + [ + kind, + restoreTarget, + uploadPercent, + busy, + creating, + restoring, + autoRunning, + createBackup, + runAutoBackup, + restoreBackup, + uploadBackup, + syncFromServer, + setOnSettled, + ], + ); + + return ( + + {children} + + ); +} + +export function useBackupOperation(): BackupOperationContextValue { + const ctx = useContext(BackupOperationContext); + if (!ctx) { + throw new Error( + "useBackupOperation must be used within BackupOperationProvider", + ); + } + return ctx; +} diff --git a/dashboard/src/hooks/useCardTableView.ts b/dashboard/src/hooks/useCardTableView.ts index 4ea30705..bc184bc1 100644 --- a/dashboard/src/hooks/useCardTableView.ts +++ b/dashboard/src/hooks/useCardTableView.ts @@ -4,12 +4,13 @@ import { useIsMobile } from "./useIsMobile"; export type CardTableViewMode = "card" | "table"; /** - * Shared card/table view toggle. On mobile, card view is always shown - * (matches Tasks page behaviour) regardless of Segmented selection. + * Shared card/table view toggle. + * The selected mode is honoured on all viewports (including mobile); + * tables rely on existing horizontal-scroll CSS in layout.css. */ export function useCardTableView(defaultMode: CardTableViewMode = "table") { const isMobile = useIsMobile(); const [viewMode, setViewMode] = useState(defaultMode); - const showCardView = isMobile || viewMode === "card"; + const showCardView = viewMode === "card"; return { isMobile, viewMode, setViewMode, showCardView }; } diff --git a/dashboard/src/layouts/MainLayout/index.tsx b/dashboard/src/layouts/MainLayout/index.tsx index 87436f11..f6b8687f 100644 --- a/dashboard/src/layouts/MainLayout/index.tsx +++ b/dashboard/src/layouts/MainLayout/index.tsx @@ -5,6 +5,7 @@ import Sidebar from "../Sidebar"; import Header from "../Header"; import RailEdgeControl from "../../components/RailEdgeControl"; import { ServiceRestartProvider } from "../../context/ServiceRestartContext"; +import { BackupOperationProvider } from "../../context/BackupOperationContext"; import PwaUpdatePrompt from "../../components/PwaUpdatePrompt"; import { PwaAutoPrompt } from "../../components/PwaInstallPrompt"; import { @@ -187,181 +188,183 @@ export default function MainLayout() { return ( -
- {/* Mobile overlay backdrop */} - {isMobile && !collapsed && ( -
- )} - +
- - {!isMobile && ( - )} -
- {isChatRoute && !isMinimalLayout && (
- )} - - {/* Right column: mobile header (if any) + page content */} -
- {isMobile && - !( - SELF_HEADER_PATHS.has(currentPath) || - [...SELF_HEADER_PATHS].some((p) => - currentPath.startsWith(p + "/"), - ) - ) && ( -
+ + {!isMobile && ( + )} +
- + )} + + {/* Right column: mobile header (if any) + page content */} +
- + currentPath.startsWith(p + "/"), + ) + ) && ( +
+ )} + + - - - - {workbenchMounted && ( -
- - - -
- } - > - - - -
- )} - - {/* Keep Routes mounted when visiting Workbench so leaving/re-entering - does not remount every lazy page (lag + lost UI state). */} -
- {isFullscreen ? ( + + + + {workbenchMounted && (
- {routes} + + + +
+ } + > + + +
- ) : ( -
{routes}
)} -
- - + + {/* Keep Routes mounted when visiting Workbench so leaving/re-entering + does not remount every lazy page (lag + lost UI state). */} +
+ {isFullscreen ? ( +
+ {routes} +
+ ) : ( +
{routes}
+ )} +
+ + +
- +
); } diff --git a/dashboard/src/layouts/PageShell.module.less b/dashboard/src/layouts/PageShell.module.less index 259a1d7e..d201bc68 100644 --- a/dashboard/src/layouts/PageShell.module.less +++ b/dashboard/src/layouts/PageShell.module.less @@ -83,7 +83,8 @@ @media (max-width: 767px) { flex: none; - overflow: visible; + overflow-x: hidden; + overflow-y: visible; :global(.octop-tabs-content-holder), :global(.ant-tabs-content-holder), @@ -92,7 +93,9 @@ :global(.octop-tabs-tabpane), :global(.ant-tabs-tabpane) { height: auto !important; - overflow: visible; + max-width: 100%; + overflow-x: hidden; + overflow-y: visible; } } } diff --git a/dashboard/src/layouts/PageShell.tsx b/dashboard/src/layouts/PageShell.tsx index b6cebfee..0e81f416 100644 --- a/dashboard/src/layouts/PageShell.tsx +++ b/dashboard/src/layouts/PageShell.tsx @@ -170,8 +170,12 @@ function PageShell({ background: "var(--fn-bg-container, var(--fn-bg-elevated))", borderRadius: 8, padding: contentPad, - overflow: pinBody ? "hidden" : "auto", + // Mobile: never create a page-level horizontal scrollbar; wide + // tables scroll via antd scroll.x inside their own wrapper. + overflowX: pinBody || isMobile ? "hidden" : "auto", + overflowY: pinBody ? "hidden" : "auto", minHeight: 0, + minWidth: 0, display: pinBody ? "flex" : undefined, flexDirection: pinBody ? "column" : undefined, }} diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index e7d448ea..ba8fdb7d 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -69,7 +69,7 @@ "SETUP_REQUIRED": "Initial setup is required.", "DATABASE_NOT_EMPTY": "The target database already has users. Use an empty database, or log in with the existing admin.", "BACKUP_DRIVER_MISMATCH": "This backup was made with a different database engine (SQLite vs PostgreSQL). Switch the runtime to match the backup, or create a new backup on the current engine. Cross-engine restore is not supported.", - "BACKUP_IN_PROGRESS": "A backup is already in progress. Try again shortly.", + "BACKUP_IN_PROGRESS": "A backup or restore is already in progress. Try again shortly.", "FORBIDDEN": "Permission denied.", "NOT_FOUND": "Not found.", "USER_DISABLED": "This account has been disabled.", @@ -998,6 +998,7 @@ "stream_stall": "The model stopped sending content while the connection stayed open. Click Retry, or try again later with another model. If this happens often, check the provider status or ask an admin to review stream timeout settings.", "rate_limit": "The model provider rate-limited this request. Wait a moment and retry, or switch to another model.", "auth": "Model authentication failed. Check that the API key under Settings → Models is correct and still valid.", + "insufficient_balance": "The model provider rejected this request due to insufficient balance or quota. Top up or upgrade the plan for this API key, then try again.", "context_length": "This conversation is too long for the model's context window. Start a new chat, or shorten the history and try again.", "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", @@ -1177,6 +1178,11 @@ "remoteBrowserTitle": "Remote Browser", "dockTerminalTitle": "Terminal", "dockPhoneTitle": "Remote Phone", + "dockToolUiTitle": "Plugin tool", + "dockToolUiMissing": "Tool result is no longer available in this conversation.", + "openToolUiInDock": "Open in side panel", + "toolUiDockedHint": "Moved to side panel", + "toolUiDockedOpen": "Open side panel", "loadEarlierMessages": "Load earlier messages" }, "tools": { @@ -1347,6 +1353,7 @@ "storedTitle": "Backup files", "storedDesc": "System backups are stored in the directory below. Download or restore with one click.", "createButton": "Create backup", + "creating": "Backing up…", "uploadButton": "Upload backup", "uploadSuccess": "Saved {{name}}", "uploadFailed": "Upload failed", @@ -1373,9 +1380,9 @@ "importButton": "Choose backup file", "uploading": "Uploading… {{percent}}%", "restoring": "Restoring…", - "importWarning": "Restore overwrites the current database and local workspaces, then hot-reloads models, experts, and channels. If you also restore config/env, restart the service manually for those to fully apply. This cannot be undone.", + "importWarning": "Restore overwrites the current database and local workspaces, then hot-reloads models, experts, and channels. While restore runs, database-backed APIs may be briefly unavailable — prefer a maintenance window. If you also restore config/env, restart the service manually for those to fully apply. This cannot be undone.", "importConfirmTitle": "Confirm restore", - "importConfirmBody": "Restore from “{{name}}”? Existing data will be overwritten. Models, experts, and channels are hot-reloaded afterward (no service restart). If you restore config/env, restart manually for those settings to take full effect.", + "importConfirmBody": "Restore from “{{name}}”? Existing data will be overwritten, and the service may be briefly unavailable while the database is replaced. Models, experts, and channels are hot-reloaded afterward (no service restart). If you restore config/env, restart manually for those settings to take full effect.", "importConfirmOk": "Restore", "importSuccess": "Restore complete ({{agents}} agents, {{files}} workspace files)", "importFailed": "Failed to restore backup", @@ -4198,35 +4205,67 @@ "modalEditTitle": "Edit user {{username}}" }, "adminSso": { + "panelTitle": "Single sign-on", + "panelDesc": "Connect an OpenID Connect identity provider so users can sign in with your organization account.", + "statusEnabled": "Enabled · {{name}}", + "statusDisabled": "Disabled", + "statusUnnamed": "Provider", + "guideStep1": "Fill issuer & client", + "guideStep2": "Copy redirect URI", + "guideStep3": "Save settings", + "guideStep4": "Test discovery", + "guideStep5": "Enable SSO", + "guideTitle": "Setup checklist", "enabled": "Enable single sign-on", "enabledHint": "Allow users to sign in through the configured OpenID Connect provider.", + "loginPreview": "Login page preview", + "loginPreviewDisabled": "This button appears on the login page only when SSO is enabled.", + "sectionProvider": "Identity provider", + "sectionProviderHint": "Choose a preset to fill defaults, then enter your issuer URL.", + "presetsLabel": "Quick start", + "presetAzure": "Azure AD", + "presetGoogle": "Google", + "presetKeycloak": "Keycloak", + "presetOkta": "Okta", "displayName": "Provider display name", + "displayNamePlaceholder": "Shown on the login button", "displayNameRequired": "Enter a provider display name", "issuer": "Issuer URL", "issuerHint": "The OpenID Connect issuer URL published by your identity provider.", "issuerRequired": "Enter a valid issuer URL", + "sectionCredentials": "OAuth credentials", + "sectionCredentialsHint": "Client ID and secret from your identity provider application registration.", "clientId": "Client ID", "clientIdRequired": "Enter the client ID", "clientSecret": "Client secret", "clientSecretHint": "Optional for public clients.", "clientSecretConfigured": "A client secret is configured. Leave blank to keep it unchanged.", + "clientSecretConfiguredTag": "Configured", "clientSecretPlaceholder": "Leave blank to keep the current secret", "scopes": "Scopes", + "scopesPlaceholder": "openid profile email", "scopesRequired": "Enter at least one scope", + "sectionAdvanced": "Advanced options", "dashboardOrigin": "Dashboard origin override", "dashboardOriginHint": "Optional public dashboard URL used after the identity provider redirects back.", "dashboardOriginInvalid": "Enter a valid dashboard origin URL", "redirectUri": "Redirect URI", "redirectUriHint": "Add this exact callback URL to your identity provider configuration.", + "redirectUriEmpty": "Save once to generate the redirect URI", + "redirectUriDocs": "In Azure AD, Google, Keycloak, or Okta, register this URL as an allowed redirect / callback URI.", "copy": "Copy", + "copied": "Copied", "copyRedirectUri": "Copy redirect URI", "copySuccess": "Redirect URI copied", "copyFailed": "Could not copy redirect URI", "save": "Save", + "discard": "Discard", + "unsavedChanges": "You have unsaved changes.", "saved": "Single sign-on settings saved", "saveFailed": "Could not save single sign-on settings", "loadFailed": "Could not load single sign-on settings", "testConnection": "Test connection", + "testNeedsSave": "Save your changes before testing.", "testSuccess": "OIDC connection succeeded", "testFailed": "OIDC connection test failed", "testHint": "Save changes before testing a new issuer or client ID." diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 748f30ac..27dac422 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -69,7 +69,7 @@ "SETUP_REQUIRED": "需要完成初始设置。", "DATABASE_NOT_EMPTY": "目标数据库已有用户。请改用空库,或直接登录现有管理员账户。", "BACKUP_DRIVER_MISMATCH": "该备份与当前数据库引擎不一致(SQLite 与 PostgreSQL 不能互恢)。请将运行时切回备份所用引擎后再恢复,或在当前引擎上重新备份。暂不支持跨引擎恢复。", - "BACKUP_IN_PROGRESS": "已有备份任务正在进行,请稍后再试。", + "BACKUP_IN_PROGRESS": "已有备份或恢复任务正在进行,请稍后再试。", "FORBIDDEN": "没有权限。", "NOT_FOUND": "未找到。", "USER_DISABLED": "账号已禁用。", @@ -998,6 +998,7 @@ "stream_stall": "模型响应中断:连接仍在,但长时间没有新内容。请点击「重试」,或稍后更换模型再试。若经常出现,请检查供应商状态,或联系管理员排查流式超时设置。", "rate_limit": "模型请求过于频繁,已被限流。请稍等片刻后重试,或切换其他模型。", "auth": "模型服务鉴权失败。请检查「设置 → 模型」中的 API Key 是否正确、是否过期。", + "insufficient_balance": "模型服务返回余额或额度不足。请为该 API Key 对应的账户充值或升级套餐后再试。", "context_length": "对话上下文过长,超出模型限制。请新开会话,或精简历史后再试。", "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", @@ -1177,6 +1178,11 @@ "remoteBrowserTitle": "远程浏览器", "dockTerminalTitle": "终端", "dockPhoneTitle": "远程手机", + "dockToolUiTitle": "插件工具", + "dockToolUiMissing": "该工具结果已不在当前对话中。", + "openToolUiInDock": "在侧栏打开", + "toolUiDockedHint": "已移到侧栏", + "toolUiDockedOpen": "打开侧栏", "loadEarlierMessages": "加载更早的消息" }, "tools": { @@ -1346,6 +1352,7 @@ "storedTitle": "备份文件", "storedDesc": "系统备份保存在以下目录,可下载或一键恢复。", "createButton": "新建备份", + "creating": "备份中…", "uploadButton": "上传备份", "uploadSuccess": "已保存 {{name}}", "uploadFailed": "上传失败", @@ -1371,10 +1378,10 @@ "importDesc": "从先前导出的 .tar.gz 归档恢复系统。建议在维护窗口操作。", "importButton": "选择备份文件", "uploading": "正在上传… {{percent}}%", - "restoring": "正在恢复…", - "importWarning": "恢复将覆盖当前数据库与本地工作区,并热加载模型、专家与通道。若同时恢复了 config/env,完整生效仍需手动重启服务。此操作不可撤销。", + "restoring": "恢复中…", + "importWarning": "恢复将覆盖当前数据库与本地工作区,并热加载模型、专家与通道。恢复期间依赖数据库的接口可能短暂不可用,建议在维护窗口操作。若同时恢复了 config/env,完整生效仍需手动重启服务。此操作不可撤销。", "importConfirmTitle": "确认恢复备份", - "importConfirmBody": "确定从「{{name}}」恢复?这将覆盖现有数据。恢复后会热加载模型、专家与通道,无需重启服务;若勾选恢复配置,config/env 需重启后才完全生效。", + "importConfirmBody": "确定从「{{name}}」恢复?这将覆盖现有数据,替换数据库期间服务可能短暂不可用。恢复后会热加载模型、专家与通道,无需重启服务;若勾选恢复配置,config/env 需重启后才完全生效。", "importConfirmOk": "恢复", "importSuccess": "恢复完成({{agents}} 个 Agent,{{files}} 个工作区文件)", "importFailed": "恢复备份失败", @@ -4337,35 +4344,67 @@ "modalEditTitle": "编辑用户 {{username}}" }, "adminSso": { + "panelTitle": "单点登录", + "panelDesc": "接入 OpenID Connect 身份提供商,让用户使用组织账号登录。", + "statusEnabled": "已启用 · {{name}}", + "statusDisabled": "未启用", + "statusUnnamed": "提供商", + "guideStep1": "填写签发者与客户端", + "guideStep2": "复制回调地址", + "guideStep3": "保存配置", + "guideStep4": "测试发现", + "guideStep5": "启用 SSO", + "guideTitle": "配置清单", "enabled": "启用单点登录", "enabledHint": "允许用户通过已配置的 OpenID Connect 身份提供商登录。", + "loginPreview": "登录页预览", + "loginPreviewDisabled": "仅在启用 SSO 后,登录页才会显示此按钮。", + "sectionProvider": "身份提供商", + "sectionProviderHint": "可先选择预设填充默认值,再填写签发者 URL。", + "presetsLabel": "快速开始", + "presetAzure": "Azure AD", + "presetGoogle": "Google", + "presetKeycloak": "Keycloak", + "presetOkta": "Okta", "displayName": "提供商显示名称", + "displayNamePlaceholder": "显示在登录按钮上", "displayNameRequired": "请输入提供商显示名称", "issuer": "签发者 URL", "issuerHint": "身份提供商公开的 OpenID Connect 签发者 URL。", "issuerRequired": "请输入有效的签发者 URL", + "sectionCredentials": "OAuth 凭证", + "sectionCredentialsHint": "来自身份提供商应用注册的客户端 ID 与密钥。", "clientId": "客户端 ID", "clientIdRequired": "请输入客户端 ID", "clientSecret": "客户端密钥", "clientSecretHint": "公共客户端可不填写。", "clientSecretConfigured": "已配置客户端密钥;留空将保留现有密钥。", + "clientSecretConfiguredTag": "已配置", "clientSecretPlaceholder": "留空以保留现有密钥", "scopes": "授权范围", + "scopesPlaceholder": "openid profile email", "scopesRequired": "请至少输入一个授权范围", + "sectionAdvanced": "高级选项", "dashboardOrigin": "控制台来源地址覆盖", "dashboardOriginHint": "可选。身份提供商回调后使用的公开控制台 URL。", "dashboardOriginInvalid": "请输入有效的控制台来源地址 URL", "redirectUri": "回调地址", "redirectUriHint": "请将此精确回调 URL 添加到身份提供商配置中。", + "redirectUriEmpty": "保存一次后生成回调地址", + "redirectUriDocs": "在 Azure AD、Google、Keycloak 或 Okta 中,将此 URL 注册为允许的重定向 / 回调地址。", "copy": "复制", + "copied": "已复制", "copyRedirectUri": "复制回调地址", "copySuccess": "回调地址已复制", "copyFailed": "无法复制回调地址", "save": "保存", + "discard": "放弃更改", + "unsavedChanges": "有未保存的更改。", "saved": "单点登录设置已保存", "saveFailed": "无法保存单点登录设置", "loadFailed": "无法加载单点登录设置", "testConnection": "测试连接", + "testNeedsSave": "请先保存更改再测试。", "testSuccess": "OIDC 连接成功", "testFailed": "OIDC 连接测试失败", "testHint": "更改签发者或客户端 ID 后,请先保存再测试。" diff --git a/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx b/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx index 93cbc50a..d563d94c 100644 --- a/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx +++ b/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx @@ -1,7 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; -import { I18nextProvider } from "react-i18next"; -import i18n from "../../../i18n"; +import userEvent from "@testing-library/user-event"; const { getOidcConfig, putOidcConfig, testOidcConfig } = vi.hoisted(() => ({ getOidcConfig: vi.fn(), @@ -14,12 +13,16 @@ vi.mock("../../../api/modules/sso", () => ({ })); vi.mock("@/utils/antdMessage", () => ({ - message: { error: vi.fn(), success: vi.fn() }, + message: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, })); import SsoPanel from "./SsoPanel"; describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("loads the provider configuration and displays its callback URL", async () => { getOidcConfig.mockResolvedValue({ enabled: true, @@ -32,11 +35,7 @@ describe("", () => { redirect_uri: "https://octop.example.com/api/auth/oidc/callback", }); - render( - - - , - ); + render(); await waitFor(() => expect(getOidcConfig).toHaveBeenCalledOnce()); expect(screen.getByDisplayValue("Acme SSO")).toBeInTheDocument(); @@ -45,5 +44,29 @@ describe("", () => { "https://octop.example.com/api/auth/oidc/callback", ), ).toBeInTheDocument(); + // Mocked t() returns the key; interpolation keeps {{name}} unless options used. + expect(screen.getByText("adminSso.statusEnabled")).toBeInTheDocument(); + }); + + it("applies an IdP preset into display name", async () => { + const user = userEvent.setup(); + getOidcConfig.mockResolvedValue({ + enabled: false, + display_name: "", + issuer: "", + client_id: "", + scopes: "openid profile email", + dashboard_origin: null, + has_client_secret: false, + redirect_uri: "", + }); + + render(); + + await waitFor(() => expect(getOidcConfig).toHaveBeenCalledOnce()); + await user.click( + screen.getByRole("button", { name: "adminSso.presetGoogle" }), + ); + expect(screen.getByDisplayValue("Google")).toBeInTheDocument(); }); }); diff --git a/dashboard/src/pages/Admin/Users/SsoPanel.tsx b/dashboard/src/pages/Admin/Users/SsoPanel.tsx index af387dae..ffeb62ca 100644 --- a/dashboard/src/pages/Admin/Users/SsoPanel.tsx +++ b/dashboard/src/pages/Admin/Users/SsoPanel.tsx @@ -1,6 +1,34 @@ -import { useCallback, useEffect, useState } from "react"; -import { Button, Form, Input, Space, Spin, Switch, Typography } from "antd"; -import { Copy, FlaskConical, Save } from "lucide-react"; +import { + useCallback, + useEffect, + useRef, + useState, + type ChangeEvent, +} from "react"; +import { + Alert, + Button, + Collapse, + Form, + Input, + Select, + Space, + Spin, + Switch, + Tag, + Tooltip, + Typography, +} from "antd"; +import { + Check, + CheckCircle2, + Copy, + FlaskConical, + KeyRound, + Lock, + Save, + XCircle, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { message } from "@/utils/antdMessage"; import { @@ -10,6 +38,8 @@ import { } from "../../../api/modules/sso"; import { apiErrorMessage } from "../../../utils/apiError"; import { copyText } from "../../../utils/copyText"; +import { TabPanelHeader } from "../../Settings/AdvancedSettings/TabPanelHeader"; +import styles from "./index.module.less"; interface SsoFormValues { enabled: boolean; @@ -17,21 +47,83 @@ interface SsoFormValues { issuer: string; client_id: string; client_secret?: string; - scopes: string; + scopes: string[]; dashboard_origin?: string; } +type TestResult = { ok: boolean; detail: string } | null; + +type IdpPresetId = "azure" | "google" | "keycloak" | "okta"; + +interface IdpPreset { + id: IdpPresetId; + labelKey: string; + displayName: string; + scopes: string[]; + issuerPlaceholder: string; +} + +const IDP_PRESETS: IdpPreset[] = [ + { + id: "azure", + labelKey: "adminSso.presetAzure", + displayName: "Microsoft", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://login.microsoftonline.com/{tenant}/v2.0", + }, + { + id: "google", + labelKey: "adminSso.presetGoogle", + displayName: "Google", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://accounts.google.com", + }, + { + id: "keycloak", + labelKey: "adminSso.presetKeycloak", + displayName: "Keycloak", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://keycloak.example.com/realms/{realm}", + }, + { + id: "okta", + labelKey: "adminSso.presetOkta", + displayName: "Okta", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://{domain}.okta.com", + }, +]; + +const SCOPE_OPTIONS = ["openid", "profile", "email", "offline_access"].map( + (value) => ({ value, label: value }), +); + +const GUIDE_STEPS = [ + "adminSso.guideStep1", + "adminSso.guideStep2", + "adminSso.guideStep3", + "adminSso.guideStep4", + "adminSso.guideStep5", +] as const; + function configToFormValues(config: OidcConfig): SsoFormValues { return { enabled: config.enabled, display_name: config.display_name, issuer: config.issuer, client_id: config.client_id, - scopes: config.scopes, + scopes: config.scopes + .split(/\s+/) + .map((s) => s.trim()) + .filter(Boolean), dashboard_origin: config.dashboard_origin ?? "", }; } +function normalizeIssuer(raw: string): string { + return raw.trim().replace(/\/+$/, ""); +} + export default function SsoPanel() { const { t } = useTranslation(); const [form] = Form.useForm(); @@ -40,40 +132,85 @@ export default function SsoPanel() { const [testing, setTesting] = useState(false); const [redirectUri, setRedirectUri] = useState(""); const [hasClientSecret, setHasClientSecret] = useState(false); + const [dirty, setDirty] = useState(false); + const [copied, setCopied] = useState(false); + const [testResult, setTestResult] = useState(null); + const [issuerPlaceholder, setIssuerPlaceholder] = useState( + "https://identity.example.com", + ); + const [activePreset, setActivePreset] = useState(null); + const hydratingRef = useRef(false); + + const enabled = Form.useWatch("enabled", form) ?? false; + const displayName = Form.useWatch("display_name", form) ?? ""; + const issuer = Form.useWatch("issuer", form) ?? ""; + const clientId = Form.useWatch("client_id", form) ?? ""; + + const applyConfig = useCallback( + (config: OidcConfig) => { + hydratingRef.current = true; + form.setFieldsValue(configToFormValues(config)); + form.setFieldValue("client_secret", undefined); + setRedirectUri(config.redirect_uri ?? ""); + setHasClientSecret(config.has_client_secret); + setDirty(false); + setTestResult(null); + setActivePreset(null); + queueMicrotask(() => { + hydratingRef.current = false; + }); + }, + [form], + ); const loadConfig = useCallback(async () => { setLoading(true); try { const config = await ssoApi.getOidcConfig(); - form.setFieldsValue(configToFormValues(config)); - setRedirectUri(config.redirect_uri ?? ""); - setHasClientSecret(config.has_client_secret); + applyConfig(config); } catch (error) { message.error(apiErrorMessage(error, t("adminSso.loadFailed"), t)); } finally { setLoading(false); } - }, [form, t]); + }, [applyConfig, t]); useEffect(() => { - void loadConfig(); - }, [loadConfig]); + let cancelled = false; + setLoading(true); + void (async () => { + try { + const config = await ssoApi.getOidcConfig(); + if (cancelled) return; + applyConfig(config); + } catch (error) { + if (cancelled) return; + message.error(apiErrorMessage(error, t("adminSso.loadFailed"), t)); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + // Mount-only load; reload goes through loadConfig(). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const saveConfig = async (values: SsoFormValues) => { setSaving(true); try { const body: OidcConfigPut = { - ...values, + enabled: values.enabled, + display_name: values.display_name.trim(), + issuer: normalizeIssuer(values.issuer), + client_id: values.client_id.trim(), client_secret: values.client_secret?.trim() || undefined, + scopes: values.scopes.join(" "), dashboard_origin: values.dashboard_origin?.trim() || null, }; const saved = await ssoApi.putOidcConfig(body); - form.setFieldsValue(configToFormValues(saved)); - form.setFieldValue("client_secret", undefined); - setHasClientSecret(saved.has_client_secret); - if (saved.redirect_uri) { - setRedirectUri(saved.redirect_uri); - } + applyConfig(saved); message.success(t("adminSso.saved")); } catch (error) { message.error(apiErrorMessage(error, t("adminSso.saveFailed"), t)); @@ -83,146 +220,455 @@ export default function SsoPanel() { }; const testConnection = async () => { + if (dirty) { + message.warning(t("adminSso.testNeedsSave")); + return; + } setTesting(true); + setTestResult(null); try { const result = await ssoApi.testOidcConfig(); - if (result.ok) { - message.success(result.detail || t("adminSso.testSuccess")); - } else { - message.error(result.detail || t("adminSso.testFailed")); - } + const detail = + result.detail || + (result.ok ? t("adminSso.testSuccess") : t("adminSso.testFailed")); + setTestResult({ ok: result.ok, detail }); + if (result.ok) message.success(detail); + else message.error(detail); } catch (error) { - message.error(apiErrorMessage(error, t("adminSso.testFailed"), t)); + const detail = apiErrorMessage(error, t("adminSso.testFailed"), t); + setTestResult({ ok: false, detail }); + message.error(detail); } finally { setTesting(false); } }; const copyRedirectUri = async () => { + if (!redirectUri) return; const ok = await copyText(redirectUri); - if (ok) message.success(t("adminSso.copySuccess")); - else message.error(t("adminSso.copyFailed")); + if (ok) { + message.success(t("adminSso.copySuccess")); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } else { + message.error(t("adminSso.copyFailed")); + } + }; + + const applyPreset = (preset: IdpPreset) => { + form.setFieldsValue({ + display_name: preset.displayName, + scopes: preset.scopes, + }); + setIssuerPlaceholder(preset.issuerPlaceholder); + setActivePreset(preset.id); + setDirty(true); + setTestResult(null); }; + const guideStep = (() => { + if (!issuer.trim() || !clientId.trim()) return 0; + if (!redirectUri) return 1; + if (dirty) return 2; + if (!testResult?.ok) return 3; + if (!enabled) return 4; + return 5; + })(); + + const statusLabel = enabled + ? t("adminSso.statusEnabled", { + name: displayName.trim() || t("adminSso.statusUnnamed"), + }) + : t("adminSso.statusDisabled"); + return ( - - - form={form} - layout="vertical" - requiredMark={false} - onFinish={(values) => void saveConfig(values)} - style={{ maxWidth: 680 }} - initialValues={{ enabled: false, scopes: "openid profile email" }} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + {t("adminSso.redirectUriDocs")} + + + + {testResult && ( + + ) : ( + + ) + } + message={ + testResult.ok + ? t("adminSso.testSuccess") + : t("adminSso.testFailed") + } + description={testResult.detail} + closable + onClose={() => setTestResult(null)} + /> + )} + + + + form={form} + layout="vertical" + requiredMark={false} + onFinish={(values) => void saveConfig(values)} + onValuesChange={() => { + if (hydratingRef.current) return; + setDirty(true); + setTestResult(null); + }} + initialValues={{ + enabled: false, + scopes: ["openid", "profile", "email"], + }} + className={styles.ssoForm} > - {t("adminSso.testConnection")} - - - - {t("adminSso.testHint")} - - - +
+
+
+
+ {t("adminSso.enabled")} +
+

+ {t("adminSso.enabledHint")} +

+
+ + + +
+
+ +
+
+

+ {t("adminSso.sectionProvider")} +

+

+ {t("adminSso.sectionProviderHint")} +

+
+ +
+ + {t("adminSso.presetsLabel")} + +
+ {IDP_PRESETS.map((preset) => ( + + ))} +
+
+ + + + + ) => + e.target.value + } + > + { + const next = normalizeIssuer(e.target.value); + if (next !== e.target.value) { + form.setFieldValue("issuer", next); + if (!hydratingRef.current) setDirty(true); + } + }} + /> + +
+ +
+
+

+ {t("adminSso.sectionCredentials")} +

+

+ {t("adminSso.sectionCredentialsHint")} +

+
+
+ + + + + {t("adminSso.clientSecret")} + {hasClientSecret && ( + + + {t("adminSso.clientSecretConfiguredTag")} + + )} + + } + extra={ + hasClientSecret + ? t("adminSso.clientSecretConfigured") + : t("adminSso.clientSecretHint") + } + > + + +
+ { + if (!value || value.length === 0) { + throw new Error(t("adminSso.scopesRequired")); + } + }, + }, + ]} + > + + + ), + }, + ]} + /> + +
+
+ + + {dirty && ( + + )} +
+
+ {dirty ? ( + + {t("adminSso.unsavedChanges")} + + ) : ( + + {t("adminSso.testHint")} + + )} +
+
+ + + + ); } diff --git a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx index f88c9d60..0129127a 100644 --- a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx +++ b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx @@ -2,7 +2,7 @@ * Admin → Users page (plan §14.7). * * List all users with role/disabled toggles, password reset, delete. - * Card and table views; default is card on mobile, table on desktop. The view switcher + refresh + + * Card and table views (default table). The view switcher + refresh + * new-user buttons live in a content-area toolbar (mirrors the Experts * page layout). Each row/card shows agent count; click opens a drawer * with that user's agents. diff --git a/dashboard/src/pages/Admin/Users/index.module.less b/dashboard/src/pages/Admin/Users/index.module.less index f31958f3..8e5fa8d2 100644 --- a/dashboard/src/pages/Admin/Users/index.module.less +++ b/dashboard/src/pages/Admin/Users/index.module.less @@ -993,3 +993,441 @@ gap: 2px; font-size: 12px; } + +/* ── SSO panel ──────────────────────────────────────────────────── */ + +.ssoPanel { + width: 100%; + min-width: 0; + padding-bottom: 88px; +} + +.ssoLayout { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + align-items: start; + + @media (min-width: 1100px) { + grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); + gap: 24px; + } +} + +.ssoAside { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + order: -1; + + @media (min-width: 1100px) { + order: 0; + position: sticky; + top: 8px; + /* Place aside in the second column */ + grid-column: 2; + grid-row: 1; + } +} + +.ssoForm { + min-width: 0; + + @media (min-width: 1100px) { + grid-column: 1; + grid-row: 1; + } + + :global(.ant-form-item) { + margin-bottom: 16px; + } + + :global(.ant-form-item-label > label) { + color: var(--fn-text-secondary); + font-weight: 500; + } +} + +.ssoAsideTitle { + margin: 0 0 10px; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.ssoAsideCard { + padding: 14px 16px; + border: 1px solid var(--fn-border-primary); + border-radius: var(--fn-radius-lg, 12px); + background: var(--fn-bg-primary); +} + +.ssoFieldGrid { + display: grid; + grid-template-columns: 1fr; + gap: 0 16px; + + @media (min-width: 720px) { + grid-template-columns: 1fr 1fr; + } +} + +.ssoStatusTagOn, +.ssoStatusTagOff { + display: inline-flex !important; + align-items: center; + gap: 6px; + margin: 0 !important; + padding: 2px 10px !important; + border-radius: 999px !important; + font-size: 12px !important; + font-weight: 500 !important; + line-height: 1.5 !important; + border: 1px solid transparent !important; +} + +.ssoStatusTagOn { + color: var(--fn-color-brand) !important; + background: var(--fn-color-brand-bg) !important; + border-color: var( + --fn-color-brand-border, + color-mix(in srgb, var(--fn-color-brand) 28%, transparent) + ) !important; +} + +.ssoStatusTagOff { + color: var(--fn-text-tertiary) !important; + background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)) !important; + border-color: var(--fn-border-primary) !important; +} + +.ssoStatusDotOn, +.ssoStatusDotOff { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.ssoStatusDotOn { + background: var(--fn-color-brand); +} + +.ssoStatusDotOff { + background: var(--fn-text-tertiary); +} + +.ssoGuide { + margin: 0; + padding: 14px 16px; + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-lg, 12px); + background: var(--fn-bg-secondary, var(--fn-bg-elevated)); +} + +.ssoGuideList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.ssoGuideItem { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 12px; + line-height: 1.45; + color: var(--fn-text-tertiary); +} + +.ssoGuideCurrent { + color: var(--fn-text-primary); + font-weight: 500; +} + +.ssoGuideDone { + color: var(--fn-text-secondary); +} + +.ssoGuideIndex { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; + background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)); + color: inherit; +} + +.ssoGuideDone .ssoGuideIndex { + color: var(--fn-color-brand); + background: var(--fn-color-brand-bg); +} + +.ssoGuideCurrent .ssoGuideIndex { + color: #fff; + background: var(--fn-color-brand); +} + +.ssoAlert { + margin: 0; +} + +.ssoSection { + margin-bottom: 16px; + padding: 16px 18px; + border: 1px solid var(--fn-border-primary); + border-radius: var(--fn-radius-lg, 12px); + background: var(--fn-bg-primary); +} + +.ssoSectionHeader { + margin-bottom: 14px; +} + +.ssoSectionTitle { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.4; + color: var(--fn-text-primary); +} + +.ssoSectionHint { + margin: 4px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--fn-text-tertiary); +} + +.ssoEnableRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.ssoEnableText { + flex: 1; + min-width: 0; +} + +.ssoEnableSwitch { + margin: 0 !important; + flex-shrink: 0; +} + +.ssoPreviewBtn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 40px; + padding: 0 12px; + border: 1px solid var(--fn-border-primary); + border-radius: 10px; + background: var(--fn-bg-elevated, var(--fn-bg-secondary)); + color: var(--fn-text-primary); + font-size: 13px; + font-weight: 500; + text-align: center; + pointer-events: none; + user-select: none; +} + +.ssoPreviewBtnMuted { + opacity: 0.55; +} + +.ssoPreviewHint { + margin: 6px 0 0; + font-size: 12px; + color: var(--fn-text-tertiary); +} + +.ssoPresets { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 14px; +} + +.ssoPresetsLabel { + font-size: 12px; + font-weight: 500; + color: var(--fn-text-tertiary); +} + +.ssoPresetChips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ssoPresetChip { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 10px; + border: 1px solid var(--fn-border-primary); + border-radius: 999px; + background: var(--fn-bg-elevated, var(--fn-bg-primary)); + color: var(--fn-text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease, + color 0.15s ease; + + &:hover { + border-color: var(--fn-border-strong, var(--fn-border-primary)); + color: var(--fn-text-primary); + } +} + +.ssoPresetChipActive { + border-color: var( + --fn-color-brand-border, + color-mix(in srgb, var(--fn-color-brand) 45%, transparent) + ); + background: var(--fn-color-brand-bg); + color: var(--fn-color-brand); +} + +.ssoSecretLabel { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ssoSecretTag { + display: inline-flex !important; + align-items: center; + gap: 4px; + margin: 0 !important; + padding: 0 6px !important; + border: none !important; + border-radius: 4px !important; + font-size: 11px !important; + line-height: 18px !important; + color: var(--fn-color-brand) !important; + background: var(--fn-color-brand-bg) !important; +} + +.ssoAdvanced { + margin-bottom: 16px; + + :global(.ant-collapse-header) { + padding: 10px 4px !important; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-secondary) !important; + } + + :global(.ant-collapse-content-box) { + padding: 0 4px 4px !important; + } +} + +.ssoRedirectCard { + margin: 0; + padding: 14px 16px; + border: 1px solid + var( + --fn-color-brand-border, + color-mix(in srgb, var(--fn-color-brand) 28%, transparent) + ); + border-radius: var(--fn-radius-lg, 12px); + background: var( + --fn-color-brand-bg, + color-mix(in srgb, var(--fn-color-brand) 6%, transparent) + ); +} + +.ssoRedirectHeader { + margin-bottom: 12px; +} + +.ssoRedirectRow { + width: 100%; +} + +.ssoRedirectInput { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.ssoRedirectDocs { + margin: 10px 0 0 !important; + font-size: 12px !important; + line-height: 1.5; +} + +.ssoFooter { + position: sticky; + bottom: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 16px; + margin-top: 8px; + padding: 14px 0 4px; + border-top: 1px solid var(--fn-border-primary); + background: linear-gradient( + to top, + var(--fn-bg-primary) 70%, + color-mix(in srgb, var(--fn-bg-primary) 80%, transparent) + ); +} + +.ssoFooterActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.ssoFooterMeta { + flex: 1; + min-width: 0; + font-size: 12px; + line-height: 1.5; +} + +.ssoDirtyHint { + color: #d48806; +} + +.ssoTestHint { + color: var(--fn-text-tertiary); +} + +@media (max-width: 640px) { + .ssoPanel { + padding-bottom: 24px; + } + + .ssoEnableRow { + flex-direction: column; + align-items: stretch; + } + + .ssoFooter { + position: static; + flex-direction: column; + align-items: stretch; + } +} diff --git a/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx b/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx index 3020f2b0..abae4174 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx @@ -1,6 +1,6 @@ -import { Popconfirm, Switch, Table, Tag } from "antd"; +import { Popconfirm, Switch, Table, Tag, Tooltip } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { Trash2 } from "lucide-react"; +import { Eye, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { SkillSpec } from "../useSkills"; import { useSkillDisplayName } from "../skillDisplayNames"; @@ -71,20 +71,23 @@ export default function SkillsTable({ { title: t("skills.table.actions", "操作"), key: "actions", - width: kind === "custom" ? "12%" : "8%", + width: kind === "custom" ? 88 : 56, align: "center", render: (_v, row) => (
- + + + {kind === "custom" && onDelete ? ( ({ onClick: () => onView(row), style: { cursor: "pointer" }, diff --git a/dashboard/src/pages/Chat/ChatToolDockContext.tsx b/dashboard/src/pages/Chat/ChatToolDockContext.tsx new file mode 100644 index 00000000..ef35825a --- /dev/null +++ b/dashboard/src/pages/Chat/ChatToolDockContext.tsx @@ -0,0 +1,70 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { dockToolUiTabId } from "./utils/dockToolUiTabId"; +import type { DockTab, DockTabId } from "./hooks/useChatDockPanel"; + +export interface OpenToolUiPanelOptions { + callId: string; + title?: string; + toolName?: string; +} + +interface ChatToolDockContextValue { + openToolUiPanel: (opts: OpenToolUiPanelOptions) => void; + closeToolUiPanel: (callId: string) => void; + focusToolUiPanel: (callId: string) => void; + isToolUiDocked: (callId: string | undefined) => boolean; +} + +const ChatToolDockContext = createContext( + null, +); + +export function ChatToolDockProvider({ + dockOpen, + openTabs, + activeTabId, + openToolUiPanel, + closeToolUiPanel, + focusToolUiPanel, + children, +}: { + dockOpen: boolean; + openTabs: DockTab[]; + activeTabId: DockTabId | null; + openToolUiPanel: (opts: OpenToolUiPanelOptions) => void; + closeToolUiPanel: (callId: string) => void; + focusToolUiPanel: (callId: string) => void; + children: ReactNode; +}) { + const activeToolUiCallId = useMemo(() => { + if (!dockOpen || activeTabId == null) return null; + const active = openTabs.find((tab) => tab.id === activeTabId); + return active?.kind === "toolUi" ? active.callId : null; + }, [dockOpen, openTabs, activeTabId]); + + const value = useMemo( + () => ({ + openToolUiPanel, + closeToolUiPanel, + focusToolUiPanel, + // Placeholder only while this tool's tab is the visible dock surface. + // Closing the dock, the tab, or switching away restores the chat card. + isToolUiDocked: (callId) => !!callId && activeToolUiCallId === callId, + }), + [activeToolUiCallId, openToolUiPanel, closeToolUiPanel, focusToolUiPanel], + ); + + return ( + + {children} + + ); +} + +export function useChatToolDock(): ChatToolDockContextValue | null { + return useContext(ChatToolDockContext); +} + +export function dockTabIdForToolCall(callId: string): DockTabId { + return dockToolUiTabId(callId); +} diff --git a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less index bc26bfbd..2b0269c8 100644 --- a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less +++ b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less @@ -221,6 +221,39 @@ justify-content: center; } +.dockToolUiBody { + flex: 1; + min-width: 0; + min-height: 0; + width: 100%; + overflow: auto; + padding: 12px; + box-sizing: border-box; + + /* Stretch plugin cards that use chat-column maxWidth (often inline). */ + .toolUiRendererWrap, + [data-octop-tool-renderer], + [data-octop-plugin-ui], + .octop-builtin-ui-fallback { + display: block; + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box; + } +} + +.dockToolUiMissing { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--fn-text-tertiary); + font-size: 13px; + text-align: center; +} + .dockFileList { flex: 1; min-height: 0; diff --git a/dashboard/src/pages/Chat/chatMessages.partial.less b/dashboard/src/pages/Chat/chatMessages.partial.less index 7c9cc3f4..0d296912 100644 --- a/dashboard/src/pages/Chat/chatMessages.partial.less +++ b/dashboard/src/pages/Chat/chatMessages.partial.less @@ -709,6 +709,104 @@ max-width: 100%; } +.toolUiRendererWrap { + position: relative; + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + /* Normal block flow: cards with maxWidth keep filling up to that cap; + cards with width:100% fill the message column. Do not use fit-content / + inline-grid here — those shrink to content width. */ + + .toolUiDockActions { + position: absolute; + z-index: 2; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } + + &:hover .toolUiDockActions, + &:focus-within .toolUiDockActions { + opacity: 1; + pointer-events: auto; + } + + @media (hover: none) { + .toolUiDockActions { + opacity: 1; + pointer-events: auto; + } + } +} + +.toolUiDockBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--fn-border-secondary); + border-radius: 8px; + background: color-mix(in srgb, var(--fn-bg-primary) 92%, transparent); + color: var(--fn-text-secondary); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); + cursor: pointer; + backdrop-filter: blur(4px); + + &:hover { + color: var(--fn-color-brand); + border-color: color-mix(in srgb, var(--fn-color-brand) 38%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } +} + +.toolUiDockPlaceholder { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 8px; + max-width: 100%; + padding: 8px 12px; + border: 1px dashed var(--fn-border-secondary); + border-radius: 10px; + background: color-mix(in srgb, var(--fn-bg-secondary) 70%, transparent); + color: var(--fn-text-secondary); + text-align: left; + cursor: pointer; + + &:hover { + border-color: color-mix(in srgb, var(--fn-color-brand) 38%, transparent); + color: var(--fn-color-brand); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } +} + +.toolUiDockPlaceholderTitle { + font-size: 13px; + font-weight: 500; + color: var(--fn-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolUiDockPlaceholderHint { + font-size: 12px; + color: var(--fn-text-tertiary); + flex-shrink: 0; +} + .assistantTurnAnswer { min-width: 0; } diff --git a/dashboard/src/pages/Chat/components/ChatDockPanel.tsx b/dashboard/src/pages/Chat/components/ChatDockPanel.tsx index 11469835..135dab32 100644 --- a/dashboard/src/pages/Chat/components/ChatDockPanel.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockPanel.tsx @@ -13,6 +13,7 @@ import { FilePen, FolderOpen, Globe, + Puzzle, RefreshCw, Terminal, X, @@ -29,6 +30,7 @@ import { dockFileBasename } from "../utils/dockFilePath"; import styles from "../index.module.less"; import ChatDockFileList from "./ChatDockFileList"; import FilePanelContent from "./FilePanelContent"; +import ChatDockToolUiContent from "./ChatDockToolUiContent"; const TerminalPage = lazy(() => import("../../Control/Terminal")); @@ -45,6 +47,8 @@ interface ChatDockPanelProps { onCloseTab: (id: DockTabId) => void; onOpenFile: (path: string) => void; browserEnvironment?: DisplayEnvironment; + threadId?: string | null; + isStreamingTurn?: boolean; /** * False while the chat dock shell is closed but keep-alive mounted. * Mirrors Workbench ``isVisible`` so terminal does not treat hide as a @@ -70,6 +74,8 @@ const ChatDockPanel: React.FC = ({ onCloseTab, onOpenFile, browserEnvironment = "desktop", + threadId = null, + isStreamingTurn = false, surfaceVisible = true, }) => { const { t } = useTranslation(); @@ -82,6 +88,10 @@ const ChatDockPanel: React.FC = ({ const [mountedFilePaths, setMountedFilePaths] = useState(() => openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), ); + const [mountedToolUiCallIds, setMountedToolUiCallIds] = useState( + () => + openTabs.filter((tab) => tab.kind === "toolUi").map((tab) => tab.callId), + ); const [fileActionsByPath, setFileActionsByPath] = useState< Record >({}); @@ -98,6 +108,9 @@ const ChatDockPanel: React.FC = ({ const openFilePaths = new Set( openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), ); + const openToolUiCallIds = new Set( + openTabs.filter((tab) => tab.kind === "toolUi").map((tab) => tab.callId), + ); setMountedFilePaths((prev) => { const next = prev.filter((path) => openFilePaths.has(path)); let changed = next.length !== prev.length; @@ -109,6 +122,17 @@ const ChatDockPanel: React.FC = ({ } return changed ? next : prev; }); + setMountedToolUiCallIds((prev) => { + const next = prev.filter((callId) => openToolUiCallIds.has(callId)); + let changed = next.length !== prev.length; + for (const callId of openToolUiCallIds) { + if (!next.includes(callId)) { + next.push(callId); + changed = true; + } + } + return changed ? next : prev; + }); setFileActionsByPath((prev) => { let changed = false; const next: Record = {}; @@ -172,6 +196,15 @@ const ChatDockPanel: React.FC = ({ {t("chat.dockTerminalTitle", "终端")} + ) : tab.kind === "toolUi" ? ( + <> + + + {tab.title ?? + tab.toolName ?? + t("chat.dockToolUiTitle", "Plugin tool")} + + ) : ( <> @@ -312,6 +345,26 @@ const ChatDockPanel: React.FC = ({
)} + + {mountedToolUiCallIds.map((callId) => { + const isActive = + activeTab?.kind === "toolUi" && activeTab.callId === callId; + return ( + + ); + })} ); diff --git a/dashboard/src/pages/Chat/components/ChatDockPanels.tsx b/dashboard/src/pages/Chat/components/ChatDockPanels.tsx index 26ce7dbd..e8c13c26 100644 --- a/dashboard/src/pages/Chat/components/ChatDockPanels.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockPanels.tsx @@ -18,6 +18,8 @@ interface ChatDockPanelsProps { onCloseTab: (id: DockTabId) => void; onOpenFile: (path: string) => void; browserEnvironment: DisplayEnvironment; + threadId?: string | null; + isStreamingTurn?: boolean; onModeChange: (mode: PanelMode) => void; onClose: () => void; onResizeStart: ( @@ -50,6 +52,8 @@ export default function ChatDockPanels({ onCloseTab, onOpenFile, browserEnvironment, + threadId = null, + isStreamingTurn = false, onModeChange, onClose, onResizeStart, @@ -84,6 +88,8 @@ export default function ChatDockPanels({ onCloseTab={onCloseTab} onOpenFile={onOpenFile} browserEnvironment={browserEnvironment} + threadId={threadId} + isStreamingTurn={isStreamingTurn} surfaceVisible={visible} /> ); diff --git a/dashboard/src/pages/Chat/components/ChatDockToolUiContent.tsx b/dashboard/src/pages/Chat/components/ChatDockToolUiContent.tsx new file mode 100644 index 00000000..cfa0e934 --- /dev/null +++ b/dashboard/src/pages/Chat/components/ChatDockToolUiContent.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { useToolMessageByCallId } from "../hooks/useToolMessageByCallId"; +import { ToolDetailsInline } from "./MessageBubble"; +import styles from "../index.module.less"; + +interface ChatDockToolUiContentProps { + threadId: string | null; + callId: string; + agentId: string; + isStreamingTurn: boolean; +} + +export default function ChatDockToolUiContent({ + threadId, + callId, + agentId, + isStreamingTurn, +}: ChatDockToolUiContentProps) { + const { t } = useTranslation(); + const message = useToolMessageByCallId(threadId, callId); + + if (!message?.toolData) { + return ( +
+ {t( + "chat.dockToolUiMissing", + "Tool result is no longer available in this conversation.", + )} +
+ ); + } + + const isStreaming = message.status === "streaming" && isStreamingTurn; + + return ( +
+ +
+ ); +} diff --git a/dashboard/src/pages/Chat/components/MessageBubble.tsx b/dashboard/src/pages/Chat/components/MessageBubble.tsx index a96c26a8..9995d220 100644 --- a/dashboard/src/pages/Chat/components/MessageBubble.tsx +++ b/dashboard/src/pages/Chat/components/MessageBubble.tsx @@ -1,5 +1,5 @@ import { memo, useMemo, useState, useCallback, useRef, useEffect } from "react"; -import { Image, Button } from "antd"; +import { Image, Button, Tooltip } from "antd"; import { message as antMessage } from "@/utils/antdMessage"; import Markdown from "../../../components/Markdown/LazyMarkdown"; @@ -11,6 +11,7 @@ import { Volume2, Settings, GitFork, + PanelRight, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -51,6 +52,10 @@ import { import { BuiltinOctopUiFallback } from "../../../plugins/toolRenderers/builtin/BuiltinOctopUiFallback"; import { ToolUiErrorBoundary } from "../../../plugins/toolRenderers/ToolUiErrorBoundary"; import { lookupPluginIdForTool } from "../../../plugins/toolRenderers/toolPluginIndex"; +import { useChatToolDock } from "../ChatToolDockContext"; +import { useToolUiDockButtonStyle } from "../hooks/useToolUiDockButtonStyle"; +import type { ParsedToolOutput } from "../../../plugins/toolRenderers/types"; +import type { ToolRendererRegistration } from "../../../plugins/toolRenderers/types"; interface MessageBubbleProps { message: ChatMessage; @@ -312,19 +317,42 @@ function CopyButton({ text }: { text: string }) { ); } +function canDockToolUi( + registration: ToolRendererRegistration | null, + parsed: ParsedToolOutput, +): boolean { + if (parsed.octopUi) return true; + return ( + registration != null && + registration.id !== "default" && + registration.pluginId !== "builtin" + ); +} + +function toolUiDockLabel( + toolData: NonNullable, +): string { + return toolData.displayName?.trim() || toolData.name?.trim() || "Tool"; +} + export function ToolDetailsInline({ toolData, isStreaming, onAcpPermissionSelect, hideMediaPreview = false, agentId = null, + forceInline = false, }: { toolData: NonNullable; isStreaming: boolean; onAcpPermissionSelect?: (message: string) => void; hideMediaPreview?: boolean; agentId?: string | null; + /** When true, skip dock attach / placeholder (full renderer in side panel). */ + forceInline?: boolean; }) { + const { t } = useTranslation(); + const toolDock = useChatToolDock(); // Plugin UIs load async after mount — bump forces resolve() to re-run. const rendererVersion = useToolRendererVersion(); const parsed = useMemo( @@ -385,13 +413,77 @@ export function ToolDetailsInline({ agentId, }; + const dockable = canDockToolUi(registration ?? null, parsed); + const callId = toolData.callId; + const isDocked = + !forceInline && + dockable && + !!callId && + (toolDock?.isToolUiDocked(callId) ?? false); + + const showDockButton = + !forceInline && !isDocked && dockable && !!callId && !!toolDock; + const { wrapRef, buttonStyle } = useToolUiDockButtonStyle(showDockButton, [ + toolData.output, + registration?.id, + rendererVersion, + ]); + + const openInDock = useCallback(() => { + if (!callId || !toolDock) return; + toolDock.openToolUiPanel({ + callId, + title: toolUiDockLabel(toolData), + toolName: toolData.name, + }); + }, [callId, toolData, toolDock]); + + const dockActions = showDockButton ? ( +
+ + + +
+ ) : null; + + if (isDocked) { + return ( + + ); + } + if (registration && registration.id !== "default") { const Comp = registration.component; return ( -
+
+ {dockActions}
); } @@ -399,8 +491,13 @@ export function ToolDetailsInline({ // Structured plugin envelope without a loaded custom renderer — still show a card. if (parsed.octopUi) { return ( -
+
+ {dockActions}
); } diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts index c89e1828..0fa60191 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts @@ -115,6 +115,64 @@ describe("useChatDockPanel tabs", () => { expect(result.current.dockOpen).toBe(false); }); + it("openToolUiTab dedupes by callId and focuses the tool tab", () => { + const { result } = renderHook(() => useChatDockPanel(false)); + act(() => { + result.current.openToolUiTab({ + callId: "call-1", + title: "Demo card", + toolName: "demo_card", + }); + }); + act(() => { + result.current.openToolUiTab({ + callId: "call-1", + title: "Demo card", + }); + }); + expect(result.current.dockOpen).toBe(true); + expect( + result.current.openTabs.filter((t) => t.kind === "toolUi"), + ).toHaveLength(1); + expect(result.current.activeTabId).toBe("toolUi:call-1"); + expect(result.current.openTabs[0]).toMatchObject({ + kind: "toolUi", + callId: "call-1", + title: "Demo card", + }); + }); + + it("focusToolUiTab reopens dock on an existing tool tab", () => { + const { result } = renderHook(() => useChatDockPanel(false)); + act(() => { + result.current.openToolUiTab({ callId: "call-2", title: "Card" }); + result.current.handleClose(); + }); + expect(result.current.dockOpen).toBe(false); + // Closing the dock drops toolUi tabs so the message stream restores. + expect( + result.current.openTabs.filter((t) => t.kind === "toolUi"), + ).toHaveLength(0); + act(() => { + result.current.openToolUiTab({ callId: "call-2", title: "Card" }); + }); + expect(result.current.dockOpen).toBe(true); + expect(result.current.activeTabId).toBe("toolUi:call-2"); + }); + + it("handleClose removes toolUi tabs but keeps other tabs", () => { + const { result } = renderHook(() => useChatDockPanel(false)); + act(() => { + result.current.openBrowserTab(); + result.current.openToolUiTab({ callId: "call-3", title: "Card" }); + }); + act(() => { + result.current.handleClose(); + }); + expect(result.current.dockOpen).toBe(false); + expect(result.current.openTabs.map((t) => t.id)).toEqual(["browser"]); + }); + it("does not expose deprecated dismiss / kind aliases", () => { const { result } = renderHook(() => useChatDockPanel(false)); expect(result.current).not.toHaveProperty("userDismissedRef"); diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts index 8be9d2eb..d6ae45da 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts @@ -6,6 +6,7 @@ import { isHostAbsolutePath, normalizeDockFilePath, } from "../utils/dockFilePath"; +import { dockToolUiTabId } from "../utils/dockToolUiTabId"; import { usePanelResize, type PanelSizes } from "./usePanelResize"; const PANEL_MODE_KEY = "octop:chat-dock:mode"; @@ -20,7 +21,14 @@ export type DockTab = | { id: "files"; kind: "files" } | { id: "browser"; kind: "browser" } | { id: "terminal"; kind: "terminal" } - | { id: string; kind: "file"; path: string }; + | { id: string; kind: "file"; path: string } + | { + id: string; + kind: "toolUi"; + callId: string; + title?: string; + toolName?: string; + }; export type DockTabId = DockTab["id"]; @@ -120,6 +128,16 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { const handleClose = useCallback(() => { setDockOpen(false); + // Closing the dock restores tool UIs to the message stream. + setOpenTabs((prev) => { + const next = prev.filter((t) => t.kind !== "toolUi"); + setActiveTabId((current) => { + if (current == null) return null; + if (next.some((t) => t.id === current)) return current; + return next[0]?.id ?? null; + }); + return next; + }); }, []); const openFileList = useCallback(() => { @@ -174,6 +192,39 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { openDock(); }, [openDock]); + const openToolUiTab = useCallback( + (opts: { callId: string; title?: string; toolName?: string }) => { + const callId = opts.callId?.trim(); + if (!callId) return; + const id = dockToolUiTabId(callId); + setOpenTabs((prev) => { + if (prev.some((t) => t.id === id)) return prev; + return [ + ...prev, + { + id, + kind: "toolUi" as const, + callId, + title: opts.title, + toolName: opts.toolName, + }, + ]; + }); + setActiveTabId(id); + openDock(); + }, + [openDock], + ); + + const focusToolUiTab = useCallback( + (callId: string) => { + const id = dockToolUiTabId(callId); + setActiveTabId(id); + openDock(); + }, + [openDock], + ); + /** Toggle dock open/closed around a dedicated tab (browser / terminal). */ const toggleDockTab = useCallback( (tab: Extract) => { @@ -263,6 +314,8 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { toggleBrowserPanel, openTerminalTab, toggleTerminalPanel, + openToolUiTab, + focusToolUiTab, closeTab, setActiveTab, }; diff --git a/dashboard/src/pages/Chat/hooks/useToolMessageByCallId.ts b/dashboard/src/pages/Chat/hooks/useToolMessageByCallId.ts new file mode 100644 index 00000000..9f7cff5a --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useToolMessageByCallId.ts @@ -0,0 +1,24 @@ +import { useSyncExternalStore } from "react"; +import * as chatStore from "./chatStore"; +import type { ChatMessage } from "./useChat"; + +/** Live tool message row for a ``toolData.callId`` in the active thread. */ +export function useToolMessageByCallId( + threadId: string | null, + callId: string, +): ChatMessage | null { + return useSyncExternalStore( + (onStoreChange) => + threadId ? chatStore.subscribe(threadId, onStoreChange) : () => {}, + () => { + if (!threadId) return null; + const { messages } = chatStore.getSnapshot(threadId); + return ( + messages.find( + (m) => m.toolData?.callId === callId && m.toolData != null, + ) ?? null + ); + }, + () => null, + ); +} diff --git a/dashboard/src/pages/Chat/hooks/useToolUiDockButtonStyle.ts b/dashboard/src/pages/Chat/hooks/useToolUiDockButtonStyle.ts new file mode 100644 index 00000000..e3b41880 --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useToolUiDockButtonStyle.ts @@ -0,0 +1,73 @@ +import { + useCallback, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type RefObject, +} from "react"; + +const DOCK_BTN_SIZE = 28; +const DOCK_BTN_INSET = 8; + +function findToolUiCard(root: HTMLElement): HTMLElement | null { + return ( + root.querySelector("[data-octop-plugin-ui]") ?? + root.querySelector(".octop-builtin-ui-fallback") ?? + null + ); +} + +/** + * Place the dock button at the top-right of the plugin card without changing + * the card's layout width (avoid fit-content / inline-grid shrink-to-fit). + */ +export function useToolUiDockButtonStyle( + enabled: boolean, + deps: unknown[] = [], +): { + wrapRef: RefObject; + buttonStyle: CSSProperties | undefined; +} { + const wrapRef = useRef(null); + const [buttonStyle, setButtonStyle] = useState(); + + const update = useCallback(() => { + const root = wrapRef.current; + if (!root || !enabled) { + setButtonStyle(undefined); + return; + } + const card = findToolUiCard(root); + if (!card) { + setButtonStyle({ top: DOCK_BTN_INSET, right: DOCK_BTN_INSET }); + return; + } + const rootRect = root.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + setButtonStyle({ + top: Math.max(0, cardRect.top - rootRect.top) + DOCK_BTN_INSET, + left: + Math.max(0, cardRect.right - rootRect.left) - + DOCK_BTN_SIZE - + DOCK_BTN_INSET, + }); + }, [enabled]); + + useLayoutEffect(() => { + update(); + if (!enabled) return; + const root = wrapRef.current; + if (!root) return; + + const ro = new ResizeObserver(() => update()); + ro.observe(root); + const card = findToolUiCard(root); + if (card) ro.observe(card); + + return () => ro.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- caller passes render-invalidating deps + }, [enabled, update, ...deps]); + + return { wrapRef, buttonStyle }; +} diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 4ae773a6..5c8f2bc7 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -57,6 +57,10 @@ import { prefetchVoiceConfig } from "../../hooks/useVoiceConfig"; import { isSharedExpertViewer } from "../../utils/sharedExpert"; import ChatDockPanels from "./components/ChatDockPanels"; import { ChatFilePreviewProvider } from "./ChatFilePreviewContext"; +import { + ChatToolDockProvider, + dockTabIdForToolCall, +} from "./ChatToolDockContext"; import ChatSidebarPanel from "./components/ChatSidebarPanel"; import ChatTitleBar from "./components/ChatTitleBar"; import ChatComposerChrome from "./components/ChatComposerChrome"; @@ -69,7 +73,10 @@ import { apiErrorMessage } from "../../utils/apiError"; import PwaInstallPrompt from "../../components/PwaInstallPrompt"; import { promptNeedsUserInput } from "../../utils/quickInputPrefill"; import { OPEN_NAV_RECORDS_EVENT } from "../../layouts/chatHistoryRail"; -import { usePluginToolUis } from "../../plugins/toolRenderers"; +import { + usePluginToolUis, + setPluginUiDockHandlers, +} from "../../plugins/toolRenderers"; import styles from "./index.module.less"; export default function ChatPage() { @@ -317,10 +324,27 @@ function ChatPageInner() { openBrowserTab, toggleBrowserPanel, toggleTerminalPanel, + openToolUiTab, + focusToolUiTab, closeTab: closeDockTab, setActiveTab: setDockActiveTab, } = useChatDockPanel(isMobile, resolvedAgentId); + const closeToolUiPanel = useCallback( + (callId: string) => { + closeDockTab(dockTabIdForToolCall(callId)); + }, + [closeDockTab], + ); + + useEffect(() => { + setPluginUiDockHandlers({ + openSidePanel: openToolUiTab, + closeSidePanel: closeToolUiPanel, + }); + return () => setPluginUiDockHandlers({}); + }, [openToolUiTab, closeToolUiPanel]); + const composerSession = useMemo( () => sessions.find((session) => session.id === activeThreadId) ?? null, [sessions, activeThreadId], @@ -829,364 +853,384 @@ function ChatPageInner() { return ( - {chatHistoryRail ? createPortal(chatSidebarPanel, chatHistoryRail) : null} -
- {/* Main chat area */} -
- {/* Mobile toolbar — session list + optional title + agent profile */} - {isMobile && ( -
- - {activeSessionTitle && ( -
- {activeSessionTitle} -
- )} - {resolvedAgentId && !sharedExpertViewer && ( -
- - + {activeSessionTitle && ( +
- - -
- )} -
- )} - - {!isMobile && activeSession && activeSessionTitle && ( - - )} - - {memoryMaintVisible && memoryMaint && ( - - )} - - {historyMigration.visible && historyMigration.status && ( - void historyMigration.start()} - /> - )} + {activeSessionTitle} +
+ )} + {resolvedAgentId && !sharedExpertViewer && ( +
+ + +
+ )} +
+ )} -
- {!agentChatReady || noAgents ? ( - - ) : showWelcome ? ( - - ) : ( - 0 && !isMobile - ? openFileList - : undefined - } + )} + + {historyMigration.visible && historyMigration.status && ( + void historyMigration.start()} /> )} -
- {!isMobile && - !dockOpen && - !agentProfileOpen && - !workspaceDrawerOpen && ( -
- {/* PWA install first when available — same column as browser / experts. */} - - {resolvedAgentId && !sharedExpertViewer && ( - <> +
+ {!agentChatReady || noAgents ? ( + + ) : showWelcome ? ( + + ) : ( + 0 && + !isMobile + ? openFileList + : undefined + } + /> + )} +
+ + {!isMobile && + !dockOpen && + !agentProfileOpen && + !workspaceDrawerOpen && ( +
+ {/* PWA install first when available — same column as browser / experts. */} + + {resolvedAgentId && !sharedExpertViewer && ( + <> + + + + + + + + + + + + )} + {!sharedExpertViewer && panelFilePaths.length > 0 && ( + {panelFilePaths.length > 1 && ( + + {panelFilePaths.length > 99 + ? "99+" + : panelFilePaths.length} + + )} + )} + {canTerminal && ( - - )} - {!sharedExpertViewer && panelFilePaths.length > 0 && ( - - - - {panelFilePaths.length > 1 && ( - - {panelFilePaths.length > 99 - ? "99+" - : panelFilePaths.length} - - )} - - - )} - {canTerminal && ( + )} - )} - - - - - -
- )} +
+ )} - - { - prefillInputRef.current = ""; - }} - availableModels={availableModels} - selectedModel={selectedModel} - onModelChange={setSelectedModel} - reasoningMode={reasoningMode} - reasoningEffort={reasoningEffort} - onReasoningChange={handleReasoningChange} - availableConnectors={chatConnectors} - selectedConnectors={selectedConnectors} - onConnectorsChange={handleConnectorsChange} - availableKnowledgeBases={chatKnowledgeBases} - selectedKnowledgeBaseIds={selectedKnowledgeBaseIds} - onKnowledgeBaseIdsChange={handleKnowledgeBaseIdsChange} - availableSkills={chatSkills} - selectedSkills={selectedSkills} - onSkillsChange={handleSkillsChange} - availableAgents={chatAgentOptions} - selectedTargetAgents={selectedTargetAgents} - onTargetAgentsChange={setSelectedTargetAgents} - agentId={resolvedAgentId} + + { + prefillInputRef.current = ""; + }} + availableModels={availableModels} + selectedModel={selectedModel} + onModelChange={setSelectedModel} + reasoningMode={reasoningMode} + reasoningEffort={reasoningEffort} + onReasoningChange={handleReasoningChange} + availableConnectors={chatConnectors} + selectedConnectors={selectedConnectors} + onConnectorsChange={handleConnectorsChange} + availableKnowledgeBases={chatKnowledgeBases} + selectedKnowledgeBaseIds={selectedKnowledgeBaseIds} + onKnowledgeBaseIdsChange={handleKnowledgeBaseIdsChange} + availableSkills={chatSkills} + selectedSkills={selectedSkills} + onSkillsChange={handleSkillsChange} + availableAgents={chatAgentOptions} + selectedTargetAgents={selectedTargetAgents} + onTargetAgentsChange={setSelectedTargetAgents} + agentId={resolvedAgentId} + threadId={activeThreadId} + defaultModel={activeAgent?.default_model ?? null} + contextUsedTokens={contextUsedTokens} + contextMaxTokens={contextMaxTokens} + /> +
+ + -
- - - {!sharedExpertViewer && ( - <> - setAgentProfileOpen(false)} - /> - setWorkspaceDrawerOpen(false)} - /> - - )} -
+ {!sharedExpertViewer && ( + <> + setAgentProfileOpen(false)} + /> + setWorkspaceDrawerOpen(false)} + /> + + )} + + ); } diff --git a/dashboard/src/pages/Chat/utils/dockToolUiTabId.ts b/dashboard/src/pages/Chat/utils/dockToolUiTabId.ts new file mode 100644 index 00000000..3bd1c50f --- /dev/null +++ b/dashboard/src/pages/Chat/utils/dockToolUiTabId.ts @@ -0,0 +1,4 @@ +/** Stable dock tab id for a tool call's plugin UI panel. */ +export function dockToolUiTabId(callId: string): string { + return `toolUi:${callId}`; +} diff --git a/dashboard/src/pages/Control/CronJobs/components/columns.tsx b/dashboard/src/pages/Control/CronJobs/components/columns.tsx index 62e1c229..a95cf3b0 100644 --- a/dashboard/src/pages/Control/CronJobs/components/columns.tsx +++ b/dashboard/src/pages/Control/CronJobs/components/columns.tsx @@ -26,6 +26,8 @@ interface ColumnHandlers { onDelete: (jobId: string) => void; t: TFunction; timeZone: string; + /** Pin leading/trailing columns (disable on narrow screens). */ + stickyColumns?: boolean; } function channelLabel(channel: string, t: TFunction): string { @@ -36,13 +38,14 @@ function channelLabel(channel: string, t: TFunction): string { export const createColumns = ( handlers: ColumnHandlers, ): ColumnsType => { + const sticky = handlers.stickyColumns !== false; return [ { title: handlers.t("cronJobs.col.id"), dataIndex: "id", key: "id", width: 120, - fixed: "left", + fixed: sticky ? "left" : undefined, ellipsis: true, onHeaderCell: () => ({ style: { paddingLeft: 28 } }), render: (id: string, record: CronJob) => { @@ -113,7 +116,7 @@ export const createColumns = ( dataIndex: "enabled", key: "enabled", width: 100, - fixed: "left", + fixed: sticky ? "left" : undefined, render: (enabled: boolean) => ( { const menuItems: MenuProps["items"] = [ { diff --git a/dashboard/src/pages/Control/CronJobs/index.module.less b/dashboard/src/pages/Control/CronJobs/index.module.less index 7c434671..af3916ac 100644 --- a/dashboard/src/pages/Control/CronJobs/index.module.less +++ b/dashboard/src/pages/Control/CronJobs/index.module.less @@ -13,16 +13,28 @@ /* ── Table (aligned with admin/users .userTable) ─────────────── */ .cronTable { + width: 100%; + min-width: 0; + max-width: 100%; + + :global(.octop-table-wrapper), + :global(.ant-table-wrapper) { + max-width: 100%; + } + + :global(.octop-table), :global(.ant-table) { background: transparent; } + :global(.octop-table-container), :global(.ant-table-container) { border: 1px solid var(--fn-border-primary); border-radius: var(--fn-radius-lg, 12px); overflow: hidden; } + :global(.octop-table-thead > tr > th), :global(.ant-table-thead > tr > th) { background: var(--fn-bg-secondary, var(--fn-bg-elevated)); color: var(--fn-text-tertiary); @@ -32,21 +44,28 @@ white-space: nowrap; } + :global(.octop-table-tbody > tr > td), :global(.ant-table-tbody > tr > td) { padding: 10px 12px; vertical-align: middle; } + :global(.octop-table-cell-fix-left), + :global(.octop-table-cell-fix-right), :global(.ant-table-cell-fix-left), :global(.ant-table-cell-fix-right) { background: var(--fn-bg-primary, #fff) !important; } + :global(.octop-table-thead .octop-table-cell-fix-left), + :global(.octop-table-thead .octop-table-cell-fix-right), :global(.ant-table-thead .ant-table-cell-fix-left), :global(.ant-table-thead .ant-table-cell-fix-right) { background: var(--fn-bg-secondary, #fafafa) !important; } + :global(.octop-table-row:hover > .octop-table-cell-fix-left), + :global(.octop-table-row:hover > .octop-table-cell-fix-right), :global(.ant-table-row:hover > .ant-table-cell-fix-left), :global(.ant-table-row:hover > .ant-table-cell-fix-right) { background: var(--fn-bg-hover, rgba(0, 0, 0, 0.02)) !important; @@ -98,6 +117,9 @@ .listShell { position: relative; transition: opacity 0.15s ease; + width: 100%; + min-width: 0; + max-width: 100%; } .listShellBusy { diff --git a/dashboard/src/pages/Control/CronJobs/index.tsx b/dashboard/src/pages/Control/CronJobs/index.tsx index 7071d49b..546fb27c 100644 --- a/dashboard/src/pages/Control/CronJobs/index.tsx +++ b/dashboard/src/pages/Control/CronJobs/index.tsx @@ -230,6 +230,7 @@ function CronJobsPage() { onDelete: handleDelete, t, timeZone: cronTimezone, + stickyColumns: !isMobile, }); // Until the user picks an agent there is nothing to fetch and no scope @@ -354,7 +355,7 @@ function CronJobsPage() { dataSource={jobs} rowKey="id" size="middle" - scroll={{ x: "max-content" }} + scroll={{ x: 1300 }} pagination={{ pageSize: 10, showSizeChanger: false, diff --git a/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx b/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx index ecda869e..e3f87140 100644 --- a/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx +++ b/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx @@ -38,6 +38,7 @@ import ToolCatalogDrawer from "./ToolCatalogDrawer"; import { request } from "../../../api/request"; import type { OctopAgent } from "../../../context/AgentContext"; import { useAgent } from "../../../context/AgentContext"; +import { useIsMobile } from "../../../hooks/useIsMobile"; import MbtiPersonaTag from "../../../components/MbtiPersonaTag"; import MbtiCatalogDrawer from "./MbtiCatalogDrawer"; import { ExpertIcon } from "./iconForName"; @@ -82,6 +83,7 @@ export default function AgentExpertsTable({ }: AgentExpertsTableProps) { const { t } = useTranslation(); const navigate = useNavigate(); + const isMobile = useIsMobile(); const { setActiveAgent, refresh: refreshAgents } = useAgent(); const [localStates, setLocalStates] = useState>({}); const [actionLoadingId, setActionLoadingId] = useState(null); @@ -112,6 +114,7 @@ export default function AgentExpertsTable({ const [scrollY, setScrollY] = useState(360); useLayoutEffect(() => { + if (isMobile) return; const el = tableWrapRef.current; if (!el) return; @@ -130,7 +133,7 @@ export default function AgentExpertsTable({ ro.disconnect(); window.removeEventListener("resize", update); }; - }, [agents.length]); + }, [agents.length, isMobile]); const openMbtiCatalog = useCallback((agentId: string) => { setMbtiAgentId(agentId); @@ -267,7 +270,7 @@ export default function AgentExpertsTable({ title: t("experts.table.name", "名称"), dataIndex: "name", width: 160, - fixed: "left", + fixed: isMobile ? undefined : "left", render: (name: string, row) => (
{ const state = localStates[row.agent_id] ?? row.state; const isTransient = TRANSIENT.has(state); @@ -559,7 +562,7 @@ export default function AgentExpertsTable({ rowKey="agent_id" dataSource={agents} columns={columns} - scroll={{ x: 1370, y: scrollY }} + scroll={isMobile ? { x: 1370 } : { x: 1370, y: scrollY }} pagination={{ defaultPageSize: 10, showSizeChanger: true, diff --git a/dashboard/src/pages/Experts/index.module.less b/dashboard/src/pages/Experts/index.module.less index 2f11ca0c..02f9f5d2 100644 --- a/dashboard/src/pages/Experts/index.module.less +++ b/dashboard/src/pages/Experts/index.module.less @@ -1739,6 +1739,14 @@ .expertsTable { margin: 12px 0 0; + width: 100%; + min-width: 0; + max-width: 100%; + + :global(.octop-table-wrapper), + :global(.ant-table-wrapper) { + max-width: 100%; + } :global(.octop-table-pagination) { margin: 12px 0 0 !important; diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx index 9cfad61c..027cf322 100644 --- a/dashboard/src/pages/KnowledgeBases/index.tsx +++ b/dashboard/src/pages/KnowledgeBases/index.tsx @@ -1485,7 +1485,8 @@ export default function KnowledgeBasesPage() { {t("knowledgeBases.documentLimit", { count: fileCount, - max: selected?.max_documents ?? limits.max_docs_per_kb, + max: + selected?.max_documents ?? limits.max_docs_per_kb, })}
@@ -1616,7 +1617,8 @@ export default function KnowledgeBasesPage() { type="info" showIcon message={t("knowledgeBases.documentLimitReached", { - count: selected?.max_documents ?? limits.max_docs_per_kb, + count: + selected?.max_documents ?? limits.max_docs_per_kb, })} /> ) : null} @@ -1733,6 +1735,7 @@ export default function KnowledgeBasesPage() { size="small" rowKey="id" pagination={false} + scroll={{ x: 720 }} dataSource={folderEntries} onRow={(document) => ({ onClick: document.is_dir diff --git a/dashboard/src/pages/Settings/BackupRestore/index.tsx b/dashboard/src/pages/Settings/BackupRestore/index.tsx index 1f458bb2..d12172a9 100644 --- a/dashboard/src/pages/Settings/BackupRestore/index.tsx +++ b/dashboard/src/pages/Settings/BackupRestore/index.tsx @@ -34,6 +34,7 @@ import { type AutoBackupSettings, type BackupFileItem, } from "../../../api/modules/backup"; +import { useBackupOperation } from "../../../context/BackupOperationContext"; import { useServiceRestartContext } from "../../../context/ServiceRestartContext"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { useServerTimezone } from "../../../hooks/useServerTimezone"; @@ -75,6 +76,7 @@ function parseIntervalSeconds(spec: string): number | null { interface BackupFileCardProps { row: BackupFileItem; downloading: boolean; + restoringThis: boolean; busy: boolean; timeZone: string; onDownload: (row: BackupFileItem) => void; @@ -85,6 +87,7 @@ interface BackupFileCardProps { function BackupFileCard({ row, downloading, + restoringThis, busy, timeZone, onDownload, @@ -122,10 +125,11 @@ function BackupFileCard({ { - void (async () => { - setUploadPercent(0); - try { - await backupApi.uploadBackup(file, (p) => - setUploadPercent(p), - ); - setUploadPercent(100); - message.success( - t("backup.uploadSuccess", { name: file.name }), - ); - await refresh(); - } catch (err: unknown) { - const detail = - err instanceof Error ? err.message : String(err); - message.error(detail || t("backup.uploadFailed")); - } finally { - setUploadPercent(null); - } - })(); + void uploadBackup(file); return false; }} > @@ -457,7 +447,7 @@ export default function BackupRestorePanel() { {t("common.refresh")}
- {(uploadPercent !== null || restoreProgress) && ( + {(uploadPercent !== null || restoring || backingUp) && (
{uploadPercent !== null ? ( <> @@ -466,13 +456,20 @@ export default function BackupRestorePanel() {
- ) : ( + ) : restoring ? ( <>
{t("backup.restoring")}
+ ) : ( + <> +
+ {t("backup.creating")} +
+ + )}
)} @@ -494,6 +491,7 @@ export default function BackupRestorePanel() { key={row.name} row={row} downloading={downloading === row.name} + restoringThis={restoringThisName === row.name} busy={busy} timeZone={serverTimezone} onDownload={onDownload} @@ -635,7 +633,7 @@ export default function BackupRestorePanel() { disabled={busy && !autoRunning} onClick={() => void onRunAuto()} > - {t("backup.autoRunNow")} + {autoRunning ? t("backup.creating") : t("backup.autoRunNow")} @@ -652,14 +650,16 @@ export default function BackupRestorePanel() { } }} onOk={() => void confirmRestore()} - okText={t("backup.importConfirmOk")} + okText={restoring ? t("backup.restoring") : t("backup.importConfirmOk")} cancelText={t("common.cancel")} confirmLoading={restoring} okButtonProps={{ danger: true, disabled: busy && !restoring }} cancelButtonProps={{ disabled: restoring }} >

- {t("backup.importConfirmBody", { name: pendingRestore?.name ?? "" })} + {t("backup.importConfirmBody", { + name: pendingRestore?.name ?? restoreTarget ?? "", + })}

([]); const [loading, setLoading] = useState(true); - const { viewMode, setViewMode, showCardView } = useCardTableView( + const { isMobile, viewMode, setViewMode, showCardView } = useCardTableView( loadViewMode(), ); const [actionLoadingId, setActionLoadingId] = useState(null); @@ -144,7 +144,7 @@ export default function OctopAgentsPage() { title: t("adminAgents.columns.name"), dataIndex: "name", width: 140, - fixed: "left", + fixed: isMobile ? undefined : "left", render: (name: string, row: OctopAgent) => ( + + + {canMutate && onDelete ? ( ({ diff --git a/dashboard/src/plugins/toolRenderers/host.ts b/dashboard/src/plugins/toolRenderers/host.ts index a03225c0..b1a43aec 100644 --- a/dashboard/src/plugins/toolRenderers/host.ts +++ b/dashboard/src/plugins/toolRenderers/host.ts @@ -13,6 +13,19 @@ import type { } from "./types"; let contextOverride: Partial = {}; +let dockHandlers: { + openSidePanel?: (opts: { + callId: string; + title?: string; + toolName?: string; + }) => void; + closeSidePanel?: (callId: string) => void; +} = {}; + +/** Chat page wires dock open/close for plugin ``host.openSidePanel``. */ +export function setPluginUiDockHandlers(handlers: typeof dockHandlers): void { + dockHandlers = handlers; +} /** Chat page sets agent/thread so plugin UIs can call scoped APIs. */ export function setPluginUiToolContext( @@ -56,6 +69,12 @@ function createHost(defaultPluginId: string): OctopPluginUIHost { request(path: string, init?: RequestInit) { return request(path, init); }, + openSidePanel(opts) { + dockHandlers.openSidePanel?.(opts); + }, + closeSidePanel(callId) { + dockHandlers.closeSidePanel?.(callId); + }, }; } diff --git a/dashboard/src/plugins/toolRenderers/index.ts b/dashboard/src/plugins/toolRenderers/index.ts index 7682ec98..d6a5d18a 100644 --- a/dashboard/src/plugins/toolRenderers/index.ts +++ b/dashboard/src/plugins/toolRenderers/index.ts @@ -3,6 +3,7 @@ export { builtinPluginHost, createPluginUiHost, setPluginUiToolContext, + setPluginUiDockHandlers, } from "./host"; export { loadInstalledPluginUis, diff --git a/dashboard/src/plugins/toolRenderers/types.ts b/dashboard/src/plugins/toolRenderers/types.ts index 9a430dab..be024839 100644 --- a/dashboard/src/plugins/toolRenderers/types.ts +++ b/dashboard/src/plugins/toolRenderers/types.ts @@ -53,6 +53,12 @@ export interface ToolRendererRegistration { component: ComponentType; } +export interface OpenSidePanelOptions { + callId: string; + title?: string; + toolName?: string; +} + export interface OctopPluginUIHost { registerRenderer( reg: Omit & { pluginId?: string }, @@ -62,6 +68,10 @@ export interface OctopPluginUIHost { patchResult(callId: string, nextData: unknown): void; /** Authenticated Octop API request (path starts with ``/`` under ``/api``). */ request(path: string, init?: RequestInit): Promise; + /** Open this tool call's UI in the chat side dock (one tab per callId). */ + openSidePanel(opts: OpenSidePanelOptions): void; + /** Close the side dock tab for this tool call, if open. */ + closeSidePanel(callId: string): void; } /** Shape expected from ``ui/dist/index.js``. */ diff --git a/dashboard/src/styles/layout.css b/dashboard/src/styles/layout.css index 44879355..89e4dfba 100644 --- a/dashboard/src/styles/layout.css +++ b/dashboard/src/styles/layout.css @@ -311,16 +311,13 @@ html[data-theme="dark"] .octop-ant-switch-checked { padding: 0; } - /* Force tables to scroll horizontally on mobile */ + /* Tables scroll inside their own wrapper on mobile — do not set + min-width on .octop-table (that expands the page and creates a + second, page-level horizontal scrollbar). */ .ant-table-wrapper, - [class*="-table-wrapper"] { - overflow-x: auto !important; - -webkit-overflow-scrolling: touch; - } - - .ant-table, - [class*="-table"] { - min-width: 600px; + .octop-table-wrapper { + max-width: 100%; + min-width: 0; } /* Modal responsive — max width on mobile */ diff --git a/dashboard/src/utils/chatStreamError.test.ts b/dashboard/src/utils/chatStreamError.test.ts index 52ab443a..910eb4ca 100644 --- a/dashboard/src/utils/chatStreamError.test.ts +++ b/dashboard/src/utils/chatStreamError.test.ts @@ -29,6 +29,30 @@ describe("classifyChatStreamError", () => { ).toBe("stream_errors.auth"); }); + it("classifies insufficient balance / 402", () => { + const msg = + "Error code: 402 - {'error': {'message': 'Insufficient Balance', " + + "'type': 'unknown_error', 'param': None, 'code': 'invalid_request_error'}}"; + expect(classifyChatStreamError(msg)).toBe( + "stream_errors.insufficient_balance", + ); + expect( + classifyChatStreamError( + "HTTP 402 POST https://api.example.com/v1/chat: Insufficient Balance", + ), + ).toBe("stream_errors.insufficient_balance"); + expect(chatStreamErrorAction(msg)).toEqual({ + path: "/admin/models", + labelKey: "modelConfig.configureButton", + }); + }); + + it("classifies HTTP 5xx as provider_unavailable", () => { + expect(classifyChatStreamError("HTTP 503: service overloaded")).toBe( + "stream_errors.provider_unavailable", + ); + }); + it("classifies LangGraph recursion limit as recursion_limit", () => { const msg = "Recursion limit of 2 reached without hitting a stop condition. " + diff --git a/dashboard/src/utils/chatStreamError.ts b/dashboard/src/utils/chatStreamError.ts index bdb6dd12..f5ed50e2 100644 --- a/dashboard/src/utils/chatStreamError.ts +++ b/dashboard/src/utils/chatStreamError.ts @@ -6,6 +6,7 @@ const _STREAM_ERROR_KEYS = [ "stream_errors.stream_stall", "stream_errors.rate_limit", "stream_errors.auth", + "stream_errors.insufficient_balance", "stream_errors.context_length", "stream_errors.recursion_limit", "stream_errors.timeout_network", @@ -26,6 +27,10 @@ const STREAM_ERROR_ACTIONS: Partial> = path: "/admin/models", labelKey: "modelConfig.configureButton", }, + "stream_errors.insufficient_balance": { + path: "/admin/models", + labelKey: "modelConfig.configureButton", + }, "stream_errors.recursion_limit": { path: "/agent-config", labelKey: "chat.goToAgentConfig", @@ -64,6 +69,7 @@ export function classifyChatStreamError( if ( lower.includes("error code: 429") || + lower.includes("http 429") || lower.includes("rate_limit") || compact.includes("ratelimiterror") || lower.includes("too many requests") @@ -71,8 +77,26 @@ export function classifyChatStreamError( return "stream_errors.rate_limit"; } + if ( + lower.includes("error code: 402") || + lower.includes("http 402") || + lower.includes("insufficient balance") || + lower.includes("insufficient_quota") || + lower.includes("insufficient credits") || + lower.includes("exceeded your current quota") || + lower.includes("payment_required") || + lower.includes("billing_not_active") || + lower.includes("arrearage") || + msg.includes("余额不足") || + msg.includes("账户余额") || + msg.includes("欠费") + ) { + return "stream_errors.insufficient_balance"; + } + if ( lower.includes("error code: 401") || + lower.includes("http 401") || lower.includes("invalid_api_key") || lower.includes("incorrect api key") || compact.includes("authenticationerror") || @@ -108,7 +132,10 @@ export function classifyChatStreamError( lower.includes("service unavailable") || lower.includes("error code: 500") || lower.includes("error code: 502") || - lower.includes("error code: 503") + lower.includes("error code: 503") || + lower.includes("http 500") || + lower.includes("http 502") || + lower.includes("http 503") ) { return "stream_errors.provider_unavailable"; } diff --git a/src/octop/api/routers/backup.py b/src/octop/api/routers/backup.py index b55e07d8..905427d1 100644 --- a/src/octop/api/routers/backup.py +++ b/src/octop/api/routers/backup.py @@ -2,33 +2,43 @@ from __future__ import annotations +import asyncio import logging +import os +import tempfile +from pathlib import Path from typing import Any, cast -from fastapi import APIRouter, Depends, File, Query, UploadFile -from fastapi.responses import StreamingResponse +from fastapi import APIRouter, BackgroundTasks, Depends, File, Query, UploadFile +from fastapi.responses import FileResponse from pydantic import BaseModel, Field from octop.api.common.content_disposition import content_disposition from octop.api.deps import get_server, require_permission -from octop.config import load_config +from octop.config import DatabaseConfig, load_config from octop.infra.backup.auto import ( AUTO_BACKUP_JOB_ID, - BACKUP_LOCK, backup_config_from_payload, + backup_status_payload, + hold_backup_lock, + raise_if_backup_busy, run_auto_backup, update_server_backup_config, ) from octop.infra.backup.store import ( + BackupFileInfo, delete_backup_file, list_backup_files, normalize_backup_filename, - read_backup_file, + place_backup_file, + resolve_backup_path, write_backup_file, ) from octop.infra.backup.system_archive import create_system_backup, restore_system_backup +from octop.infra.db.pool import DatabasePool from octop.infra.db.repos.audit import ACTOR_ADMIN from octop.infra.errors import ErrorCode, OctopError +from octop.infra.utils.paths import PathLayout logger = logging.getLogger(__name__) @@ -63,6 +73,83 @@ def _auto_settings_payload(server: Any) -> dict[str, Any]: } +def _raise_if_backup_busy() -> None: + raise_if_backup_busy() + + +def _create_and_store_manual_backup( + *, + paths: PathLayout, + agent_rows: list[Any], + pool: DatabasePool, + db_config: DatabaseConfig, +) -> BackupFileInfo: + """Sync create + place for ``asyncio.to_thread`` (keeps the event loop free).""" + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) / "backup.tar.gz" + filename = create_system_backup( + paths=paths, + agent_rows=agent_rows, + pool=pool, + db_config=db_config, + dest=tmp_path, + ) + return place_backup_file(paths, filename, tmp_path) + + +def _restore_stored_backup( + *, + paths: PathLayout, + filename: str, + pool: DatabasePool, + db_config: DatabaseConfig, + restore_config: bool, + owner_user_id: int, +) -> dict[str, Any]: + """Sync path-based restore for ``asyncio.to_thread``.""" + archive = resolve_backup_path(paths, filename) + return restore_system_backup( + archive, + paths=paths, + pool=pool, + db_config=db_config, + restore_config=restore_config, + owner_user_id=owner_user_id, + ) + + +def _create_ephemeral_backup( + *, + paths: PathLayout, + agent_rows: list[Any], + pool: DatabasePool, + db_config: DatabaseConfig, +) -> tuple[Path, str]: + """Write a backup to a temp file; caller must delete after the response is sent.""" + fd, name = tempfile.mkstemp(prefix="octop-export-", suffix=".tar.gz") + os.close(fd) + tmp_path = Path(name) + try: + filename = create_system_backup( + paths=paths, + agent_rows=agent_rows, + pool=pool, + db_config=db_config, + dest=tmp_path, + ) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + return tmp_path, filename + + +def _unlink_quiet(path: Path) -> None: + try: + path.unlink(missing_ok=True) + except OSError: + logger.warning("failed to remove ephemeral backup %s", path, exc_info=True) + + async def _rehydrate_runtime_after_restore(server: Any) -> None: """Sync restored providers/agents, IM channels, and cron (no process restart).""" runtime = getattr(server, "app_runtime", None) @@ -101,6 +188,14 @@ async def list_backups( } +@router.get("/backup/status", summary="Whether a backup or restore is in progress") +async def get_backup_status( + _: Any = Depends(require_permission("backup")), +) -> dict[str, Any]: + """Return lock state for dashboard busy UI across navigation / refresh.""" + return backup_status_payload() + + @router.get("/backup/auto", summary="Get automatic backup settings") async def get_auto_backup_settings( _: Any = Depends(require_permission("backup")), @@ -128,11 +223,7 @@ async def run_auto_backup_now( server: Any = Depends(get_server), ) -> dict[str, Any]: """Create one automatic backup immediately (same path as the scheduled job).""" - if BACKUP_LOCK.locked(): - raise OctopError( - ErrorCode.BACKUP_IN_PROGRESS, - "a backup is already in progress", - ) + _raise_if_backup_busy() entry = await run_auto_backup(server) if entry is None: raise OctopError( @@ -149,38 +240,35 @@ async def create_backup( ) -> dict[str, Any]: """Create a full backup archive and persist it under ``backups_dir``.""" assert server.services is not None - if BACKUP_LOCK.locked(): - raise OctopError( - ErrorCode.BACKUP_IN_PROGRESS, - "a backup is already in progress", - ) - async with BACKUP_LOCK: - data, filename = create_system_backup( + _raise_if_backup_busy() + async with hold_backup_lock("create"): + entry = await asyncio.to_thread( + _create_and_store_manual_backup, paths=server.paths, agent_rows=_agent_rows(server), pool=server.services.db, db_config=server.services.config.database, ) - entry = write_backup_file(server.paths, filename, data) return {"ok": True, "item": entry.to_dict()} @router.get( "/backup/files/{filename}", summary="Download a stored backup archive", - response_class=StreamingResponse, + response_class=FileResponse, ) async def download_backup_file( filename: str, _: Any = Depends(require_permission("backup")), server: Any = Depends(get_server), -) -> StreamingResponse: - """Stream a backup file from ``backups_dir``.""" +) -> FileResponse: + """Stream a backup file from ``backups_dir`` without loading it into memory.""" safe = normalize_backup_filename(filename) - data = read_backup_file(server.paths, safe) - return StreamingResponse( - iter([data]), + path = await asyncio.to_thread(resolve_backup_path, server.paths, safe) + return FileResponse( + path, media_type="application/gzip", + filename=safe, headers={"Content-Disposition": content_disposition(safe)}, ) @@ -200,19 +288,26 @@ async def restore_backup_file( Restored ``config.json`` / ``env`` still require a process restart to take effect. LightClaw migration archives reassign imported ownership to the restoring admin. + + Heavy disk/DB work runs in a worker thread so the asyncio event loop stays + responsive; concurrent create/restore/auto backup is rejected via + ``BACKUP_LOCK``. SQLite restore still holds the shared DB lock for the + merge window, so DB-backed APIs may briefly queue during that phase. """ assert server.services is not None + _raise_if_backup_busy() safe = normalize_backup_filename(filename) - raw = read_backup_file(server.paths, safe) - result = restore_system_backup( - raw, - paths=server.paths, - pool=server.services.db, - db_config=server.services.config.database, - restore_config=restore_config, - owner_user_id=int(user.id), - ) - await _rehydrate_runtime_after_restore(server) + async with hold_backup_lock("restore"): + result = await asyncio.to_thread( + _restore_stored_backup, + paths=server.paths, + filename=safe, + pool=server.services.db, + db_config=server.services.config.database, + restore_config=restore_config, + owner_user_id=int(user.id), + ) + await _rehydrate_runtime_after_restore(server) server.services.audit_repo.write( actor=getattr(user, "username", None) or ACTOR_ADMIN, action="backup.restore", @@ -230,29 +325,35 @@ async def remove_backup_file( ) -> None: """Remove a backup archive from ``backups_dir``.""" safe = normalize_backup_filename(filename) - delete_backup_file(server.paths, safe) + await asyncio.to_thread(delete_backup_file, server.paths, safe) @router.get( "/backup/export", summary="Download full system backup (ephemeral)", - response_class=StreamingResponse, + response_class=FileResponse, ) async def export_backup( + background_tasks: BackgroundTasks, _: Any = Depends(require_permission("backup")), server: Any = Depends(get_server), -) -> StreamingResponse: +) -> FileResponse: """Create and stream a backup without persisting to ``backups_dir``.""" assert server.services is not None - data, filename = create_system_backup( - paths=server.paths, - agent_rows=_agent_rows(server), - pool=server.services.db, - db_config=server.services.config.database, - ) - return StreamingResponse( - iter([data]), + _raise_if_backup_busy() + async with hold_backup_lock("export"): + tmp_path, filename = await asyncio.to_thread( + _create_ephemeral_backup, + paths=server.paths, + agent_rows=_agent_rows(server), + pool=server.services.db, + db_config=server.services.config.database, + ) + background_tasks.add_task(_unlink_quiet, tmp_path) + return FileResponse( + tmp_path, media_type="application/gzip", + filename=filename, headers={"Content-Disposition": content_disposition(filename)}, ) @@ -272,5 +373,5 @@ async def import_backup( name = file.filename or "uploaded-backup.tar.gz" safe = normalize_backup_filename(name) - entry = write_backup_file(server.paths, safe, raw) + entry = await asyncio.to_thread(write_backup_file, server.paths, safe, raw) return {"ok": True, "item": entry.to_dict()} diff --git a/src/octop/api/routers/providers.py b/src/octop/api/routers/providers.py index e030f13f..c9c49634 100644 --- a/src/octop/api/routers/providers.py +++ b/src/octop/api/routers/providers.py @@ -8,7 +8,7 @@ from types import SimpleNamespace from typing import Any, cast -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from pydantic import BaseModel from octop.api.deps import current_user, get_server, require_permission @@ -36,6 +36,7 @@ poll_device_token, request_device_code, ) +from octop.infra.utils.locale import resolve_request_locale from octop.infra.utils.ulid import new_ulid logger = logging.getLogger(__name__) @@ -297,6 +298,7 @@ async def admin_delete_provider( @admin_router.post("/test-draft", summary="Test unsaved provider draft") async def admin_test_provider_draft( body: ProviderTestDraftBody, + request: Request, _: Any = Depends(require_permission("providers")), ) -> dict[str, Any]: """Probe connectivity for a provider draft before it is saved.""" @@ -315,12 +317,18 @@ async def admin_test_provider_draft( extra_json=body.extra_json, embedding=body.embedding, ) - return await probe_provider_row(row, model_id=model_id, embedding=body.embedding) + return await probe_provider_row( + row, + model_id=model_id, + embedding=body.embedding, + locale=resolve_request_locale(request), + ) @admin_router.post("/fetch-models", summary="List models from an OpenAI-compatible draft") async def admin_fetch_provider_models( body: ProviderFetchModelsBody, + request: Request, _: Any = Depends(require_permission("providers")), ) -> dict[str, Any]: """Fetch remote model ids via OpenAI-compatible ``GET /models`` (openai kind only).""" @@ -337,6 +345,7 @@ async def admin_fetch_provider_models( base_url=(body.base_url or "").strip() or None, api_key=api_key, extra_headers=provider_headers(draft) or None, + locale=resolve_request_locale(request), ) @@ -456,6 +465,7 @@ async def codex_oauth_logout( @admin_router.post("/{provider_id}/test") async def admin_test_provider( provider_id: int, + request: Request, body: ProviderTestBody | None = None, _: Any = Depends(require_permission("providers")), server: Any = Depends(get_server), @@ -467,4 +477,9 @@ async def admin_test_provider( row = await _maybe_refresh_codex_row(server, row) model_id = body.model_id if body else None embedding = body.embedding if body else None - return await probe_provider_row(row, model_id=model_id, embedding=embedding) + return await probe_provider_row( + row, + model_id=model_id, + embedding=embedding, + locale=resolve_request_locale(request), + ) diff --git a/src/octop/api/routers/setup.py b/src/octop/api/routers/setup.py index 7387453b..8a7f7dda 100644 --- a/src/octop/api/routers/setup.py +++ b/src/octop/api/routers/setup.py @@ -390,6 +390,7 @@ async def resume_wizard(server: Any = Depends(get_server)) -> dict[str, Any]: @router.post("/setup/test-provider", summary="Test provider draft connectivity") async def test_provider_draft( body: ProviderTestBody, + request: Request, server: Any = Depends(get_server), authorization: str | None = Header(default=None), ) -> dict[str, Any]: @@ -405,7 +406,7 @@ async def test_provider_draft( base_url=body.base_url, model_id=body.model_id, ) - return await probe_provider_row(row) + return await probe_provider_row(row, locale=resolve_request_locale(request)) @router.post("/setup/finish", summary="Finish setup wizard") diff --git a/src/octop/cli/commands/backup.py b/src/octop/cli/commands/backup.py index 8503b9f5..f706bb85 100644 --- a/src/octop/cli/commands/backup.py +++ b/src/octop/cli/commands/backup.py @@ -2,12 +2,14 @@ from __future__ import annotations +import tempfile from pathlib import Path import click from octop.config import load_config from octop.infra.backup.auto import create_and_store_auto_backup +from octop.infra.backup.store import place_backup_file from octop.infra.backup.system_archive import create_system_backup, restore_system_backup from octop.infra.db.factory import open_database from octop.infra.db.migrate import run_migrations @@ -39,21 +41,29 @@ def create(output: Path | None, home: Path | None) -> None: run_migrations(db) services = build_shared_services(db=db, paths=paths, config=config) rows = services.agent_repo.list_all() - data, suggested = create_system_backup( - paths=paths, - agent_rows=rows, - pool=db, - db_config=config.database, - ) - db.close() + try: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) / "backup.tar.gz" + suggested = create_system_backup( + paths=paths, + agent_rows=rows, + pool=db, + db_config=config.database, + dest=tmp_path, + ) + if output is None: + entry = place_backup_file(paths, suggested, tmp_path) + dest = paths.backup_file(entry.name) + size = entry.size + else: + dest = output + dest.parent.mkdir(parents=True, exist_ok=True) + tmp_path.replace(dest) + size = dest.stat().st_size + finally: + db.close() - if output is None: - paths.ensure_backups_dir() - dest = paths.backup_file(suggested) - else: - dest = output - dest.write_bytes(data) - click.echo(f"wrote {dest} ({len(data)} bytes)") + click.echo(f"wrote {dest} ({size} bytes)") @backup.group("auto") @@ -133,9 +143,8 @@ def restore( paths = _paths(home) config = load_config(paths.config) db = open_database(config, paths) - raw = archive.read_bytes() result = restore_system_backup( - raw, + archive, paths=paths, pool=db, db_config=config.database, diff --git a/src/octop/cli/support/embedded_ops.py b/src/octop/cli/support/embedded_ops.py index 9d801864..394f3c32 100644 --- a/src/octop/cli/support/embedded_ops.py +++ b/src/octop/cli/support/embedded_ops.py @@ -67,13 +67,13 @@ def fetch_thread_history(agent_id: str, thread_id: str, *, limit: int = 50) -> A async def probe_provider_async(provider_id: int, *, model_id: str | None = None) -> dict[str, Any]: - from octop.cli.support.db import open_cli_services + from octop.cli.support.db import open_cli_services, resolve_cli_locale with open_cli_services() as svc: row = svc.provider_repo.get(provider_id) if row is None: raise OctopError(ErrorCode.NOT_FOUND, "provider not found") - return await probe_provider_row(row, model_id=model_id) + return await probe_provider_row(row, model_id=model_id, locale=resolve_cli_locale()) def probe_provider(provider_id: int, *, model_id: str | None = None) -> dict[str, Any]: diff --git a/src/octop/i18n/domains/stream.py b/src/octop/i18n/domains/stream.py index 2d21d543..05d4bb6c 100644 --- a/src/octop/i18n/domains/stream.py +++ b/src/octop/i18n/domains/stream.py @@ -10,6 +10,7 @@ STREAM_STALL = f"{_PREFIX}stream_errors.stream_stall" RATE_LIMIT = f"{_PREFIX}stream_errors.rate_limit" AUTH = f"{_PREFIX}stream_errors.auth" +INSUFFICIENT_BALANCE = f"{_PREFIX}stream_errors.insufficient_balance" CONTEXT_LENGTH = f"{_PREFIX}stream_errors.context_length" RECURSION_LIMIT = f"{_PREFIX}stream_errors.recursion_limit" TIMEOUT_NETWORK = f"{_PREFIX}stream_errors.timeout_network" @@ -19,6 +20,7 @@ __all__ = [ "AUTH", "CONTEXT_LENGTH", + "INSUFFICIENT_BALANCE", "MODEL_CALL_FAILED", "PROVIDER_UNAVAILABLE", "RATE_LIMIT", @@ -64,14 +66,32 @@ def classify_stream_error_message(message: str) -> str | None: if ( "error code: 429" in lower + or "http 429" in lower or "rate_limit" in lower or "ratelimiterror" in compact or "too many requests" in lower ): return RATE_LIMIT + if ( + "error code: 402" in lower + or "http 402" in lower + or "insufficient balance" in lower + or "insufficient_quota" in lower + or "insufficient credits" in lower + or "exceeded your current quota" in lower + or "payment_required" in lower + or "billing_not_active" in lower + or "arrearage" in lower + or "余额不足" in msg + or "账户余额" in msg + or "欠费" in msg + ): + return INSUFFICIENT_BALANCE + if ( "error code: 401" in lower + or "http 401" in lower or "invalid_api_key" in lower or "incorrect api key" in lower or "authenticationerror" in compact @@ -105,6 +125,9 @@ def classify_stream_error_message(message: str) -> str | None: or "error code: 500" in lower or "error code: 502" in lower or "error code: 503" in lower + or "http 500" in lower + or "http 502" in lower + or "http 503" in lower ): return PROVIDER_UNAVAILABLE diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index e52d594d..96ec5fad 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -286,7 +286,7 @@ "SETUP_REQUIRED": "Initial setup is required.", "DATABASE_NOT_EMPTY": "The target database already has users. Use an empty database, or log in with the existing admin.", "BACKUP_DRIVER_MISMATCH": "This backup was made with a different database engine (SQLite vs PostgreSQL). Switch the runtime to match the backup, or create a new backup on the current engine. Cross-engine restore is not supported.", - "BACKUP_IN_PROGRESS": "A backup is already in progress. Try again shortly.", + "BACKUP_IN_PROGRESS": "A backup or restore is already in progress. Try again shortly.", "FORBIDDEN": "Permission denied.", "NOT_FOUND": "Not found.", "USER_DISABLED": "This account has been disabled.", @@ -551,6 +551,7 @@ "stream_stall": "The model stopped sending content while the connection stayed open. Click Retry, or try again later with another model. If this happens often, check the provider status or ask an admin to review stream timeout settings.", "rate_limit": "The model provider rate-limited this request. Wait a moment and retry, or switch to another model.", "auth": "Model authentication failed. Check that the API key under Settings → Models is correct and still valid.", + "insufficient_balance": "The model provider rejected this request due to insufficient balance or quota. Top up or upgrade the plan for this API key, then try again.", "context_length": "This conversation is too long for the model's context window. Start a new chat, or shorten the history and try again.", "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index b6861e94..e8057f13 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -286,7 +286,7 @@ "SETUP_REQUIRED": "需要完成初始设置。", "DATABASE_NOT_EMPTY": "目标数据库已有用户。请改用空库,或直接登录现有管理员账户。", "BACKUP_DRIVER_MISMATCH": "该备份与当前数据库引擎不一致(SQLite 与 PostgreSQL 不能互恢)。请将运行时切回备份所用引擎后再恢复,或在当前引擎上重新备份。暂不支持跨引擎恢复。", - "BACKUP_IN_PROGRESS": "已有备份任务正在进行,请稍后再试。", + "BACKUP_IN_PROGRESS": "已有备份或恢复任务正在进行,请稍后再试。", "FORBIDDEN": "没有权限。", "NOT_FOUND": "未找到。", "USER_DISABLED": "账号已禁用。", @@ -551,6 +551,7 @@ "stream_stall": "模型响应中断:连接仍在,但长时间没有新内容。请点击「重试」,或稍后更换模型再试。若经常出现,请检查供应商状态,或联系管理员排查流式超时设置。", "rate_limit": "模型请求过于频繁,已被限流。请稍等片刻后重试,或切换其他模型。", "auth": "模型服务鉴权失败。请检查「设置 → 模型」中的 API Key 是否正确、是否过期。", + "insufficient_balance": "模型服务返回余额或额度不足。请为该 API Key 对应的账户充值或升级套餐后再试。", "context_length": "对话上下文过长,超出模型限制。请新开会话,或精简历史后再试。", "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", diff --git a/src/octop/infra/agents/providers/probe.py b/src/octop/infra/agents/providers/probe.py index cd4b554d..0d8fae1c 100644 --- a/src/octop/infra/agents/providers/probe.py +++ b/src/octop/infra/agents/providers/probe.py @@ -149,7 +149,16 @@ def _embeddings_url(base_url: str | None) -> str: return f"{root}/embeddings" -async def _probe_embedding_endpoint(row: Any, *, model_id: str) -> dict[str, Any]: +def _friendly_probe_error(exc: BaseException | str, *, locale: str) -> str: + """Map raw provider exceptions / HTTP bodies to localized guidance when known.""" + from octop.i18n.domains.stream import stream_error_message + + return stream_error_message(str(exc), locale) + + +async def _probe_embedding_endpoint( + row: Any, *, model_id: str, locale: str = "en" +) -> dict[str, Any]: """POST OpenAI-compatible ``{base}/embeddings`` and time the round-trip.""" started = time.perf_counter() url = _embeddings_url(getattr(row, "base_url", None)) @@ -166,7 +175,7 @@ async def _probe_embedding_endpoint(row: Any, *, model_id: str) -> dict[str, Any ) except Exception as exc: logger.info("embedding probe failed for %s: %s", getattr(row, "name", "?"), exc) - return {"ok": False, "error": str(exc)} + return {"ok": False, "error": _friendly_probe_error(exc, locale=locale)} if response.status_code >= 400: detail = response.text.strip() @@ -175,7 +184,7 @@ async def _probe_embedding_endpoint(row: Any, *, model_id: str) -> dict[str, Any error = f"HTTP {response.status_code} POST {url}" if detail: error = f"{error}: {detail}" - return {"ok": False, "error": error} + return {"ok": False, "error": _friendly_probe_error(error, locale=locale)} try: payload = response.json() @@ -198,7 +207,11 @@ async def _probe_embedding_endpoint(row: Any, *, model_id: str) -> dict[str, Any async def probe_provider_row( - row: Any, *, model_id: str | None = None, embedding: bool | None = None + row: Any, + *, + model_id: str | None = None, + embedding: bool | None = None, + locale: str = "en", ) -> dict[str, Any]: """Probe a provider: chat models get a one-token ping; embedding models POST /embeddings.""" from octop.infra.agents.providers.model_flags import is_onnx_local_provider @@ -213,18 +226,20 @@ async def probe_provider_row( result = await probe_local_model(_onnx_probe_model_id(row, model_id)) if result.get("latency_ms") is not None: result["latency_ms"] = int(result["latency_ms"]) + if not result.get("ok") and result.get("error"): + result["error"] = _friendly_probe_error(str(result["error"]), locale=locale) return result mid = _probe_model_id(row, model_id) if _should_probe_embedding(row, model_id=mid, embedding=embedding): - return await _probe_embedding_endpoint(row, model_id=mid) + return await _probe_embedding_endpoint(row, model_id=mid, locale=locale) started = time.perf_counter() try: chat = build_probe_chat_model(row, model_id=mid) result = await asyncio.wait_for(chat.ainvoke("ping"), timeout=30.0) except Exception as exc: logger.info("provider probe failed for %s: %s", getattr(row, "name", "?"), exc) - return {"ok": False, "error": str(exc)} + return {"ok": False, "error": _friendly_probe_error(exc, locale=locale)} latency_ms = int((time.perf_counter() - started) * 1000) _ = getattr(result, "content", None) return {"ok": True, "latency_ms": latency_ms} @@ -240,6 +255,7 @@ async def fetch_openai_compatible_models( base_url: str | None, api_key: str, extra_headers: dict[str, str] | None = None, + locale: str = "en", ) -> dict[str, Any]: """List models via OpenAI-compatible ``GET {base}/models``.""" url = _models_list_url(base_url) @@ -251,7 +267,7 @@ async def fetch_openai_compatible_models( response = await client.get(url, headers=headers) except Exception as exc: logger.info("provider fetch-models failed for %s: %s", url, exc) - return {"ok": False, "error": str(exc)} + return {"ok": False, "error": _friendly_probe_error(exc, locale=locale)} if response.status_code >= 400: detail = response.text.strip() @@ -260,7 +276,7 @@ async def fetch_openai_compatible_models( error = f"HTTP {response.status_code}" if detail: error = f"{error}: {detail}" - return {"ok": False, "error": error} + return {"ok": False, "error": _friendly_probe_error(error, locale=locale)} try: payload = response.json() diff --git a/src/octop/infra/backup/auto.py b/src/octop/infra/backup/auto.py index 6bb8c998..2f4c2aff 100644 --- a/src/octop/infra/backup/auto.py +++ b/src/octop/infra/backup/auto.py @@ -5,16 +5,19 @@ import asyncio import json import logging +import tempfile +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from dataclasses import replace from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast from octop.config import BackupConfig, OctopConfig, load_config from octop.infra.backup.store import ( BackupFileInfo, is_auto_backup_filename, + place_backup_file, prune_auto_backups, - write_backup_file, ) from octop.infra.backup.system_archive import create_system_backup from octop.infra.cron.trigger import build_trigger @@ -34,12 +37,47 @@ _AUTO_FILENAME_PREFIX = "octop-auto-backup-" _MANUAL_FILENAME_PREFIX = "octop-backup-" +BackupOperation = Literal["create", "restore", "auto", "export"] + _lock = asyncio.Lock() +_active_operation: BackupOperation | None = None # Shared with manual create so concurrent backups do not overlap. BACKUP_LOCK = _lock +def get_backup_operation() -> BackupOperation | None: + """Return the in-flight backup/restore kind, or ``None`` when idle.""" + if not _lock.locked(): + return None + return _active_operation + + +def backup_status_payload() -> dict[str, Any]: + op = get_backup_operation() + return {"busy": op is not None, "operation": op} + + +@asynccontextmanager +async def hold_backup_lock(operation: BackupOperation) -> AsyncIterator[None]: + """Acquire ``BACKUP_LOCK`` and publish *operation* for status polling.""" + global _active_operation + async with _lock: + _active_operation = operation + try: + yield + finally: + _active_operation = None + + +def raise_if_backup_busy() -> None: + if _lock.locked(): + raise OctopError( + ErrorCode.BACKUP_IN_PROGRESS, + "a backup or restore is already in progress", + ) + + def to_auto_backup_filename(suggested: str) -> str: """Map ``octop-backup-*.tar.gz`` → ``octop-auto-backup-*.tar.gz``.""" name = Path(suggested).name @@ -105,14 +143,17 @@ def create_and_store_auto_backup( retention_count: int, ) -> tuple[BackupFileInfo, list[str]]: """Create a full system backup with the auto filename prefix and prune.""" - data, suggested = create_system_backup( - paths=paths, - agent_rows=agent_rows, - pool=pool, - db_config=db_config, - ) - filename = to_auto_backup_filename(suggested) - entry = write_backup_file(paths, filename, data) + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) / "backup.tar.gz" + suggested = create_system_backup( + paths=paths, + agent_rows=agent_rows, + pool=pool, + db_config=db_config, + dest=tmp_path, + ) + filename = to_auto_backup_filename(suggested) + entry = place_backup_file(paths, filename, tmp_path) deleted = prune_auto_backups(paths, keep=retention_count) return entry, deleted @@ -123,7 +164,7 @@ async def run_auto_backup(server: OctopServer) -> BackupFileInfo | None: logger.info("auto backup skipped: another backup is already running") return None - async with _lock: + async with hold_backup_lock("auto"): config = load_config(server.paths.config) if server.services is None: logger.warning("auto backup skipped: server services not ready") diff --git a/src/octop/infra/backup/store.py b/src/octop/infra/backup/store.py index aa9260c8..21f4e5a6 100644 --- a/src/octop/infra/backup/store.py +++ b/src/octop/infra/backup/store.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import shutil from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -74,49 +75,66 @@ def list_backup_files(paths: PathLayout) -> list[BackupFileInfo]: continue if not any(path.name.endswith(suffix) for suffix in _BACKUP_SUFFIXES): continue - stat = path.stat() - modified = _iso_utc_from_timestamp(stat.st_mtime) - created = resolve_backup_created_at(path.name, path, mtime=stat.st_mtime) - out.append( - BackupFileInfo( - name=path.name, - size=stat.st_size, - modified_at=modified, - created_at=created, - ) - ) + out.append(backup_file_info(path)) return out -def write_backup_file(paths: PathLayout, filename: str, data: bytes) -> BackupFileInfo: - paths.ensure_backups_dir() - safe = normalize_backup_filename(filename) - dest = paths.backup_file(safe) - dest.write_bytes(data) - stat = dest.stat() +def backup_file_info(path: Path) -> BackupFileInfo: + """Build ``BackupFileInfo`` from an existing archive path.""" + path = Path(path) + if not path.is_file(): + raise OctopError(ErrorCode.NOT_FOUND, f"backup not found: {path.name}") + stat = path.stat() modified = _iso_utc_from_timestamp(stat.st_mtime) - created = resolve_backup_created_at(safe, dest, mtime=stat.st_mtime) + created = resolve_backup_created_at(path.name, path, mtime=stat.st_mtime) return BackupFileInfo( - name=safe, + name=path.name, size=stat.st_size, modified_at=modified, created_at=created, ) -def read_backup_file(paths: PathLayout, filename: str) -> bytes: +def write_backup_file(paths: PathLayout, filename: str, data: bytes) -> BackupFileInfo: + paths.ensure_backups_dir() safe = normalize_backup_filename(filename) - path = paths.backup_file(safe) - if not path.is_file(): - raise OctopError(ErrorCode.NOT_FOUND, f"backup not found: {safe}") - return path.read_bytes() + dest = paths.backup_file(safe) + dest.write_bytes(data) + return backup_file_info(dest) -def delete_backup_file(paths: PathLayout, filename: str) -> None: +def place_backup_file(paths: PathLayout, filename: str, src: Path) -> BackupFileInfo: + """Move *src* into ``backups_dir`` under *filename* (atomic replace when possible).""" + paths.ensure_backups_dir() + safe = normalize_backup_filename(filename) + dest = paths.backup_file(safe) + src = Path(src) + if src.resolve() == dest.resolve(): + return backup_file_info(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + try: + src.replace(dest) + except OSError: + shutil.copy2(src, dest) + src.unlink(missing_ok=True) + return backup_file_info(dest) + + +def resolve_backup_path(paths: PathLayout, filename: str) -> Path: + """Return the on-disk path for a stored backup (must exist).""" safe = normalize_backup_filename(filename) path = paths.backup_file(safe) if not path.is_file(): raise OctopError(ErrorCode.NOT_FOUND, f"backup not found: {safe}") + return path + + +def read_backup_file(paths: PathLayout, filename: str) -> bytes: + return resolve_backup_path(paths, filename).read_bytes() + + +def delete_backup_file(paths: PathLayout, filename: str) -> None: + path = resolve_backup_path(paths, filename) path.unlink() diff --git a/src/octop/infra/backup/system_archive.py b/src/octop/infra/backup/system_archive.py index 8e45ef2b..53a87904 100644 --- a/src/octop/infra/backup/system_archive.py +++ b/src/octop/infra/backup/system_archive.py @@ -45,47 +45,65 @@ _PG_DUMP_ARC = f"{_DB_DIR}/octop.dump" _MIGRATION_VERSION_SUFFIX = "-migrated-from-lightclaw" +# Align with workspace zip export; keep backups smaller / faster. +_SKIP_DIR_NAMES = frozenset( + { + ".git", + "__pycache__", + ".venv", + "venv", + "node_modules", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".next", + ".turbo", + "dist", + "build", + } +) + def _timestamp() -> str: return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") +def suggested_backup_filename() -> str: + """Canonical basename for a newly created manual backup archive.""" + return f"octop-backup-{_timestamp()}.tar.gz" + + +def _should_skip_path(rel: Path) -> bool: + return any(part in _SKIP_DIR_NAMES for part in rel.parts) + + def _add_dir(tf: tarfile.TarFile, src: Path, arc_root: str) -> None: if not src.is_dir(): return for path in sorted(src.rglob("*")): if not path.is_file(): continue - rel = path.relative_to(src).as_posix() - tf.add(path, arcname=f"{arc_root}/{rel}") + rel = path.relative_to(src) + if _should_skip_path(rel): + continue + tf.add(path, arcname=f"{arc_root}/{rel.as_posix()}") -def create_system_backup( +def _build_manifest( *, paths: PathLayout, agent_rows: list[Any], pool: DatabasePool, - db_config: DatabaseConfig, -) -> tuple[bytes, str]: - """Build a ``.tar.gz`` archive and return ``(bytes, suggested_filename)``.""" + db_arc: str, + database_driver: str, + database_dump_format: str, + env_path: Path, +) -> BackupManifest: try: schema_version = _current_version(pool) except Exception: schema_version = 0 - - if pool.dialect == "postgresql": - db_arc = _PG_DUMP_ARC - database_driver = "postgresql" - database_dump_format = "pg_custom" - else: - db_arc = _SQLITE_DB_ARC - database_driver = "sqlite" - database_dump_format = "sqlite_file" - if not isinstance(pool, SqlitePool): - raise OctopError(ErrorCode.INTERNAL_ERROR, "sqlite backup requires SqlitePool") - if not pool.path.is_file(): - raise OctopError(ErrorCode.NOT_FOUND, f"database not found: {pool.path}") - agents = [ AgentBackupEntry( agent_id=str(row.agent_id), @@ -94,8 +112,7 @@ def create_system_backup( ) for row in agent_rows ] - env_path = env_file_path(paths.root) - manifest = BackupManifest( + return BackupManifest( manifest_version=MANIFEST_VERSION, octop_version=__version__, schema_version=schema_version, @@ -109,57 +126,106 @@ def create_system_backup( includes_env=env_path.is_file(), ) - buf = io.BytesIO() - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - db_dest = root / db_arc - if pool.dialect == "postgresql": - dump_postgres(db_config.postgresql_conninfo(), db_dest) - else: - assert isinstance(pool, SqlitePool) - snapshot_sqlite_file(pool.path, db_dest) - - manifest_path = root / _MANIFEST_NAME - manifest_path.write_text(manifest.to_json(), encoding="utf-8") - - if paths.config.is_file(): - cfg_dir = root / _CONFIG_DIR - cfg_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(paths.config, cfg_dir / "config.json") - if env_path.is_file(): - cfg_dir = root / _CONFIG_DIR - cfg_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(env_path, cfg_dir / "env") - - with tarfile.open(fileobj=buf, mode="w:gz") as tf: - tf.add(manifest_path, arcname=_MANIFEST_NAME) - tf.add(db_dest, arcname=db_arc) + +def create_system_backup( + *, + paths: PathLayout, + agent_rows: list[Any], + pool: DatabasePool, + db_config: DatabaseConfig, + dest: Path, +) -> str: + """Write a ``.tar.gz`` archive to *dest* (streamed to disk). + + Returns the suggested basename (``octop-backup-….tar.gz``). Callers that + need a different name should rename/move *dest* afterward. + """ + if pool.dialect == "postgresql": + db_arc = _PG_DUMP_ARC + database_driver = "postgresql" + database_dump_format = "pg_custom" + else: + db_arc = _SQLITE_DB_ARC + database_driver = "sqlite" + database_dump_format = "sqlite_file" + if not isinstance(pool, SqlitePool): + raise OctopError(ErrorCode.INTERNAL_ERROR, "sqlite backup requires SqlitePool") + if not pool.path.is_file(): + raise OctopError(ErrorCode.NOT_FOUND, f"database not found: {pool.path}") + + env_path = env_file_path(paths.root) + manifest = _build_manifest( + paths=paths, + agent_rows=agent_rows, + pool=pool, + db_arc=db_arc, + database_driver=database_driver, + database_dump_format=database_dump_format, + env_path=env_path, + ) + filename = suggested_backup_filename() + dest = Path(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + partial = dest.with_name(dest.name + ".partial") + + try: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + db_dest = root / db_arc + if pool.dialect == "postgresql": + dump_postgres(db_config.postgresql_conninfo(), db_dest) + else: + assert isinstance(pool, SqlitePool) + snapshot_sqlite_file(pool.path, db_dest) + + manifest_path = root / _MANIFEST_NAME + manifest_path.write_text(manifest.to_json(), encoding="utf-8") + if paths.config.is_file(): - tf.add(root / _CONFIG_DIR / "config.json", arcname=f"{_CONFIG_DIR}/config.json") + cfg_dir = root / _CONFIG_DIR + cfg_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(paths.config, cfg_dir / "config.json") if env_path.is_file(): - tf.add(root / _CONFIG_DIR / "env", arcname=f"{_CONFIG_DIR}/env") - for row in agent_rows: - ws = workspace_dir_from_config_json( - getattr(row, "config_json", None), - paths=paths, - agent_id=str(row.agent_id), - ) - if ws.is_dir(): - _add_dir(tf, ws, f"{_WORKSPACES_DIR}/{row.agent_id}") - if paths.skill_packages_dir.is_dir(): - _add_dir(tf, paths.skill_packages_dir, _SKILL_PACKAGES_DIR) + cfg_dir = root / _CONFIG_DIR + cfg_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(env_path, cfg_dir / "env") + + with tarfile.open(partial, mode="w:gz") as tf: + tf.add(manifest_path, arcname=_MANIFEST_NAME) + tf.add(db_dest, arcname=db_arc) + if paths.config.is_file(): + tf.add( + root / _CONFIG_DIR / "config.json", + arcname=f"{_CONFIG_DIR}/config.json", + ) + if env_path.is_file(): + tf.add(root / _CONFIG_DIR / "env", arcname=f"{_CONFIG_DIR}/env") + for row in agent_rows: + ws = workspace_dir_from_config_json( + getattr(row, "config_json", None), + paths=paths, + agent_id=str(row.agent_id), + ) + if ws.is_dir(): + _add_dir(tf, ws, f"{_WORKSPACES_DIR}/{row.agent_id}") + if paths.skill_packages_dir.is_dir(): + _add_dir(tf, paths.skill_packages_dir, _SKILL_PACKAGES_DIR) + + partial.replace(dest) + except Exception: + partial.unlink(missing_ok=True) + raise - filename = f"octop-backup-{_timestamp()}.tar.gz" - return buf.getvalue(), filename + return filename -def _extract_manifest(members: dict[str, bytes]) -> BackupManifest: - raw = members.get(_MANIFEST_NAME) - if raw is None: +def _extract_manifest_from_dir(extracted: Path) -> BackupManifest: + manifest_path = extracted / _MANIFEST_NAME + if not manifest_path.is_file(): raise OctopError(ErrorCode.SLASH_BAD_ARGS, "backup archive missing manifest.json") try: - manifest = BackupManifest.load_text(raw.decode("utf-8")) - except (json.JSONDecodeError, ValueError, TypeError) as exc: + manifest = BackupManifest.load_text(manifest_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError, TypeError, OSError) as exc: raise OctopError(ErrorCode.SLASH_BAD_ARGS, f"invalid manifest: {exc}") from exc if manifest.manifest_version != MANIFEST_VERSION: raise OctopError( @@ -169,17 +235,18 @@ def _extract_manifest(members: dict[str, bytes]) -> BackupManifest: return manifest -def _read_tar_members(data: bytes) -> dict[str, bytes]: - out: dict[str, bytes] = {} - with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf: - for member in tf.getmembers(): - if not member.isfile(): - continue - extracted = tf.extractfile(member) - if extracted is None: - continue - out[member.name.replace("\\", "/")] = extracted.read() - return out +def _extract_archive(source: Path | bytes, dest_dir: Path) -> None: + """Extract *source* into *dest_dir* without holding the whole archive in a dict.""" + dest_dir.mkdir(parents=True, exist_ok=True) + if isinstance(source, bytes): + with tarfile.open(fileobj=io.BytesIO(source), mode="r:*") as tf: + # Python 3.12+: refuse path traversal / special files. + tf.extractall(dest_dir, filter=tarfile.data_filter) + return + if not Path(source).is_file(): + raise OctopError(ErrorCode.NOT_FOUND, f"backup not found: {source}") + with tarfile.open(source, mode="r:*") as tf: + tf.extractall(dest_dir, filter=tarfile.data_filter) def _is_migration_backup(manifest: BackupManifest) -> bool: @@ -187,8 +254,22 @@ def _is_migration_backup(manifest: BackupManifest) -> bool: return manifest.octop_version.endswith(_MIGRATION_VERSION_SUFFIX) +def _iter_extracted_files(root: Path, prefix: str) -> list[tuple[str, Path]]: + """Return ``(archive-relative-posix, on-disk path)`` under *prefix*.""" + base = root / prefix + if not base.is_dir(): + return [] + out: list[tuple[str, Path]] = [] + for path in sorted(base.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root).as_posix() + out.append((rel, path)) + return out + + def restore_system_backup( - data: bytes, + source: Path | bytes, *, paths: PathLayout, pool: DatabasePool, @@ -199,6 +280,8 @@ def restore_system_backup( ) -> dict[str, Any]: """Restore database, workspaces, and optional config from a tar.gz archive. + *source* may be a filesystem path (preferred) or in-memory bytes (tests / legacy). + ``preserve_users`` controls whether the *current* Octop instance's login credentials (``users`` rows + ``secrets.jwt``) are written back after the database is replaced: @@ -215,72 +298,69 @@ def restore_system_backup( performing the restore) receives all imported ``user_id`` ownership. When omitted, the first preserved admin (else first preserved user) is used. """ - members = _read_tar_members(data) - manifest = _extract_manifest(members) - is_migration = _is_migration_backup(manifest) - - # Resolve effective preserve_users flag before touching the DB. - effective_preserve_users = is_migration if preserve_users is None else preserve_users - - archive_driver = manifest.database_driver or "sqlite" - if archive_driver != pool.dialect: - raise OctopError( - ErrorCode.BACKUP_DRIVER_MISMATCH, - f"backup database_driver={archive_driver!r} does not match " - f"runtime dialect={pool.dialect!r}; cross-engine restore is refused", - status=400, - details={"archive_driver": archive_driver, "runtime_driver": pool.dialect}, - ) - - db_blob = members.get(manifest.db_file) - if db_blob is None: - raise OctopError(ErrorCode.SLASH_BAD_ARGS, "backup archive missing database file") - - # Capture current login credentials before overwriting the DB (only when needed). - saved_users: list[tuple[object, ...]] = [] - saved_jwt: bytes | None = None - if effective_preserve_users and pool is not None: - saved_users = capture_users_from_pool(pool) - saved_jwt = capture_jwt_secret_from_pool(pool) - - effective_owner: int | None = None - if is_migration: - effective_owner = owner_user_id - if effective_owner is None: - effective_owner = infer_owner_user_id(saved_users) - if effective_owner is not None and saved_users: - saved_ids = {int(str(row[0])) for row in saved_users} - if int(effective_owner) not in saved_ids: - raise OctopError( - ErrorCode.SLASH_BAD_ARGS, - f"owner_user_id={effective_owner} is not among current users", - status=400, - ) - - ownership_remap: dict[str, int] | None = None with tempfile.TemporaryDirectory() as tmp: + extracted = Path(tmp) / "extracted" + _extract_archive(source, extracted) + manifest = _extract_manifest_from_dir(extracted) + is_migration = _is_migration_backup(manifest) + + # Resolve effective preserve_users flag before touching the DB. + effective_preserve_users = is_migration if preserve_users is None else preserve_users + + archive_driver = manifest.database_driver or "sqlite" + if archive_driver != pool.dialect: + raise OctopError( + ErrorCode.BACKUP_DRIVER_MISMATCH, + f"backup database_driver={archive_driver!r} does not match " + f"runtime dialect={pool.dialect!r}; cross-engine restore is refused", + status=400, + details={"archive_driver": archive_driver, "runtime_driver": pool.dialect}, + ) + + db_path = extracted / manifest.db_file + if not db_path.is_file(): + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "backup archive missing database file") + + # Capture current login credentials before overwriting the DB (only when needed). + saved_users: list[tuple[object, ...]] = [] + saved_jwt: bytes | None = None + if effective_preserve_users and pool is not None: + saved_users = capture_users_from_pool(pool) + saved_jwt = capture_jwt_secret_from_pool(pool) + + effective_owner: int | None = None + if is_migration: + effective_owner = owner_user_id + if effective_owner is None: + effective_owner = infer_owner_user_id(saved_users) + if effective_owner is not None and saved_users: + saved_ids = {int(str(row[0])) for row in saved_users} + if int(effective_owner) not in saved_ids: + raise OctopError( + ErrorCode.SLASH_BAD_ARGS, + f"owner_user_id={effective_owner} is not among current users", + status=400, + ) + + ownership_remap: dict[str, int] | None = None if pool.dialect == "postgresql": - dump_path = Path(tmp) / "octop.dump" - dump_path.write_bytes(db_blob) - restore_postgres(db_config.postgresql_conninfo(), dump_path) + restore_postgres(db_config.postgresql_conninfo(), db_path) else: - backup_db = Path(tmp) / "octop.db" - backup_db.write_bytes(db_blob) if isinstance(pool, SqlitePool): - restore_sqlite_into_pool(backup_db, pool) + restore_sqlite_into_pool(db_path, pool) else: raise OctopError(ErrorCode.INTERNAL_ERROR, "sqlite restore requires SqlitePool") if restore_config: - cfg_blob = members.get(f"{_CONFIG_DIR}/config.json") - if cfg_blob is not None: + cfg_path = extracted / _CONFIG_DIR / "config.json" + if cfg_path.is_file(): paths.config.parent.mkdir(parents=True, exist_ok=True) - paths.config.write_bytes(cfg_blob) - env_blob = members.get(f"{_CONFIG_DIR}/env") - if env_blob is not None: + shutil.copy2(cfg_path, paths.config) + env_blob_path = extracted / _CONFIG_DIR / "env" + if env_blob_path.is_file(): env_path = env_file_path(paths.root) env_path.parent.mkdir(parents=True, exist_ok=True) - env_path.write_bytes(env_blob) + shutil.copy2(env_blob_path, env_path) # Migration ownership remap must run after the target owner exists and # before pruning backup placeholder users (avoids ON DELETE CASCADE). @@ -303,14 +383,12 @@ def restore_system_backup( prefix = f"{_WORKSPACES_DIR}/" agent_repo = AgentRepo(pool) workspace_by_agent: dict[str, Path] = {} - for name, blob in members.items(): - if not name.startswith(prefix): + for rel, src_file in _iter_extracted_files(extracted, _WORKSPACES_DIR): + file_rel = rel[len(prefix) :] + if "/" not in file_rel: continue - rel = name[len(prefix) :] - if "/" not in rel: - continue - agent_id, _, file_rel = rel.partition("/") - if not agent_id or not file_rel: + agent_id, _, rest = file_rel.partition("/") + if not agent_id or not rest: continue dest_root = workspace_by_agent.get(agent_id) if dest_root is None: @@ -321,9 +399,9 @@ def restore_system_backup( agent_id=agent_id, ) workspace_by_agent[agent_id] = dest_root - dest = dest_root / file_rel + dest = dest_root / rest dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(blob) + shutil.copy2(src_file, dest) restored_workspaces += 1 # Replace skill-package files wholesale so leftover package dirs cannot @@ -333,15 +411,13 @@ def restore_system_backup( paths.skill_packages_dir.mkdir(parents=True, exist_ok=True) restored_skill_package_files = 0 skill_packages_prefix = f"{_SKILL_PACKAGES_DIR}/" - for name, blob in members.items(): - if not name.startswith(skill_packages_prefix): - continue - rel = name[len(skill_packages_prefix) :] - if not rel: + for rel, src_file in _iter_extracted_files(extracted, _SKILL_PACKAGES_DIR): + rest = rel[len(skill_packages_prefix) :] + if not rest: continue - dest = paths.skill_packages_dir / rel + dest = paths.skill_packages_dir / rest dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(blob) + shutil.copy2(src_file, dest) restored_skill_package_files += 1 # LightClaw migration exports (and older Octop backups) may ship schema v1 diff --git a/tests/integration/test_postgresql_control_plane.py b/tests/integration/test_postgresql_control_plane.py index 249dcd0e..9ddba0dc 100644 --- a/tests/integration/test_postgresql_control_plane.py +++ b/tests/integration/test_postgresql_control_plane.py @@ -208,17 +208,19 @@ class Row: agent_id = "agent01" name = "Test" - data, _ = create_system_backup( + archive = tmp_path / "pg-backup.tar.gz" + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=db_config, + dest=archive, ) _reset_public_schema(pool) run_migrations(pool) result = restore_system_backup( - data, + archive, paths=layout, pool=pool, db_config=db_config, diff --git a/tests/unit/api/test_backup_restore_rehydrate.py b/tests/unit/api/test_backup_restore_rehydrate.py index fcefb722..b71e82fd 100644 --- a/tests/unit/api/test_backup_restore_rehydrate.py +++ b/tests/unit/api/test_backup_restore_rehydrate.py @@ -1,4 +1,4 @@ -"""Unit tests for admin backup restore rehydrate.""" +"""Unit tests for admin backup restore rehydrate and non-blocking paths.""" from __future__ import annotations @@ -8,6 +8,9 @@ import pytest from octop.api.routers import backup as backup_router +from octop.infra.backup.auto import BACKUP_LOCK +from octop.infra.backup.store import BackupFileInfo +from octop.infra.errors import ErrorCode, OctopError @pytest.mark.asyncio @@ -27,11 +30,10 @@ async def test_restore_backup_file_rehydrates_providers_channels_and_cron( } monkeypatch.setattr(backup_router, "normalize_backup_filename", lambda name: name) - monkeypatch.setattr(backup_router, "read_backup_file", lambda *_a, **_k: b"fake-archive") monkeypatch.setattr( backup_router, - "restore_system_backup", - lambda *_a, **_k: restored, + "_restore_stored_backup", + lambda **_k: restored, ) server = MagicMock() @@ -72,11 +74,10 @@ async def test_restore_backup_file_skips_rehydrate_without_runtime( "restore_config": False, } monkeypatch.setattr(backup_router, "normalize_backup_filename", lambda name: name) - monkeypatch.setattr(backup_router, "read_backup_file", lambda *_a, **_k: b"fake-archive") monkeypatch.setattr( backup_router, - "restore_system_backup", - lambda *_a, **_k: restored, + "_restore_stored_backup", + lambda **_k: restored, ) server = MagicMock() @@ -115,3 +116,74 @@ async def test_rehydrate_reloads_channels_and_cron_even_if_provider_rehydrate_fa on_provider_changed.assert_awaited_once_with() reload_channels.assert_awaited_once_with() reload_cron.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_create_backup_offloads_to_thread(monkeypatch: pytest.MonkeyPatch) -> None: + """Manual create must not run tar/sqlite work on the event loop.""" + entry = BackupFileInfo( + name="octop-backup-20260101T000000Z.tar.gz", + size=12, + modified_at="2026-01-01T00:00:00+00:00", + created_at="2026-01-01T00:00:00+00:00", + ) + called: dict[str, Any] = {} + + def _fake_create(**kwargs: Any) -> BackupFileInfo: + called.update(kwargs) + return entry + + monkeypatch.setattr(backup_router, "_create_and_store_manual_backup", _fake_create) + monkeypatch.setattr(backup_router, "_agent_rows", lambda _server: []) + + server = MagicMock() + server.services = MagicMock() + server.services.db = object() + server.services.config.database = object() + server.paths = object() + server.app_runtime = MagicMock() + + result = await backup_router.create_backup(_=None, server=server) + + assert result == {"ok": True, "item": entry.to_dict()} + assert called["pool"] is server.services.db + + +@pytest.mark.asyncio +async def test_create_backup_rejects_when_lock_held() -> None: + await BACKUP_LOCK.acquire() + try: + with pytest.raises(OctopError) as exc_info: + await backup_router.create_backup(_=None, server=MagicMock(services=MagicMock())) + assert exc_info.value.code == ErrorCode.BACKUP_IN_PROGRESS + finally: + BACKUP_LOCK.release() + + +@pytest.mark.asyncio +async def test_restore_backup_rejects_when_lock_held(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(backup_router, "normalize_backup_filename", lambda name: name) + await BACKUP_LOCK.acquire() + try: + with pytest.raises(OctopError) as exc_info: + await backup_router.restore_backup_file( + filename="octop-backup.tar.gz", + restore_config=True, + user=MagicMock(id=1, username="admin"), + server=MagicMock(services=MagicMock()), + ) + assert exc_info.value.code == ErrorCode.BACKUP_IN_PROGRESS + finally: + BACKUP_LOCK.release() + + +@pytest.mark.asyncio +async def test_backup_status_reports_idle_and_busy() -> None: + from octop.infra.backup.auto import hold_backup_lock + + idle = await backup_router.get_backup_status(_=None) + assert idle == {"busy": False, "operation": None} + + async with hold_backup_lock("create"): + busy = await backup_router.get_backup_status(_=None) + assert busy == {"busy": True, "operation": "create"} diff --git a/tests/unit/backup/test_system_archive.py b/tests/unit/backup/test_system_archive.py index 9c6df61f..400fc501 100644 --- a/tests/unit/backup/test_system_archive.py +++ b/tests/unit/backup/test_system_archive.py @@ -42,7 +42,7 @@ def test_manifest_roundtrip_includes_driver_fields() -> None: assert loaded.db_file == "db/octop.dump" -def test_roundtrip_backup(layout: PathLayout) -> None: +def test_roundtrip_backup(layout: PathLayout, tmp_path: Path) -> None: db_path = layout.db pool = SqlitePool(db_path) run_migrations(pool) @@ -63,11 +63,13 @@ class Row: agent_id = "agent01" name = "Test" - data, _name = create_system_backup( + archive = tmp_path / "roundtrip.tar.gz" + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=DatabaseConfig(), + dest=archive, ) pool.close() @@ -78,7 +80,7 @@ class Row: run_migrations(restore_pool) result = restore_system_backup( - data, + archive, paths=restore_layout, pool=restore_pool, db_config=DatabaseConfig(), @@ -98,7 +100,7 @@ class Row: ) assert json.loads(restore_layout.config.read_text(encoding="utf-8"))["port"] == 8088 - with tarfile.open(fileobj=BytesIO(data), mode="r:gz") as tf: + with tarfile.open(archive, mode="r:gz") as tf: manifest = json.loads(tf.extractfile("manifest.json").read().decode("utf-8")) assert manifest["manifest_version"] == MANIFEST_VERSION assert manifest["database_driver"] == "sqlite" @@ -117,21 +119,54 @@ class Row: name = "Test" config_json = json.dumps({"workspace_dir": str(custom)}) - data, _name = create_system_backup( + archive = tmp_path / "custom-ws-backup.tar.gz" + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=DatabaseConfig(), + dest=archive, ) pool.close() - with tarfile.open(fileobj=BytesIO(data), mode="r:gz") as tf: + with tarfile.open(archive, mode="r:gz") as tf: names = tf.getnames() assert "workspaces/agent01/SOUL.md" in names assert not (layout.agent_workspace("agent01") / "SOUL.md").exists() -def test_restore_replaces_stale_skill_package_files(layout: PathLayout) -> None: +def test_backup_skips_junk_directories(layout: PathLayout, tmp_path: Path) -> None: + pool = SqlitePool(layout.db) + run_migrations(pool) + ws = layout.ensure_agent_workspace("agent01") + (ws / "keep.txt").write_text("ok", encoding="utf-8") + (ws / "node_modules").mkdir() + (ws / "node_modules" / "pkg.js").write_text("skip", encoding="utf-8") + (ws / ".git").mkdir() + (ws / ".git" / "config").write_text("skip", encoding="utf-8") + + class Row: + agent_id = "agent01" + name = "Test" + + archive = tmp_path / "skip-junk.tar.gz" + create_system_backup( + paths=layout, + agent_rows=[Row()], + pool=pool, + db_config=DatabaseConfig(), + dest=archive, + ) + pool.close() + + with tarfile.open(archive, mode="r:gz") as tf: + names = set(tf.getnames()) + assert "workspaces/agent01/keep.txt" in names + assert "workspaces/agent01/node_modules/pkg.js" not in names + assert "workspaces/agent01/.git/config" not in names + + +def test_restore_replaces_stale_skill_package_files(layout: PathLayout, tmp_path: Path) -> None: pool = SqlitePool(layout.db) run_migrations(pool) with pool.connect() as conn: @@ -148,11 +183,13 @@ class Row: agent_id = "agent01" name = "Test" - data, _ = create_system_backup( + archive = tmp_path / "packages.tar.gz" + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=DatabaseConfig(), + dest=archive, ) pool.close() @@ -164,7 +201,7 @@ class Row: stale.write_text("---\nname: old\ndescription: stale\n---\n", encoding="utf-8") restore_system_backup( - data, + archive, paths=restore_layout, pool=restore_pool, db_config=DatabaseConfig(), @@ -178,11 +215,12 @@ class Row: def _make_migration_backup( layout: PathLayout, + dest: Path, *, username: str = "lc_user", jwt_secret: bytes | None = b"foreign-jwt-from-migration-backup!!!!", -) -> tuple[bytes, SqlitePool]: - """Build a fake LightClaw migration backup and return (data, source_pool).""" +) -> SqlitePool: + """Build a fake LightClaw migration backup at *dest*; return source_pool.""" pool = SqlitePool(layout.db) run_migrations(pool) with pool.connect() as conn: @@ -238,15 +276,16 @@ class Row: agent_id = "agent-lc" name = "LC Agent" - data, _ = create_system_backup( + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=DatabaseConfig(), + dest=dest, ) # Rewrite octop_version to signal a LightClaw migration backup. members: dict[str, bytes] = {} - with tarfile.open(fileobj=BytesIO(data), mode="r:gz") as tf: + with tarfile.open(dest, mode="r:gz") as tf: for m in tf.getmembers(): if m.isfile(): f = tf.extractfile(m) @@ -255,13 +294,12 @@ class Row: manifest_obj = json.loads(members["manifest.json"]) manifest_obj["octop_version"] = manifest_obj["octop_version"] + "-migrated-from-lightclaw" members["manifest.json"] = json.dumps(manifest_obj).encode() - buf = BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tf: + with tarfile.open(dest, mode="w:gz") as tf: for name, blob in members.items(): info = tarfile.TarInfo(name=name) info.size = len(blob) tf.addfile(info, BytesIO(blob)) - return buf.getvalue(), pool + return pool def test_migration_restore_preserves_current_users_and_imported_agents( @@ -283,7 +321,8 @@ def test_migration_restore_preserves_current_users_and_imported_agents( # --- source: simulate a LightClaw migration export with one agent --- src_layout = PathLayout(tmp_path / "src") src_layout.root.mkdir() - migration_data, src_pool = _make_migration_backup(src_layout, username="lc_user") + migration_archive = tmp_path / "migration.tar.gz" + src_pool = _make_migration_backup(src_layout, migration_archive, username="lc_user") src_pool.close() # --- target: a fresh Octop instance with its own admin user + JWT --- @@ -303,7 +342,7 @@ def test_migration_restore_preserves_current_users_and_imported_agents( ) result = restore_system_backup( - migration_data, + migration_archive, paths=tgt_layout, pool=tgt_pool, db_config=DatabaseConfig(), @@ -350,7 +389,8 @@ def test_migration_restore_remaps_ownership_to_admin_user_id_2(tmp_path: Path) - """Migration import assigns agents/channels/cron to the restoring admin (id=2).""" src_layout = PathLayout(tmp_path / "src") src_layout.root.mkdir() - migration_data, src_pool = _make_migration_backup(src_layout, username="lc_user") + migration_archive = tmp_path / "migration-remap.tar.gz" + src_pool = _make_migration_backup(src_layout, migration_archive, username="lc_user") src_pool.close() tgt_layout = PathLayout(tmp_path / "tgt") @@ -366,7 +406,7 @@ def test_migration_restore_remaps_ownership_to_admin_user_id_2(tmp_path: Path) - ) result = restore_system_backup( - migration_data, + migration_archive, paths=tgt_layout, pool=tgt_pool, db_config=DatabaseConfig(), @@ -425,7 +465,8 @@ def test_migration_restore_via_none_autodetect(tmp_path: Path) -> None: """preserve_users=None auto-detects the migration flag from octop_version.""" src_layout = PathLayout(tmp_path / "src") src_layout.root.mkdir() - migration_data, src_pool = _make_migration_backup(src_layout, username="lc_auto") + migration_archive = tmp_path / "migration-auto.tar.gz" + src_pool = _make_migration_backup(src_layout, migration_archive, username="lc_auto") src_pool.close() tgt_layout = PathLayout(tmp_path / "tgt") @@ -440,7 +481,7 @@ def test_migration_restore_via_none_autodetect(tmp_path: Path) -> None: # preserve_users=None — should auto-detect and preserve result = restore_system_backup( - migration_data, + migration_archive, paths=tgt_layout, pool=tgt_pool, db_config=DatabaseConfig(), @@ -458,7 +499,7 @@ def test_migration_restore_via_none_autodetect(tmp_path: Path) -> None: tgt_pool.close() -def test_refuse_cross_engine_restore(layout: PathLayout) -> None: +def test_refuse_cross_engine_restore(layout: PathLayout, tmp_path: Path) -> None: pool = SqlitePool(layout.db) run_migrations(pool) @@ -466,15 +507,17 @@ class Row: agent_id = "a1" name = "n" - data, _ = create_system_backup( + archive = tmp_path / "cross-engine.tar.gz" + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=DatabaseConfig(), + dest=archive, ) # Rewrite manifest to pretend it's a postgres dump. members: dict[str, bytes] = {} - with tarfile.open(fileobj=BytesIO(data), mode="r:gz") as tf: + with tarfile.open(archive, mode="r:gz") as tf: for m in tf.getmembers(): if m.isfile(): f = tf.extractfile(m) @@ -483,15 +526,14 @@ class Row: manifest = json.loads(members["manifest.json"]) manifest["database_driver"] = "postgresql" members["manifest.json"] = json.dumps(manifest).encode() - buf = BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tf: + with tarfile.open(archive, mode="w:gz") as tf: for name, blob in members.items(): info = tarfile.TarInfo(name=name) info.size = len(blob) tf.addfile(info, BytesIO(blob)) with pytest.raises(OctopError) as excinfo: restore_system_backup( - buf.getvalue(), + archive, paths=layout, pool=pool, db_config=DatabaseConfig(), diff --git a/tests/unit/i18n/test_stream.py b/tests/unit/i18n/test_stream.py index b2bc75c6..9e756757 100644 --- a/tests/unit/i18n/test_stream.py +++ b/tests/unit/i18n/test_stream.py @@ -33,6 +33,22 @@ def test_classify_rate_limit() -> None: ) +def test_classify_insufficient_balance() -> None: + msg = ( + "Error code: 402 - {'error': {'message': 'Insufficient Balance', " + "'type': 'unknown_error', 'param': None, 'code': 'invalid_request_error'}}" + ) + assert classify_stream_error_message(msg) == "octop:stream_errors.insufficient_balance" + assert ( + classify_stream_error_message("HTTP 402 POST https://api.example.com/v1/embeddings: quota") + == "octop:stream_errors.insufficient_balance" + ) + assert ( + classify_stream_error_message("You exceeded your current quota, please check billing") + == "octop:stream_errors.insufficient_balance" + ) + + def test_classify_auth() -> None: assert ( classify_stream_error_message("Error code: 401 - Incorrect API key provided") @@ -40,6 +56,17 @@ def test_classify_auth() -> None: ) +def test_classify_provider_unavailable_http_status() -> None: + assert ( + classify_stream_error_message("HTTP 503 POST https://api.example.com/v1/embeddings") + == "octop:stream_errors.provider_unavailable" + ) + assert ( + classify_stream_error_message("Error code: 502 - Bad Gateway") + == "octop:stream_errors.provider_unavailable" + ) + + def test_classify_context_length() -> None: assert ( classify_stream_error_message( @@ -85,6 +112,14 @@ def test_format_stream_error_zh_guidance() -> None: assert "LANGCHAIN" not in text +def test_format_insufficient_balance_zh() -> None: + msg = "Error code: 402 - {'error': {'message': 'Insufficient Balance'}}" + text = format_stream_error(msg, "zh") + assert "余额" in text or "额度" in text + assert "402" not in text + assert "Insufficient Balance" not in text + + def test_format_recursion_limit_zh_guides_to_config() -> None: msg = ( "Recursion limit of 2 reached without hitting a stop condition. " diff --git a/tests/unit/test_provider_fetch_models.py b/tests/unit/test_provider_fetch_models.py index 13c8896d..949d0fee 100644 --- a/tests/unit/test_provider_fetch_models.py +++ b/tests/unit/test_provider_fetch_models.py @@ -93,7 +93,28 @@ async def test_fetch_models_auth_error() -> None: ) assert result["ok"] is False - assert "401" in result["error"] or "invalid" in result["error"].lower() + assert "API key" in result["error"] + assert "401" not in result["error"] + + +@pytest.mark.asyncio +async def test_fetch_models_insufficient_balance_zh() -> None: + response = _mock_response(402, {"error": {"message": "Insufficient Balance"}}) + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client): + result = await fetch_openai_compatible_models( + base_url="https://api.example.com/v1", + api_key="sk-test", + locale="zh", + ) + + assert result["ok"] is False + assert "余额" in result["error"] or "额度" in result["error"] + assert "402" not in result["error"] @pytest.mark.asyncio @@ -131,3 +152,25 @@ async def test_fetch_models_connection_error() -> None: assert result["ok"] is False assert result["error"] + # ConnectError string often includes "connection" → timeout_network guidance + assert "401" not in result["error"] + + +@pytest.mark.asyncio +async def test_fetch_models_server_error_friendly() -> None: + response = _mock_response(503, {"error": {"message": "overloaded"}}) + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client): + result = await fetch_openai_compatible_models( + base_url="https://api.example.com/v1", + api_key="sk-test", + locale="en", + ) + + assert result["ok"] is False + assert "temporarily unavailable" in result["error"].lower() + assert "503" not in result["error"] diff --git a/tests/unit/test_provider_probe.py b/tests/unit/test_provider_probe.py index 7ca1e43c..19ec9989 100644 --- a/tests/unit/test_provider_probe.py +++ b/tests/unit/test_provider_probe.py @@ -116,8 +116,37 @@ async def test_embedding_probe_reports_http_error() -> None: result = await probe_provider_row(_embedding_row()) assert result["ok"] is False - assert "401" in result["error"] - assert "POST https://api.example.com/v1/embeddings" in result["error"] + assert "API key" in result["error"] + assert "401" not in result["error"] + + +@pytest.mark.asyncio +async def test_chat_probe_maps_insufficient_balance() -> None: + row = SimpleNamespace( + name="HAI", + kind="openai", + base_url="https://api.example.com/v1", + api_key="sk-test", + extra_json=None, + get_models=lambda: [{"id": "gpt-4o-mini", "name": "gpt-4o-mini"}], + ) + fake = AsyncMock() + fake.ainvoke = AsyncMock( + side_effect=RuntimeError( + "Error code: 402 - {'error': {'message': 'Insufficient Balance', " + "'type': 'unknown_error', 'param': None, 'code': 'invalid_request_error'}}" + ) + ) + with patch( + "octop.infra.agents.providers.probe.build_probe_chat_model", + return_value=fake, + ): + result = await probe_provider_row(row, model_id="gpt-4o-mini", locale="zh") + + assert result["ok"] is False + assert "余额" in result["error"] or "额度" in result["error"] + assert "402" not in result["error"] + assert "Insufficient Balance" not in result["error"] @pytest.mark.asyncio From 6a64e69d05337aedc76a7227b3ae7add0c12a579 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Thu, 27 Aug 2026 14:18:50 +0000 Subject: [PATCH 2/3] test: accept locale kwarg in provider probe endpoint mock Align integration fake_probe with probe_provider_row locale parameter added for localized error messages. Co-authored-by: Cursor --- tests/integration/test_provider_test_endpoint.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_provider_test_endpoint.py b/tests/integration/test_provider_test_endpoint.py index 425c0cc7..4cc9f7bb 100644 --- a/tests/integration/test_provider_test_endpoint.py +++ b/tests/integration/test_provider_test_endpoint.py @@ -48,10 +48,15 @@ async def test_provider_test_embedding_skips_chat_probe( c, _srv, auth, pid = env_with_provider_record async def fake_probe( - row: Any, *, model_id: str | None = None, embedding: bool | None = None + row: Any, + *, + model_id: str | None = None, + embedding: bool | None = None, + locale: str = "en", ) -> dict[str, Any]: assert embedding is True assert model_id == "text-embedding-3-small" + _ = locale return {"ok": True, "latency_ms": 12} with patch( From 395e476b81a9c5bc807c58cd4fa7ea331c3c97a1 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Fri, 28 Aug 2026 02:16:46 +0000 Subject: [PATCH 3/3] fix(cli): close init DB pool and isolate OCTOP_HOME in init tests Release SQLite before --force rmtree so repeated in-process init (CliRunner) cannot flake, and pin OCTOP_HOME plus clear leaked OCTOP_DATABASE_* in init CLI tests. Co-authored-by: Cursor --- src/octop/cli/commands/init.py | 55 +++++++++++++++++---------------- tests/unit/cli/test_init_cmd.py | 10 ++++-- 2 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/octop/cli/commands/init.py b/src/octop/cli/commands/init.py index ea59ef89..78fdaeda 100644 --- a/src/octop/cli/commands/init.py +++ b/src/octop/cli/commands/init.py @@ -80,37 +80,40 @@ def init( apply_env_file(env_file_path(paths.root)) config = load_config(paths.config) db = open_database(config, paths) - run_migrations(db) + try: + run_migrations(db) - username = admin_username - password = admin_password - display_name = admin_display_name + username = admin_username + password = admin_password + display_name = admin_display_name - if not non_interactive: - from octop.cli.support import prompts as _prompts + if not non_interactive: + from octop.cli.support import prompts as _prompts - if not username: - username = _prompts.text("Admin username:") - if not password: - password = _prompts.password("Admin password:") - if display_name is None: - display_name = _prompts.text("Display name (optional):", default="") or None + if not username: + username = _prompts.text("Admin username:") + if not password: + password = _prompts.password("Admin password:") + if display_name is None: + display_name = _prompts.text("Display name (optional):", default="") or None - if not username: - click.echo("error: admin username is required", err=True) - raise SystemExit(1) - try: - validate_password_policy(password or "") - except OctopError as exc: - click.echo(f"error: {exc.message}", err=True) - raise SystemExit(1) from None + if not username: + click.echo("error: admin username is required", err=True) + raise SystemExit(1) + try: + validate_password_policy(password or "") + except OctopError as exc: + click.echo(f"error: {exc.message}", err=True) + raise SystemExit(1) from None - UserRepo(db).create( - username=username, - password_hash=hash_password(password or ""), - role="admin", - display_name=display_name, - ) + UserRepo(db).create( + username=username, + password_hash=hash_password(password or ""), + role="admin", + display_name=display_name, + ) + finally: + db.close() click.echo(f"\u2705 Octop bootstrapped at {home}") click.echo(f" admin user: {username}") diff --git a/tests/unit/cli/test_init_cmd.py b/tests/unit/cli/test_init_cmd.py index c646288d..c9492eb1 100644 --- a/tests/unit/cli/test_init_cmd.py +++ b/tests/unit/cli/test_init_cmd.py @@ -14,8 +14,13 @@ @pytest.fixture def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + octop_home = tmp_path / ".octop" monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("OCTOP_HOME", str(octop_home)) monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + for key in list(os.environ): + if key.startswith("OCTOP_DATABASE_"): + monkeypatch.delenv(key, raising=False) return tmp_path @@ -90,9 +95,10 @@ def test_init_force_resets(fake_home: Path) -> None: "TestPass34", "--yes", ] - runner.invoke(cli, args_a) + r1 = runner.invoke(cli, args_a) + assert r1.exit_code == 0, r1.output or str(r1.exception) r2 = runner.invoke(cli, args_b) - assert r2.exit_code == 0, r2.output + assert r2.exit_code == 0, r2.output or str(r2.exception) from octop.infra.db.pool import SqlitePool from octop.infra.db.repos.users import UserRepo