From 7b1578413437b99d6d1783b7fca2388b714e58b3 Mon Sep 17 00:00:00 2001 From: July-X Date: Mon, 5 Oct 2026 14:13:03 +0800 Subject: [PATCH 01/29] feat(webui): unlock the personalization tab with a global AGENTS.md editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K area (记忆与个性化), first of three PRs. The 个性化 tab was `disabled: true`; it now opens a textarea bound to the profile-wide AGENTS.md — the same file Turn assembly already reads through `GlobalInstructions.readForPrompt`, so editing it here changes what the next turn actually sees. The v2 `instructions` capability exposes only `listSources`, which returns paths rather than content and has no write, so the harness holds its own `GlobalInstructions` over the same dataDir the turn path uses. One file, one writer, no second layout. The panel renders the server-reported `maxBytes` instead of re-declaring 32KiB: a client copy would drift the moment the runtime cap moves, and the drift would surface as the server rejecting a save the UI had called valid. A localStorage draft left by the pre-AGENTS.md prototype reaches the user, but only when the profile file is genuinely absent — seeding it over a live AGENTS.md would resurrect text the user has already replaced on disk. `resolveEditorSeed` carries that decision and is pinned directly. `settings-modal.test.tsx` asserted the tab was disabled; it is rewritten for the new behaviour rather than left to go red — `account` and `custom-instructions` are enabled, the other five stay gated. Scope boundary with the P area: P owns the settings shell (tab list, groups, search, the remaining `disabled` flags); this PR owns one tab body and its two transport calls. The only shared line is the `disabled` flag this PR removes. The 30 red tests reported by the webui vitest suite are baseline reds, not introduced here — confirmed by a differential run in a clean origin/webui worktree with a bidirectional failing-file diff showing no new red. Root cause is `TypeError: resolved?.getItem is not a function` at composer-history.ts:157, unrelated to this area. --- .../src/client/components/SettingsModal.tsx | 16 +- .../settings/PersonalizationSettings.tsx | 205 ++++++++++++++++++ packages/webui/src/client/contracts.ts | 8 + packages/webui/src/client/styles/shell.css | 12 + packages/webui/src/client/transport.ts | 2 + packages/webui/src/server/host.ts | 56 +++++ packages/webui/src/server/operation/names.ts | 2 + .../server/operation/operation-handlers.ts | 10 + .../webui/src/server/operation/operations.ts | 4 + .../src/server/operation/personalization.ts | 46 ++++ packages/webui/src/server/port.ts | 25 +++ .../global-instructions-operation.test.ts | 113 ++++++++++ .../unit/personalization-settings.test.tsx | 126 +++++++++++ .../webui/test/unit/settings-modal.test.tsx | 18 +- release/public-source.json | 4 + test/vitest-suites.json | 4 +- 16 files changed, 638 insertions(+), 13 deletions(-) create mode 100644 packages/webui/src/client/components/settings/PersonalizationSettings.tsx create mode 100644 packages/webui/src/server/operation/personalization.ts create mode 100644 packages/webui/test/unit/global-instructions-operation.test.ts create mode 100644 packages/webui/test/unit/personalization-settings.test.tsx diff --git a/packages/webui/src/client/components/SettingsModal.tsx b/packages/webui/src/client/components/SettingsModal.tsx index e3c2102b8..b9bae515e 100644 --- a/packages/webui/src/client/components/SettingsModal.tsx +++ b/packages/webui/src/client/components/SettingsModal.tsx @@ -21,11 +21,12 @@ import { } from "../projection/worktree-state.js"; import { ToggleSwitch as Switch } from "./ToggleSwitch.js"; import { UsageModelSettings } from "./settings/UsageModelSettings.js"; +import { PersonalizationSettings } from "./settings/PersonalizationSettings.js"; export type SettingsTabKey = "desktop" | "shortcuts" | "voice" | "custom-instructions" | "usage" | "connection" | "account" | "coding" | "worktree" | "archived"; export interface SettingsTabDefinition { readonly key: SettingsTabKey; readonly group: "preferences" | "management" | "coding" | "archived"; readonly label: string; readonly icon: string; readonly disabled?: boolean; } export const DESKTOP_SETTINGS_TABS: readonly SettingsTabDefinition[] = [ - { key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts", disabled: true }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions", disabled: true }, +{ key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts", disabled: true }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions" }, { key: "usage", group: "management", label: "用量与模型", icon: "chart" }, { key: "connection", group: "management", label: "连接", icon: "link", disabled: true }, { key: "account", group: "management", label: "账户", icon: "user" }, { key: "coding", group: "coding", label: "代码审查", icon: "coding" }, { key: "worktree", group: "coding", label: "工作树", icon: "worktree" }, { key: "archived", group: "archived", label: "已归档任务", icon: "archived" }, ]; export const SETTINGS_GROUPS = [{ key: "preferences", label: "偏好" }, { key: "management", label: "管理" }, { key: "coding", label: "编码" }, { key: "archived", label: "归档" }] as const; @@ -94,10 +95,12 @@ export type WebuiSettingsModalCapabilities = Pick< | "startCodexOAuthLogin" | "cancelCodexOAuthLogin" | "refreshModels" - | "getWorkspaceReviewSummary" +| "getWorkspaceReviewSummary" | "listWorkspaceReviewFileDiffs" | "searchWorkspaceReviewDiffs" | "loadSessions" + | "getGlobalInstructions" + | "setGlobalInstructions" >; interface SettingsModalProps { @@ -151,10 +154,12 @@ export function SettingsModal({ open, onClose, dataDir, version, sessionId, work startCodexOAuthLogin: transport?.startCodexOAuthLogin?.bind(transport), cancelCodexOAuthLogin: transport?.cancelCodexOAuthLogin?.bind(transport), refreshModels: transport?.refreshModels?.bind(transport), - getWorkspaceReviewSummary: transport?.getWorkspaceReviewSummary?.bind(transport), +getWorkspaceReviewSummary: transport?.getWorkspaceReviewSummary?.bind(transport), listWorkspaceReviewFileDiffs: transport?.listWorkspaceReviewFileDiffs?.bind(transport), searchWorkspaceReviewDiffs: transport?.searchWorkspaceReviewDiffs?.bind(transport), loadSessions: transport?.loadSessions?.bind(transport), + getGlobalInstructions: transport?.getGlobalInstructions?.bind(transport), + setGlobalInstructions: transport?.setGlobalInstructions?.bind(transport), }), [transport]); const { listModels, selectModel, getUsageQuota, getAccountStatus, @@ -165,9 +170,10 @@ export function SettingsModal({ open, onClose, dataDir, version, sessionId, work listProviderPresets, upsertMiniMaxApiKey, getCodexOAuthStatus, getMiniMaxModelSource, setMiniMaxModelSource, testUserModelCandidate, revealModelProviderApiKey, startCodexOAuthLogin, cancelCodexOAuthLogin, - refreshModels, +refreshModels, getWorkspaceReviewSummary, listWorkspaceReviewFileDiffs, searchWorkspaceReviewDiffs, loadSessions, + getGlobalInstructions, setGlobalInstructions, } = boundCapabilities; const usageCapabilities: WebuiSettingsModalCapabilities = useMemo(() => ({ getUsageQuota, getMiniMaxApiKeyStatus, listUserModelProviders, createUserModelProvider, @@ -184,7 +190,7 @@ export function SettingsModal({ open, onClose, dataDir, version, sessionId, work const visibleTabs = useMemo(() => filterSettingsTabs(query), [query]); if (!open) return null; const selected = models.find((model) => model.selected); const modelValue = selected ? `${selected.providerId}/${selected.modelId}/${selected.variant ?? ""}` : ""; const groups = SETTINGS_GROUPS.map((group) => ({ ...group, tabs: visibleTabs.filter((tab) => tab.group === group.key) })).filter((group) => group.tabs.length > 0); const label = DESKTOP_SETTINGS_TABS.find((tab) => tab.key === active)?.label; const changeModel = async (value: string) => { const model = models.find((candidate) => `${candidate.providerId}/${candidate.modelId}/${candidate.variant ?? ""}` === value); if (!model || !selectModel) return; await selectModel({ providerId: model.providerId, modelId: model.modelId, ...(model.variant ? { variant: model.variant } : {}), ...(sessionId ? { sessionId } : {}) }); }; const handleSignOut = async () => { if (!signOut) return; try { setSignOutError(undefined); await signOut(); onClose(); } catch (error) { setSignOutError(error instanceof Error ? error.message : String(error)); } }; const handleDeleteAllArchived = async () => { if (!deleteSession || !archived.length || !window.confirm("确定删除全部已归档任务吗?此操作无法撤销。")) return; const ids = archived.map((session) => session.sessionId); try { await Promise.all(ids.map((id) => deleteSession({ id }))); setArchived([]); } catch (error) { window.alert(`删除失败:${error instanceof Error ? error.message : String(error)}`); } }; - return
{ if (event.target === event.currentTarget) onClose(); }}>
event.stopPropagation()}>

{label}

{active === "archived" ? : null}
{active === "desktop" ? : null}{active === "usage" ? : null}{active === "account" ?
{signOutError ?

{signOutError}

: null}
: null}{active === "archived" ? { if (!deleteSession) return; await deleteSession({ id }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} onUnarchive={async (id) => { if (!archiveSession) return; await archiveSession({ id, archived: false }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} /> : null}{active === "coding" ? : null}{active === "worktree" ? : null}{active !== "desktop" && active !== "usage" && active !== "account" && active !== "archived" && active !== "coding" && active !== "worktree" ?
: null}{active === "desktop" && dataDir ?

{dataDir}

: null}
; + return
{ if (event.target === event.currentTarget) onClose(); }}>
event.stopPropagation()}>

{label}

{active === "archived" ? : null}
{active === "desktop" ? : null}{active === "custom-instructions" ? : null}{active === "usage" ? : null}{active === "account" ?
{signOutError ?

{signOutError}

: null}
: null}{active === "archived" ? { if (!deleteSession) return; await deleteSession({ id }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} onUnarchive={async (id) => { if (!archiveSession) return; await archiveSession({ id, archived: false }); setArchived((items) => items.filter((item) => item.sessionId !== id)); }} /> : null}{active === "coding" ? : null}{active === "worktree" ? : null}{active !== "desktop" && active !== "usage" && active !== "account" && active !== "archived" && active !== "coding" && active !== "worktree" && active !== "custom-instructions" ?
: null}{active === "desktop" && dataDir ?

{dataDir}

: null}
; } function GenericPage({ theme, setTheme, wrap, setWrap, newTab, setNewTab, contextWindow, setContextWindow, version }: { readonly theme: string; readonly setTheme: (value: string) => void; readonly wrap: boolean; readonly setWrap: (value: boolean) => void; readonly newTab: boolean; readonly setNewTab: (value: boolean) => void; readonly contextWindow: boolean; readonly setContextWindow: (value: boolean) => void; readonly version: string }): ReactElement { diff --git a/packages/webui/src/client/components/settings/PersonalizationSettings.tsx b/packages/webui/src/client/components/settings/PersonalizationSettings.tsx new file mode 100644 index 000000000..b3219a46c --- /dev/null +++ b/packages/webui/src/client/components/settings/PersonalizationSettings.tsx @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; +import type { WebuiGlobalInstructionsView } from "../../../server/port.js"; + +/** + * Personalization panel — the profile-wide `AGENTS.md` editor. + * + * Scope note for reviewers: this lands in the same settings modal that the P + * area (设置中心) also touches, but the two do not overlap in behaviour. P owns + * the shell — tab list, groups, search filtering. This component owns one tab + * body and the two transport calls behind it. The only shared line is the + * `disabled` flag on the `custom-instructions` tab definition in + * `SettingsModal.tsx`, which this PR removes. + */ + +const PLACEHOLDER = [ + "# Agents 全局设定", + "", + "## 技术背景", + "", + "| 维度 | 内容 |", + "| --- | --- |", + "| 语言 | TypeScript / Rust / Go |", + "| 偏好 | 清晰简洁,遵循设计模式 |", + "", + "留空保存会删除该文件。", +].join("\n"); + +export interface PersonalizationSettingsProps { + readonly getGlobalInstructions?: () => Promise; + readonly setGlobalInstructions?: (request: { + readonly content: string; + }) => Promise; +} + +/** + * Pre-migration storage key from the pre-AGENTS.md prototype. If it holds + * text, that text is user-authored and would be lost on upgrade, so it is + * seeded into the editor once. The flag is written only after a successful + * server read so a failed load retries the migration instead of burning it. + */ +const LEGACY_STORAGE_KEY = "webui-custom-instructions"; +const MIGRATION_FLAG_KEY = "webui-custom-instructions-migrated"; + +function readLegacyDraft(): string { + if (typeof localStorage === "undefined") return ""; + return localStorage.getItem(LEGACY_STORAGE_KEY) ?? ""; +} + +function markMigrationDone(): void { + if (typeof localStorage === "undefined") return; + localStorage.setItem(MIGRATION_FLAG_KEY, "1"); + localStorage.removeItem(LEGACY_STORAGE_KEY); +} + +/** + * What the editor should open with. + * + * The live profile file always wins: a non-empty `AGENTS.md` is what the next + * turn will read, and seeding a stale browser-local draft over it would + * resurrect text the user has already replaced on disk. The legacy draft is + * only adopted when there is no file to conflict with, and only once — after + * that the flag is set and the draft is dropped. + */ +export function resolveEditorSeed(input: { + readonly exists: boolean; + readonly content: string; + readonly legacyDraft: string; + readonly migrationDone: boolean; +}): string { + if (input.exists) return input.content; + if (input.migrationDone) return ""; + return input.legacyDraft.trim() ? input.legacyDraft : ""; +} + +export function PersonalizationSettings({ + getGlobalInstructions, + setGlobalInstructions, +}: PersonalizationSettingsProps): ReactElement { + const [draft, setDraft] = useState(""); + const [loaded, setLoaded] = useState(); + const [maxBytes, setMaxBytes] = useState(32 * 1024); + const [filePath, setFilePath] = useState(""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(); + + useEffect(() => { + if (!getGlobalInstructions) return; + let cancelled = false; + void getGlobalInstructions() + .then((value) => { + if (cancelled || !value) return; + setLoaded(value.content); + setMaxBytes(value.maxBytes); + setFilePath(value.path); + setDraft( + resolveEditorSeed({ + exists: value.exists, + content: value.content, + legacyDraft: readLegacyDraft(), + migrationDone: localStorage?.getItem(MIGRATION_FLAG_KEY) === "1", + }), + ); + // A live profile file retires the legacy draft immediately: the user + // already has content there, so keeping a browser-local copy around + // only creates a second source that can drift. + if (value.exists) markMigrationDone(); + }) + .catch((cause: unknown) => { + if (cancelled) return; + setError(cause instanceof Error ? cause.message : String(cause)); + }); + return () => { + cancelled = true; + }; + }, [getGlobalInstructions]); + + const byteLength = useMemo(() => new TextEncoder().encode(draft).length, [draft]); + const overLimit = byteLength > maxBytes; + const dirty = loaded !== undefined && draft !== loaded; + + const save = useCallback(async () => { + if (!setGlobalInstructions || saving || overLimit) return; + setSaving(true); + setError(undefined); + try { + const next = await setGlobalInstructions({ content: draft }); + if (next) { + setLoaded(next.content); + setMaxBytes(next.maxBytes); + setFilePath(next.path); + } + markMigrationDone(); + setSaved(true); + } catch (cause: unknown) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSaving(false); + } + }, [draft, overLimit, saving, setGlobalInstructions]); + + return ( +
+
+
+

自定义指令

+ +
+
+