diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index ecaa63b998..d488435b6d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -929,6 +929,8 @@ const en = { "settings.general": "General", "settings.goalConnections": "Goal connections", "settings.language": "Language", + "settings.modelProvider": "Model provider", + "settings.globalCapabilities": "Global capabilities", "settings.languageDescription": "Choose the language used by the LoopX desktop workspace.", "settings.languageEnglishDescription": "Use English for navigation, settings, and workspace controls.", "settings.languageEnglish": "English", @@ -1965,6 +1967,8 @@ const zhCN: Record = { "settings.general": "通用", "settings.goalConnections": "Goal 连接", "settings.language": "语言", + "settings.modelProvider": "模型 Provider 配置", + "settings.globalCapabilities": "全局能力配置", "settings.languageDescription": "选择 LoopX Desktop 工作区使用的界面语言。", "settings.languageEnglishDescription": "使用英文显示导航、设置和工作区控件。", "settings.languageEnglish": "English", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx index 07ab2f258d..6d7d23d630 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/machine-configuration-settings.tsx @@ -21,7 +21,6 @@ import { withReportScheduleTimezone } from "./periodic-report-schedule-field"; import { localizeCapability, localizedCapabilityFieldCopy } from "./capability-localization"; import { canEditCapability, CapabilityCatalogNavigation, CapabilityConfigurationSummary, CapabilityDetailHeader, CapabilityEditorStatus, orderCapabilitiesForPresentation } from "./capability-workbench"; import { useWorkspaceI18n } from "./i18n"; -import { OperatorCredentialSettings } from "./operator-credential-settings"; type CapabilityDescriptor = CapabilityConfigurationCatalog["capabilities"][number]; type EditorMode = "guided" | "json"; @@ -294,16 +293,18 @@ export function MachineConfigurationSettings() {

{t("machine.liveDefaultDescription")}

- {inspection?.status === "invalid" ? ( -
- {t("machine.invalidStoredConfiguration")} -

{t("machine.invalidStoredConfigurationDescription")}

-
- ) : null} - - - -
+ {/* The catalog workbench is the only flexible block on this surface. It + lives in one body element so the surface keeps exactly two grid rows + however many notices the editor needs. */} +
+ {inspection?.status === "invalid" ? ( +
+ {t("machine.invalidStoredConfiguration")} +

{t("machine.invalidStoredConfigurationDescription")}

+
+ ) : null} + +
@@ -404,6 +405,7 @@ export function MachineConfigurationSettings() { t={t} /> : null}
+
); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 7f4b7af32d..69359ab67f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -693,6 +693,14 @@ /* Kanban layout for the goal Tasks tab: columns are task states. */ .personal-task-lane-filter { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 50px; padding: 8px 12px; border: 1px solid var(--pw-line); border-radius: 10px; background: #fff; } .personal-capability-settings { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 16px; width: 100%; max-width: 1050px; min-width: 0; min-height: 0; overflow: hidden; } +/* The surface is a two-row grid: the scope note, then the body that owns the + rest of the height. The body is a column so the workbench takes the leftover + space whether or not a notice precedes it; a taller sibling must never be + squeezed into a fixed row and spill over the block below it. */ +.personal-capability-body { display: flex; flex-direction: column; gap: 16px; min-width: 0; min-height: 0; overflow: hidden; } +.personal-capability-body > .personal-capability-layout { flex: 1 1 auto; } +.personal-provider-settings { width: 100%; max-width: 1050px; min-width: 0; } +.personal-provider-settings > .personal-operator-credential { margin-top: 0; } .personal-capability-scope-note { max-height: 120px; overflow: auto; overscroll-behavior: contain; padding: 12px 0; color: var(--pw-muted); } .personal-capability-scope-note summary { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 12px; } .personal-capability-scope-note p { padding: 10px 0 0 25px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx index 21f4843270..f68dc2152a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx @@ -1,15 +1,16 @@ import { useState } from "react"; -import { ArrowLeft, Check, Languages, Palette, ServerCog, Settings2, SlidersHorizontal } from "lucide-react"; +import { ArrowLeft, Check, KeyRound, Languages, Palette, ServerCog, Settings2, SlidersHorizontal } from "lucide-react"; import type { WorkspaceLocale } from "./i18n"; import { useWorkspaceI18n } from "./i18n"; import { LarkSettingsPage } from "./lark-settings-page"; import { GoalCapabilitySettings } from "./goal-capability-settings"; import { MachineConfigurationSettings } from "./machine-configuration-settings"; +import { OperatorCredentialSettings } from "./operator-credential-settings"; import type { PersonalWorkspaceCallbacks, WorkspaceGoal, WorkspaceGoalNotification } from "./personal-workspace-model"; import type { WorkspaceTheme } from "./workspace-theme"; -type WorkspaceSettingsTab = "machine" | "capabilities" | "lark" | "appearance" | "language"; +type WorkspaceSettingsTab = "provider" | "machine" | "capabilities" | "lark" | "appearance" | "language"; const tabIcons: Record = { appearance: Palette, @@ -17,6 +18,7 @@ const tabIcons: Record = { language: Languages, lark: Settings2, machine: ServerCog, + provider: KeyRound, }; export function WorkspaceSettingsPage({ @@ -46,7 +48,12 @@ export function WorkspaceSettingsPage({ const [tab, setTab] = useState(initialTab); const tabs: Array<{ key: WorkspaceSettingsTab; label: string }> = [ ...(initialGoalId ? [{ key: "capabilities" as const, label: t("capabilities.title") }] : []), - { key: "machine", label: t("machine.title") }, + // The model provider is one machine decision (which endpoint and key the + // operator credential holds); the capability catalog is another (which + // machine defaults every Goal inherits). They answer different questions + // and are edited on different surfaces, so they are separate categories. + { key: "provider", label: t("settings.modelProvider") }, + { key: "machine", label: t("settings.globalCapabilities") }, { key: "lark", label: "Lark" }, { key: "appearance", label: t("settings.appearance") }, { key: "language", label: t("settings.language") }, @@ -75,7 +82,10 @@ export function WorkspaceSettingsPage({ title: "Lark", }, machine: { - title: t("machine.title"), + title: t("settings.globalCapabilities"), + }, + provider: { + title: t("settings.modelProvider"), }, }; const heading = headings[tab]; @@ -123,6 +133,12 @@ export function WorkspaceSettingsPage({ /> ) : null} + {tab === "provider" ? ( +
+ +
+ ) : null} + {tab === "machine" ? : null} {tab === "capabilities" ? ( page.evaluate(() => { + for (const selector of [".personal-settings-body", ".personal-capability-settings", ".personal-capability-body"]) { + const container = document.querySelector(selector); + if (!container) continue; + const children = [...container.children].filter((node) => node.getBoundingClientRect().height > 4); + const blocks = children.map((node) => node.getBoundingClientRect()); + for (let index = 1; index < blocks.length; index += 1) { + if (blocks[index].top < blocks[index - 1].bottom - 1) { + return `${selector} blocks overlap`; + } + } + for (const child of children) { + const overflow = getComputedStyle(child).overflowY; + if (overflow !== "visible") continue; + if (child.scrollHeight > child.clientHeight + 2) { + return `${selector} clips ${child.className.toString().split(/\s+/)[0]} (${child.clientHeight} < ${child.scrollHeight})`; + } + } + } + return ""; + }); + await page.getByRole("button", { name: /模型 Provider 配置/ }).click(); + await page.getByRole("heading", { level: 1, name: "模型 Provider 配置", exact: true }).waitFor({ state: "visible" }); + await page.locator(".personal-operator-credential").waitFor({ state: "visible" }); + if (await page.locator(".personal-operator-credential").count() !== 1) { + throw new Error("The model provider category did not host exactly one credential panel"); + } + await page.locator(".personal-operator-credential-readback").waitFor({ state: "visible" }); + for (const label of [/^API key$/u, /^指纹$/u, /^Endpoint base URL$/u]) { + await page.getByText(label).first().waitFor({ state: "visible" }); + } + // The readback is redacted by construction: the key shows its fingerprint + // and the fixture's own value never appears. + const providerReadback = await page.locator(".personal-operator-credential-readback").innerText(); + if (!providerReadback.includes("已配置") || !providerReadback.includes("3efe046b2b3d")) { + throw new Error(`Model provider readback lost its redacted projection: ${providerReadback}`); + } + if (providerReadback.includes("api-key-fixture")) { + throw new Error("Model provider readback exposed a credential value"); + } + if (api.operatorCredentialWrites.length) { + throw new Error("Opening the model provider category wrote a credential"); + } + const providerOverlap = await stackedBlocks(); + if (providerOverlap) throw new Error(`Model provider category ${providerOverlap}`); + await page.screenshot({ path: resolve(outputDir, "model-provider-settings-zh-cn.png"), fullPage: false, animations: "disabled" }); + + await page.getByRole("button", { name: /全局能力配置/ }).click(); + await page.getByRole("heading", { level: 1, name: "全局能力配置", exact: true }).waitFor({ state: "visible" }); + // The catalog workbench mounts after its inspection resolves, so the + // category's contents are asserted only once the workbench itself exists. + await page.locator(".personal-capability-layout").waitFor({ state: "visible" }); + if (await page.locator(".personal-operator-credential").count()) { + throw new Error("The global capability category still hosted the operator credential panel"); + } + const capabilityOverlap = await stackedBlocks(); + if (capabilityOverlap) throw new Error(`Global capability category ${capabilityOverlap}`); const machineCatalog = page.getByRole("navigation", { name: "机器能力目录" }); const firstMachineCapability = machineCatalog.getByRole("button").filter({ hasText: "机器" }).first(); await firstMachineCapability.waitFor({ state: "visible" }); @@ -1098,7 +1161,7 @@ export const typedActionsScenario = { await page.getByRole("button", { name: /语言/ }).click(); await page.getByRole("radio", { name: /English/ }).click(); - await page.getByRole("button", { name: /Machine configuration/ }).click(); + await page.getByRole("button", { name: /Global capabilities/ }).click(); await page.getByRole("heading", { level: 2, name: "Periodic reports", exact: true }).waitFor({ state: "visible" }); const rawValues = page.locator(".personal-capability-raw-values"); if (await rawValues.getAttribute("open") !== null) throw new Error("Raw JSON must be collapsed by default"); @@ -1124,7 +1187,7 @@ export const typedActionsScenario = { await page.screenshot({ path: resolve(outputDir, "goal-subagent-capability-en.png"), fullPage: false, animations: "disabled" }); await page.getByRole("button", { name: /Language/ }).click(); await page.getByRole("radio", { name: /Simplified Chinese/ }).click(); - await page.getByRole("button", { name: /机器配置/ }).click(); + await page.getByRole("button", { name: /全局能力配置/ }).click(); await page.locator(".personal-settings-body").evaluate((element) => element.scrollTo({ top: 0 })); await page.screenshot({ path: resolve(outputDir, "machine-capability-zh-cn.png"), fullPage: false, animations: "disabled" }); // The steward's own executor is a machine setting like any other: the @@ -1169,7 +1232,7 @@ export const typedActionsScenario = { api.machineInspectionStatus = "invalid"; api.invalidMachineNamespaces = ["manager_runtime"]; - await page.getByRole("button", { name: /机器配置/ }).click(); + await page.getByRole("button", { name: /全局能力配置/ }).click(); const invalidRepair = page.getByTestId("machine-invalid-repair"); await invalidRepair.waitFor({ state: "visible" }); await page.getByRole("heading", { level: 2, name: "管家 Runtime", exact: true }).waitFor({ state: "visible" }); @@ -1192,7 +1255,7 @@ export const typedActionsScenario = { await page.getByRole("button", { name: /Lark/ }).click(); api.machineInspectionStatus = "invalid"; api.invalidMachineNamespaces = ["periodic_report"]; - await page.getByRole("button", { name: /机器配置/ }).click(); + await page.getByRole("button", { name: /全局能力配置/ }).click(); await invalidRepair.waitFor({ state: "visible" }); await page.getByRole("heading", { level: 2, name: "周期报告", exact: true }).waitFor({ state: "visible" }); await page.getByRole("button", { name: "预览变更", exact: true }).click();