From 059970e7e1fb0cd70d8d5bdef0d0330d7a799ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 04:20:13 -0400 Subject: [PATCH] Q171: provider policy fields say what they govern and show pending and saved state Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/i18n/zh.ts | 8 + .../pages/settings/ProviderPolicyTable.tsx | 196 ++++++++++++++++++ .../src/pages/settings/ProvidersPanel.tsx | 156 +------------- tests/e2e/desktop.spec.ts | 8 +- 4 files changed, 212 insertions(+), 156 deletions(-) create mode 100644 src/renderer/src/pages/settings/ProviderPolicyTable.tsx diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index cb911d94..25307ade 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -687,4 +687,12 @@ export const zh: Record = { "{model} (provider default)": "{model}(Provider 默认)", "Chosen when the session starts": "会话开始时确定", "The default model reaches {following} agents without their own model; {own} keep their own. Sessions already started keep the model they began with.": "默认模型作用于 {following} 个未单独设置模型的 Agent;{own} 个保留自己的模型。已开始的会话继续使用开始时的模型。", + "Scope: “Workspace” refuses every call that is not made for a workspace, including agent sessions on tasks without one.": "范围:选择“工作区”后,凡不是为某个工作区发起的调用都会被拒绝,包括没有工作区的任务上的 Agent 会话。", + "Data policy: zero data retention refuses background requests, so it also switches background off.": "数据策略:零数据保留会拒绝后台请求,因此会同时关闭后台执行。", + "Background: lets requests from the request console run in the provider's background mode.": "后台:允许请求控制台发出的请求以 Provider 的后台模式运行。", + "Monthly limit: counts every model call this company makes through the provider in the calendar month (UTC); at the limit, calls are refused and agent work waits until the month resets.": "月额度:统计本公司在当月(UTC 自然月)通过该 Provider 发起的每次模型调用;达到上限后调用会被拒绝,Agent 工作等待到下月重置。", + "Context window: the size new sessions fit prompts, skills, history and tool results into; sessions already running keep theirs.": "上下文窗口:新会话把提示、技能、历史和工具结果放入的容量;已在运行的会话保持原值。", + "Zero data retention switched background off.": "零数据保留已关闭后台执行。", + "Resets {date} (UTC)": "{date} 重置(UTC)", + "Unsaved": "未保存", }; diff --git a/src/renderer/src/pages/settings/ProviderPolicyTable.tsx b/src/renderer/src/pages/settings/ProviderPolicyTable.tsx new file mode 100644 index 00000000..57235e60 --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderPolicyTable.tsx @@ -0,0 +1,196 @@ +import { useState } from "react"; +import { Pill } from "../../shared/ui"; +import { t } from "../../i18n/i18n"; +import type { ProviderRateLimitStateRecord, ProviderRecord } from "../../../../shared/domain"; +import { PROVIDER_DATA_POLICY } from "../../../../shared/domain"; +import type { SnapshotActions } from "../../app/useSnapshot"; + +const POLICY_GRID = "minmax(150px, 1.2fr) minmax(118px, 0.8fr) minmax(140px, 1fr) 112px 124px 124px 132px"; +type WorkspaceScope = "organization" | "workspace"; + +export function ProviderPolicyTable({ + orgId, + providers, + providerRateLimits, + actions, +}: { + orgId: string; + providers: ProviderRecord[]; + providerRateLimits: ProviderRateLimitStateRecord[]; + actions: SnapshotActions; +}) { + return ( + <> +
{t("Runtime policy")}
+ {/* What each column governs, so a change is made knowing what it reaches. */} +
    +
  • {t("Scope: “Workspace” refuses every call that is not made for a workspace, including agent sessions on tasks without one.")}
  • +
  • {t("Data policy: zero data retention refuses background requests, so it also switches background off.")}
  • +
  • {t("Background: lets requests from the request console run in the provider's background mode.")}
  • +
  • {t("Monthly limit: counts every model call this company makes through the provider in the calendar month (UTC); at the limit, calls are refused and agent work waits until the month resets.")}
  • +
  • {t("Context window: the size new sessions fit prompts, skills, history and tool results into; sessions already running keep theirs.")}
  • +
+
+
+
{t("Provider")}
{t("Scope")}
{t("Data policy")}
{t("Background")}
{t("Monthly limit")}
{t("Context window (tokens)")}
{t("Usage")}
+
+ {providers.map((provider, index) => ( + + ))} +
+ + ); +} + +function ProviderPolicyRow({ + orgId, + provider, + rateLimit, + last, + actions, +}: { + orgId: string; + provider: ProviderRecord; + rateLimit: ProviderRateLimitStateRecord; + last: boolean; + actions: SnapshotActions; +}) { + const [dataPolicy, setDataPolicy] = useState(provider.dataPolicy); + const [backgroundEnabled, setBackgroundEnabled] = useState(provider.backgroundEnabled); + const [monthlyRequestLimit, setMonthlyRequestLimit] = useState(String(provider.monthlyRequestLimit)); + const [contextWindowTokens, setContextWindowTokens] = useState(String(provider.contextWindowTokens)); + const [workspaceScope, setWorkspaceScope] = useState(provider.workspaceScope === "workspace" ? "workspace" : "organization"); + const [linkedNote, setLinkedNote] = useState(null); + const [status, setStatus] = useState<{ kind: "saving" | "saved" } | { kind: "error"; message: string } | null>(null); + const savedScope = provider.workspaceScope === "workspace" ? "workspace" : "organization"; + const dirty = dataPolicy !== provider.dataPolicy + || backgroundEnabled !== provider.backgroundEnabled + || Number(monthlyRequestLimit) !== provider.monthlyRequestLimit + || Number(contextWindowTokens) !== provider.contextWindowTokens + || workspaceScope !== savedScope; + const edited = (set: (value: T) => void) => (value: T) => { set(value); setStatus(null); }; + + const savePolicy = () => { + setStatus({ kind: "saving" }); + actions.run(() => window.agentCompany.updateProviderPolicy({ + orgId, + providerId: provider.id, + dataPolicy, + backgroundEnabled, + monthlyRequestLimit: Number(monthlyRequestLimit), + workspaceScope, + contextWindowTokens: Number(contextWindowTokens), + })) + .then((snapshot) => { + const saved = snapshot?.providers.find((item) => item.id === provider.id); + if (!saved) return setStatus(null); + // The fields show what the backend stored (025 reads back as 25, 26.9 as 26). + setMonthlyRequestLimit(String(saved.monthlyRequestLimit)); + setContextWindowTokens(String(saved.contextWindowTokens)); + setStatus({ kind: "saved" }); + setLinkedNote(null); + }) + .catch((failure: unknown) => setStatus({ kind: "error", message: failure instanceof Error ? failure.message : String(failure) })); + }; + + const updateDataPolicy = (value: ProviderRecord["dataPolicy"]) => { + edited(setDataPolicy)(value); + if (value === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION && backgroundEnabled) { + setBackgroundEnabled(false); + setLinkedNote(t("Zero data retention switched background off.")); + } else setLinkedNote(null); + }; + + return ( +
+
+
{provider.label}
+ {linkedNote &&
{linkedNote}
} +
+ + + + edited(setMonthlyRequestLimit)(event.target.value)} + /> + edited(setContextWindowTokens)(event.target.value)} + /> +
+
{rateLimit.requestCount} / {rateLimit.monthlyRequestLimit}
+
{t("Resets {date} (UTC)", { date: rateLimit.windowEnd.slice(0, 10) })}
+
+ + {dirty && status?.kind !== "saving" && {t("Unsaved")}} + {!dirty && status?.kind === "saved" && {t("Saved")}} +
+ {status?.kind === "error" &&
{status.message}
} +
+
+ ); +} + +function requireProviderRateLimit( + provider: ProviderRecord, + rateLimits: ProviderRateLimitStateRecord[], +): ProviderRateLimitStateRecord { + const rateLimit = rateLimits.find((item) => item.providerId === provider.id); + if (!rateLimit) throw new Error(`Provider rate limit state missing for ${provider.id}.`); + return rateLimit; +} diff --git a/src/renderer/src/pages/settings/ProvidersPanel.tsx b/src/renderer/src/pages/settings/ProvidersPanel.tsx index de10e08f..995b81c1 100644 --- a/src/renderer/src/pages/settings/ProvidersPanel.tsx +++ b/src/renderer/src/pages/settings/ProvidersPanel.tsx @@ -11,10 +11,10 @@ import type { ProviderRateLimitStateRecord, ProviderRecord, } from "../../../../shared/domain"; -import { PROVIDER_DATA_POLICY } from "../../../../shared/domain"; import { ProviderLogo } from "../../shared/ProviderLogo"; import { ModelPicker, ProviderConnectFields, ProviderOAuthFields } from "./ProviderConnectionDialog"; import { composeSecret } from "./provider-secret"; +import { ProviderPolicyTable } from "./ProviderPolicyTable"; import type { SnapshotActions } from "../../app/useSnapshot"; interface ProvidersPanelProps { @@ -30,8 +30,6 @@ interface ProvidersPanelProps { } const PRICE_GRID = "minmax(150px, 1.4fr) 110px 110px 110px 92px"; -const POLICY_GRID = "minmax(150px, 1.2fr) minmax(118px, 0.8fr) minmax(140px, 1fr) 112px 124px 124px 92px"; -type WorkspaceScope = "organization" | "workspace"; export function ProvidersPanel({ orgId, companyName, employeeId, providers, authProfiles, catalog, agents, providerRateLimits, actions }: ProvidersPanelProps) { const [active, setActive] = useState(null); @@ -167,45 +165,6 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth ); } -function ProviderPolicyTable({ - orgId, - providers, - providerRateLimits, - actions, -}: { - orgId: string; - providers: ProviderRecord[]; - providerRateLimits: ProviderRateLimitStateRecord[]; - actions: SnapshotActions; -}) { - return ( - <> -
{t("Runtime policy")}
-
-
-
{t("Provider")}
{t("Scope")}
{t("Data policy")}
{t("Background")}
{t("Monthly limit")}
{t("Context window (tokens)")}
{t("Usage")}
-
- {providers.map((provider, index) => { - const rateLimit = requireProviderRateLimit(provider, providerRateLimits); - return ( - - ); - })} -
- - ); -} - /** Prices turn recorded token usage into spend, which agent and department budgets are checked against. */ function ProviderPricesTable({ orgId, providers, actions }: { orgId: string; providers: ProviderRecord[]; actions: SnapshotActions }) { return ( @@ -251,110 +210,6 @@ function ProviderPricesRow({ orgId, provider, actions }: { orgId: string; provid ); } -function ProviderPolicyRow({ - orgId, - provider, - rateLimit, - last, - actions, -}: { - orgId: string; - provider: ProviderRecord; - rateLimit: ProviderRateLimitStateRecord; - last: boolean; - actions: SnapshotActions; -}) { - const [dataPolicy, setDataPolicy] = useState(provider.dataPolicy); - const [backgroundEnabled, setBackgroundEnabled] = useState(provider.backgroundEnabled); - const [monthlyRequestLimit, setMonthlyRequestLimit] = useState(String(provider.monthlyRequestLimit)); - const [contextWindowTokens, setContextWindowTokens] = useState(String(provider.contextWindowTokens)); - const [workspaceScope, setWorkspaceScope] = useState(provider.workspaceScope === "workspace" ? "workspace" : "organization"); - - const savePolicy = () => { - void actions.run(() => window.agentCompany.updateProviderPolicy({ - orgId, - providerId: provider.id, - dataPolicy, - backgroundEnabled, - monthlyRequestLimit: Number(monthlyRequestLimit), - workspaceScope, - contextWindowTokens: Number(contextWindowTokens), - })); - }; - - const updateDataPolicy = (value: ProviderRecord["dataPolicy"]) => { - setDataPolicy(value); - if (value === PROVIDER_DATA_POLICY.ZERO_DATA_RETENTION) setBackgroundEnabled(false); - }; - - return ( -
-
-
{provider.label}
-
- - - - setMonthlyRequestLimit(event.target.value)} - /> - setContextWindowTokens(event.target.value)} - /> -
-
{rateLimit.requestCount} / {rateLimit.monthlyRequestLimit}
- -
-
- ); -} - function ProviderRow({ provider, authProfile, @@ -459,15 +314,6 @@ function readableAuth(authType: ProviderRecord["authType"]): string { return authType; } -function requireProviderRateLimit( - provider: ProviderRecord, - rateLimits: ProviderRateLimitStateRecord[], -): ProviderRateLimitStateRecord { - const rateLimit = rateLimits.find((item) => item.providerId === provider.id); - if (!rateLimit) throw new Error(`Provider rate limit state missing for ${provider.id}.`); - return rateLimit; -} - function requireCatalogEntry(catalog: ProviderCatalogEntry[], providerKey: string): ProviderCatalogEntry { const entry = catalog.find((candidate) => candidate.key === providerKey); if (!entry) throw new Error(`Provider ${providerKey} is not in the catalog.`); diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 0eb4ba25..ee109ac8 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -184,7 +184,10 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await expect(page.getByText(label, { exact: true })).toBeVisible(); } await page.getByLabel("OpenAI background execution").check(); + await expect(page.getByText("Unsaved", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Save OpenAI policy" }).click(); + await expect(page.getByText("Saved", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Save OpenAI policy" })).toBeDisabled(); // The operator request console lives under Advanced. await page.getByRole("button", { name: "Advanced" }).click(); @@ -197,8 +200,11 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByRole("button", { name: "Providers" }).click(); await page.getByLabel("OpenAI data policy").selectOption("zero_data_retention"); await expect(page.getByLabel("OpenAI background execution")).not.toBeChecked(); - await page.getByLabel("OpenAI monthly request limit").fill("25"); + await expect(page.getByText("Zero data retention switched background off.")).toBeVisible(); + await page.getByLabel("OpenAI monthly request limit").fill("25.9"); await page.getByRole("button", { name: "Save OpenAI policy" }).click(); + await expect(page.getByText("Saved", { exact: true })).toBeVisible(); + await expect(page.getByLabel("OpenAI monthly request limit")).toHaveValue("25"); await expect(page.getByText("0 / 25").first()).toBeVisible(); // Changing only the default model asks for no key and keeps the sign-in.