diff --git a/packages/config/test/config-update-preservation.test.ts b/packages/config/test/config-update-preservation.test.ts index 175c9da38..a8ff37ee4 100644 --- a/packages/config/test/config-update-preservation.test.ts +++ b/packages/config/test/config-update-preservation.test.ts @@ -184,4 +184,51 @@ describe("valid local config updates", () => { permissionMode: "default", }); }); + + /** + * The memory switches reach the config through `put`, not through the + * `preparedCommitPayload` argument. That argument is a shallow + * `Object.assign`, so `{ memory: { enabled, proactive } }` through it would + * replace the whole `memory` subtree and silently delete `dailyDigest` from + * the user's config — a setting they enabled, with no error and no trace. + * + * This is the only place that failure would be visible: the WebUI reports + * the two booleans it wrote, and it has no reason to look at a sibling key. + */ + it("keeps sibling keys of a subtree when writing through put", async () => { + const source = { + memory: { + enabled: true, + dailyDigest: { enabled: true, customRetention: 7 }, + }, + }; + fs.writeFileSync(configPath, yaml.dump(source)); + + await updateLocalConfigFile({ + field: "memory", + put: { proactive: true }, + }); + + expect(yaml.load(fs.readFileSync(configPath, "utf8"))).toEqual({ + memory: { + enabled: true, + dailyDigest: { enabled: true, customRetention: 7 }, + proactive: true, + }, + }); + }); + + it("refuses a field the whitelist does not carry, without touching the file", async () => { + // The WebUI's operation frame is two booleans, but the writer is shared by + // every caller — the whitelist is what stops a general payload from + // reaching the API-key roots through this same code path. + const source = `minimax_api:\n apiKey: ${secret}\n`; + fs.writeFileSync(configPath, source); + + await expect( + updateLocalConfigFile({ field: "minimax_api", put: { apiKey: "other" } }), + ).rejects.toThrow(/cannot update config field/u); + + expect(fs.readFileSync(configPath, "utf8")).toBe(source); + }); }); diff --git a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts index 5c1a5cea6..f249995a5 100644 --- a/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts +++ b/packages/local-runtime-v2/src/application/session/process-local-application-contract.ts @@ -260,6 +260,25 @@ export interface LocalRuntimeApplication { setPermissionMode(input: { mode: "default" | "acceptEdits" | "bypassPermissions" | "auto" | "off"; }): Promise; + /** + * The two long-term-memory switches, read and written as their own + * narrow surface rather than as a general config patch. + * + * Optional because a host that predates this surface still has to + * type-check, and every consumer must fail closed with one clear + * message instead of crashing on an absent method — the same rule the + * permission-mode pair already follows. Callers must not treat a missing + * method as "off": absence means "this host cannot answer", which is a + * different statement from either boolean value. + */ + getMemorySettings?(): Promise<{ + enabled: boolean; + proactive: boolean; + }>; + setMemorySettings?(input: { + enabled?: boolean; + proactive?: boolean; + }): Promise<{ enabled: boolean; proactive: boolean }>; }; readonly models?: { list(input?: { sessionId?: string }): Promise; diff --git a/packages/local-runtime-v2/src/compat/v1/runtime.ts b/packages/local-runtime-v2/src/compat/v1/runtime.ts index bb3ba8bd1..59c417bc5 100644 --- a/packages/local-runtime-v2/src/compat/v1/runtime.ts +++ b/packages/local-runtime-v2/src/compat/v1/runtime.ts @@ -741,6 +741,39 @@ function createPeripheralApplication( readLocalPermissionMode(host.apiHost.configGetter().permissionMode), setPermissionMode: ({ mode }) => host.apiHost.setProcessLocalPermissionMode(mode), + /** + * Reads the two long-term-memory switches with the same defaults the + * config declares (`packages/config/src/config.ts`: `enabled` true, + * `proactive` false). A missing `memory` tree is not an error — it is the + * documented default state, and reporting it as "off" would be a lie the + * settings UI would then write back. + */ + getMemorySettings: async () => { + const memory = host.apiHost.configGetter().memory; + return { + enabled: memory?.enabled !== false, + proactive: memory?.proactive === true, + }; + }, + /** + * `put` rather than the `preparedCommitPayload` argument: that path does + * a shallow `Object.assign`, so passing `{ memory: { enabled, proactive } }` + * would silently drop `memory.dailyDigest` from the user's config. `put` + * deep-merges into the existing subtree and touches one key per call. + */ + setMemorySettings: async ({ enabled, proactive }) => { + const put: Record = {}; + if (enabled !== undefined) put.enabled = enabled; + if (proactive !== undefined) put.proactive = proactive; + if (Object.keys(put).length > 0) { + await updateLocalConfigFile({ field: "memory", put }); + } + const memory = host.apiHost.configGetter().memory; + return { + enabled: memory?.enabled !== false, + proactive: memory?.proactive === true, + }; + }, }, backgroundTasks: { list: async ({ diff --git a/packages/local-runtime-v2/src/local/cli-service.ts b/packages/local-runtime-v2/src/local/cli-service.ts index 2ed8be088..9815b2f3c 100644 --- a/packages/local-runtime-v2/src/local/cli-service.ts +++ b/packages/local-runtime-v2/src/local/cli-service.ts @@ -827,6 +827,33 @@ export class CliService { ).setPermissionMode(input); } + /** + * The memory switches are optional on the capability, so a host that + * predates the surface must produce one clear failure here rather than a + * `TypeError` from calling `undefined`. Absence means "this host cannot + * answer" — it is deliberately not reported as a boolean value, because + * `false` would read as "the user turned memory off". + */ + getMemorySettings(): Promise<{ enabled: boolean; proactive: boolean }> { + const configuration = this.requireCapability("configuration", "Configuration"); + if (!configuration.getMemorySettings) + throw new Error("Runtime host does not expose memory settings"); + return configuration.getMemorySettings(); + } + + setMemorySettings( + input: Parameters< + NonNullable< + NonNullable["setMemorySettings"] + > + >[0], + ): Promise<{ enabled: boolean; proactive: boolean }> { + const configuration = this.requireCapability("configuration", "Configuration"); + if (!configuration.setMemorySettings) + throw new Error("Runtime host does not expose memory settings"); + return configuration.setMemorySettings(input); + } + listModels( input?: Parameters< NonNullable["list"] diff --git a/packages/webui/src/client/components/SessionComposer.tsx b/packages/webui/src/client/components/SessionComposer.tsx index a8167ef3e..0330900cf 100644 --- a/packages/webui/src/client/components/SessionComposer.tsx +++ b/packages/webui/src/client/components/SessionComposer.tsx @@ -156,6 +156,7 @@ import { removeWebuiSlashToken, replaceWebuiSlashToken, webuiAttachmentLimitError, + webuiTextAttachmentDataUrl, type WebuiMentionRange, } from "../projection/composer-interactions.js"; import { @@ -616,6 +617,7 @@ export function WebuiComposer({ workspaceDir, onSelectSession, onOpenPluginManagement, + seedAttachment, }: { readonly sessionId?: string; readonly sessionStatus?: unknown; @@ -661,6 +663,23 @@ export function WebuiComposer({ /** The draft lives on the shell so it survives silent first-session creation. */ readonly draft: string; readonly onDraftChange: (next: string) => void; + /** + * A file dropped into the composer from outside it — 记忆摘要's 「在会话中创建」 + * hands the memory file over this way, because the composer is the only + * surface that can render an attachment chip and the only one that can put it + * on the wire. + * + * One-shot by `token`: the shell keeps the seed around across the view switch + * that opens the composer, so an effect keyed on anything else would re-attach + * the same 74KB file on every re-render of this 3000-line component. + */ + readonly seedAttachment?: { + readonly token: string; + readonly fileName: string; + readonly mimeType: string; + readonly sizeBytes: number; + readonly content: string; + }; /** * Submitted-input history for ↑ recall (roadmap Module B: 输入历史/草稿). * Keyed by session on the shell; the composer only walks it with the pure @@ -1520,6 +1539,31 @@ export function WebuiComposer({ setAttachmentBusy(false); } }; + const seededTokenRef = useRef(); + useEffect(() => { + const seed = seedAttachment; + if (!seed || seededTokenRef.current === seed.token) return; + // Mark it consumed before anything that can bail. A seed the composer + // refuses has to stay refused; retrying on every attachment change would + // pin the error in place. + seededTokenRef.current = seed.token; + const limitError = webuiAttachmentLimitError(attachments, [{ sizeBytes: seed.sizeBytes }]); + if (limitError) { + setInteractionError(limitError); + return; + } + setAttachments((current) => [ + ...current, + { + id: `seed-${seed.token}`, + fileName: seed.fileName, + mimeType: seed.mimeType, + sizeBytes: seed.sizeBytes, + dataUrl: webuiTextAttachmentDataUrl(seed.mimeType, seed.content), + kind: "file", + }, + ]); + }, [attachments, seedAttachment]); const attachmentWire = attachments.map((attachment) => ({ meta: { attachmentType: attachment.kind, diff --git a/packages/webui/src/client/components/SettingsModal.tsx b/packages/webui/src/client/components/SettingsModal.tsx index e3c2102b8..b82e7fe59 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, type MemoryHandoff } 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,18 @@ export type WebuiSettingsModalCapabilities = Pick< | "startCodexOAuthLogin" | "cancelCodexOAuthLogin" | "refreshModels" - | "getWorkspaceReviewSummary" +| "getWorkspaceReviewSummary" | "listWorkspaceReviewFileDiffs" | "searchWorkspaceReviewDiffs" | "loadSessions" + | "getGlobalInstructions" + | "setGlobalInstructions" + | "getAgentMemory" + | "setAgentMemory" + | "getUserProfile" + | "setUserProfile" + | "getMemorySettings" + | "setMemorySettings" >; interface SettingsModalProps { @@ -117,9 +126,13 @@ interface SettingsModalProps { /** Capability source for the modal. Typed as the narrow contract so the * modal cannot accidentally start reading members it does not consume. */ readonly transport?: WebuiSettingsModalCapabilities; + /** 「在会话中创建」 on the memory manager. Not a transport capability: the + * memory is already client-side, and what is missing is a composer to put it + * in, which only the shell owns. Sibling of `getSigninPanel` upstream. */ + readonly onCreateMemorySession?: (input: MemoryHandoff) => void; } -export function SettingsModal({ open, onClose, dataDir, version, sessionId, workspaceDir, onOpenFileLine, transport }: SettingsModalProps): ReactElement | null { +export function SettingsModal({ open, onClose, dataDir, version, sessionId, workspaceDir, onOpenFileLine, transport, onCreateMemorySession }: SettingsModalProps): ReactElement | null { // The transport is optional. Each capability is optional too, so we bind // only when both are present; otherwise we surface `undefined` and let the // call sites do their existing null checks. @@ -151,10 +164,18 @@ 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), + getAgentMemory: transport?.getAgentMemory?.bind(transport), + setAgentMemory: transport?.setAgentMemory?.bind(transport), + getUserProfile: transport?.getUserProfile?.bind(transport), + setUserProfile: transport?.setUserProfile?.bind(transport), + getMemorySettings: transport?.getMemorySettings?.bind(transport), + setMemorySettings: transport?.setMemorySettings?.bind(transport), }), [transport]); const { listModels, selectModel, getUsageQuota, getAccountStatus, @@ -165,9 +186,12 @@ 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, + getAgentMemory, setAgentMemory, getUserProfile, setUserProfile, + getMemorySettings, setMemorySettings, } = boundCapabilities; const usageCapabilities: WebuiSettingsModalCapabilities = useMemo(() => ({ getUsageQuota, getMiniMaxApiKeyStatus, listUserModelProviders, createUserModelProvider, @@ -184,7 +208,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/UserMenu.tsx b/packages/webui/src/client/components/UserMenu.tsx index 54b4807b6..08580afbc 100644 --- a/packages/webui/src/client/components/UserMenu.tsx +++ b/packages/webui/src/client/components/UserMenu.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useMemo, useRef, useState, type ReactElement } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react"; import { createPortal } from "react-dom"; import { WebuiIconBell, WebuiIconBrand, WebuiIconCommandUsage } from "../icons.js"; import { @@ -19,6 +19,7 @@ import type { import type { WebuiTransport } from "../contracts.js"; import { SettingsModal, type WebuiSettingsModalCapabilities } from "./SettingsModal.js"; import { AccountLoginDialog } from "./AccountLoginDialog.js"; +import type { MemoryHandoff } from "./settings/PersonalizationSettings.js"; import { evaluateOutsideClose } from "../projection/outside-close.js"; type AccountStatus = Record; @@ -50,6 +51,9 @@ interface UserMenuProps { readonly transport?: WebuiUserMenuCapabilities; readonly getSigninPanel?: () => Promise; readonly claimSignin?: () => Promise; + /** Forwarded to the settings modal, which owns 记忆摘要's 「在会话中创建」. + * The menu neither interprets nor stores it. */ + readonly onCreateMemorySession?: (input: MemoryHandoff) => void; } interface UsageState { @@ -723,6 +727,7 @@ export function UserMenu({ transport, getSigninPanel, claimSignin, + onCreateMemorySession, }: UserMenuProps): ReactElement { // Bound once per transport, not per render: the login dialog's effects // key on these callbacks, and a fresh binding every render would restart @@ -736,6 +741,14 @@ export function UserMenu({ signOut: transport?.signOut?.bind(transport), }), [transport]); const anchorRef = useRef(null); + // The settings modal is the menu's own state, so the menu is also the only + // thing that can dismiss it. Handing the memory to a conversation moves the + // user off this surface entirely; leaving the modal parked over the home + // composer would hide the very composer the hand-off just filled. + const handleCreateMemorySession = useCallback((input: MemoryHandoff) => { + setSettingsOpen(false); + onCreateMemorySession?.(input); + }, [onCreateMemorySession]); const [open, setOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false); @@ -915,6 +928,6 @@ export function UserMenu({ cancelAccountLogin={cancelAccountLogin} signOut={signOut} /> - {typeof document !== "undefined" ? createPortal( setSettingsOpen(false)} dataDir={dataDir} version={version} sessionId={sessionId} workspaceDir={workspaceDir} onOpenFileLine={onOpenFileLine} transport={transport} />, document.body) : setSettingsOpen(false)} dataDir={dataDir} version={version} sessionId={sessionId} workspaceDir={workspaceDir} onOpenFileLine={onOpenFileLine} transport={transport} />} + {typeof document !== "undefined" ? createPortal( setSettingsOpen(false)} dataDir={dataDir} version={version} sessionId={sessionId} workspaceDir={workspaceDir} onOpenFileLine={onOpenFileLine} transport={transport} {...(onCreateMemorySession ? { onCreateMemorySession: handleCreateMemorySession } : {})} />, document.body) : setSettingsOpen(false)} dataDir={dataDir} version={version} sessionId={sessionId} workspaceDir={workspaceDir} onOpenFileLine={onOpenFileLine} transport={transport} {...(onCreateMemorySession ? { onCreateMemorySession: handleCreateMemorySession } : {})} />} ; } diff --git a/packages/webui/src/client/components/WebuiClientFoundationApp.tsx b/packages/webui/src/client/components/WebuiClientFoundationApp.tsx index d0f2aef5c..ebe02e4fa 100644 --- a/packages/webui/src/client/components/WebuiClientFoundationApp.tsx +++ b/packages/webui/src/client/components/WebuiClientFoundationApp.tsx @@ -40,6 +40,7 @@ import { } from "./LeftRail.js"; import { Transcript } from "./Transcript.js"; import { UserMenu } from "./UserMenu.js"; +import { MEMORY_HANDOFF_PROMPT, type MemoryHandoff } from "./settings/PersonalizationSettings.js"; import { WebuiWorkspacePanel, WebuiProgressOverviewPanel, @@ -832,6 +833,29 @@ export function WebuiClientFoundationApp( ); } }; + + /** + * 「在会话中创建」 on 记忆摘要: land on the home composer with the memory + * file attached and one line already typed. + * + * The line is written from an effect rather than here, and the wait is not + * ceremony. Drafts are stored per session, and `startNewTask` clears the + * *old* session's slot before `selectedSessionId` changes — writing the + * prompt inline would put it in whichever slot the shell still considered + * current, so starting from a session would type it somewhere the user never + * sees. Once no session is selected the key is the home slot, which is the + * slot the desktop's screenshot is showing. + */ + const [memoryHandoff, setMemoryHandoff] = useState(); + const createMemorySession = useCallback((input: MemoryHandoff) => { + setMemoryHandoff(input); + startNewTask(); + }, []); + useEffect(() => { + if (!memoryHandoff || selectedSessionId) return; + setDraft(MEMORY_HANDOFF_PROMPT); + }, [memoryHandoff, selectedSessionId, setDraft]); + const handleWorkspaceSubagentClick = useCallback( (subagent: WebuiWorkspaceSubagent) => { setSelectedSessionId(subagent.sessionId); @@ -1283,6 +1307,7 @@ export function WebuiClientFoundationApp( transport={transport} getSigninPanel={transport?.getSigninPanel} claimSignin={transport?.claimSignin} + onCreateMemorySession={createMemorySession} /> : null} @@ -1444,6 +1469,7 @@ export function WebuiClientFoundationApp( getAccountStatus={transport?.getAccountStatus} draft={draft} onDraftChange={setDraft} + seedAttachment={memoryHandoff} inputHistory={composerStore.history[composerKey] ?? []} onInputSubmitted={recordComposerInput} teamModeOff={composerTeamModeOff} 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..ba0b2cc88 --- /dev/null +++ b/packages/webui/src/client/components/settings/PersonalizationSettings.tsx @@ -0,0 +1,1312 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { ToggleSwitch } from "../ToggleSwitch.js"; +import type { + WebuiAgentMemoryView, + WebuiGlobalInstructionsView, + WebuiMemorySettingsView, + WebuiUserProfileFields, + WebuiUserProfileView, +} from "../../contracts.js"; + +/** + * Personalization panel — the three blocks the desktop surface ships: + * 自定义指令, 关于你, 记忆. + * + * Shape note for reviewers: the desktop presents 记忆 as a card of three rows + * (two switches plus a 管理 action that opens the memory manager), not as a + * single textarea. The earlier revision rendered a standalone 长期记忆 editor; + * it was the same file and the same operations, just a different surface, and + * the roadmap's "管理 UI 未见" is only closed by the row that points at a + * manager. + * + * Scope note: 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 + * transport calls behind it. The only shared line is the `disabled` flag on the + * `custom-instructions` tab definition in `SettingsModal.tsx`, which the earlier + * PR removed. + */ + +const INSTRUCTIONS_PLACEHOLDER = [ + "# Agents 全局设定", + "", + "## 技术背景", + "", + "| 维度 | 内容 |", + "| --- | --- |", + "| 语言 | TypeScript / Rust / Go |", + "| 偏好 | 清晰简洁,遵循设计模式 |", + "", + "留空保存会删除该文件。", +].join("\n"); + +const PROFILE_PLACEHOLDER = "告诉 Agent 你的背景和长期偏好……"; + +/** + * The editable half of a profile view. + * + * Narrowing here rather than passing the whole view around keeps the draft a + * plain three-field record, so an update cannot accidentally carry the server's + * `path` or `sizeBytes` back into a `setUserProfile` request that only accepts + * the three. + */ +function pickProfileFields(view: WebuiUserProfileView): WebuiUserProfileFields { + return { + nickname: view.nickname, + occupation: view.occupation, + moreAbout: view.moreAbout, + }; +} + +/** Field-by-field, so the dirty check is not a string-concatenation trap. */ +function fieldsEqual( + left: WebuiUserProfileFields, + right: WebuiUserProfileFields, +): boolean { + return ( + left.nickname === right.nickname && + left.occupation === right.occupation && + left.moreAbout === right.moreAbout + ); +} + +/** + * The save button's three states, as one predicate. + * + * Exported so the transition can be tested directly: `renderToStaticMarkup` + * paints one frame and never resolves the read, so every assertion made through + * it sees the pre-load state — where the button is disabled for an unrelated + * reason and would stay green with this check deleted. + */ +export function isProfileSaveable( + draft: WebuiUserProfileFields | undefined, + baseline: WebuiUserProfileFields | undefined, + options: { readonly malformed: boolean; readonly overLimit: boolean; readonly canWrite: boolean }, +): boolean { + if (draft === undefined || !options.canWrite) return false; + if (options.malformed || options.overLimit) return false; + // No baseline yet means the read has not landed, which is the same "there is + // nothing to save" state as an untouched form. + if (baseline === undefined) return false; + return !fieldsEqual(draft, baseline); +} + +/** + * The three section bubbles, copied from the desktop word for word. + * + * Exported because the test asserts the exact strings: a tooltip is copy the + * user reads, and a paraphrase that reads fine still means the WebUI and the + * desktop stop saying the same thing about the same control. + */ +export const PERSONALIZATION_SECTION_HINTS = { + globalInstructions: + "定义 Agent 应该如何工作、回答和执行任务,为此设备上的所有 Agent 提供额外指令和上下文。", + userProfile: "告诉 Agent 你的背景和长期偏好。", + memory: "设置在此电脑上如何收集、保留和整合本地记忆。", +} as const; + +/** + * The delete confirmation's copy, copied from the desktop word for word. + * + * The half-width `?` is the desktop's, not a typo to tidy up: a full-width + * `?` carries a full em of advance and visibly opens a gap after 忆, and the + * measured dialog does not have one. The body deliberately carries no byte + * count — the desktop's does not either, and the row above the button already + * shows the figure the user would compare it against. + */ +export const MEMORY_DELETE_CONFIRM = { + title: "删除记忆?", + body: "MiniMax Code 将不再记住关于你的重要信息,你的使用体验将少一些个性化。", + cancel: "取消", + confirm: "删除", +} as const; + +/** + * The section title's help affordance. + * + * A real bubble rather than a native `title`, because the desktop hangs a dark + * bubble with an arrow above the glyph and a `title` cannot match it: the + * browser draws its own surface, delays it about a second, and paints no + * arrow. The shape itself is not new — `.webui-signin-credits-tooltip` and + * `.webui-context-usage-label` already carry it, and this is the third call + * site against the same object rather than a third design. + * + * Two decisions worth stating. It is a real `button`, not the `role="img"` + * span it replaces, so the affordance is reachable by keyboard; the bubble is + * `aria-hidden` because `aria-label` already reads as the control's name and + * a `role="tooltip"` nothing references would be a second, dangling + * announcement of the same sentence. + */ +function InfoHint({ text, testId }: { readonly text: string; readonly testId: string }): ReactElement { + return ( + + + + + ); +} + +function SectionHeader({ + title, + hintTestId, + hint, + children, +}: { + readonly title: string; + readonly hint: string; + readonly hintTestId: string; + readonly children?: ReactNode; +}): ReactElement { + return ( +
+ {/* Title and hint travel together: the header is `space-between`, so + three loose children would push the action button into the middle. */} +
+

{title}

+ +
+ {children} +
+ ); +} + +/** One switch row inside the 记忆 card. Mirrors the desktop's row grammar. */ +function MemoryRow({ + title, + description, + testId, + children, +}: { + readonly title: string; + readonly description: string; + readonly testId: string; + readonly children: ReactNode; +}): ReactElement { + return ( +
+
+ {title} + {description} +
+
{children}
+
+ ); +} + +export interface PersonalizationSettingsProps { + readonly getGlobalInstructions?: () => Promise; + readonly setGlobalInstructions?: (request: { + readonly content: string; + }) => Promise; + readonly getAgentMemory?: (request?: { + readonly includeContent?: boolean; + }) => Promise; + readonly setAgentMemory?: (request: { + readonly content: string; + }) => Promise; + readonly getUserProfile?: () => Promise; + readonly setUserProfile?: (request: { + readonly nickname: string; + readonly occupation: string; + readonly moreAbout: string; + }) => Promise; + readonly getMemorySettings?: () => Promise; + readonly setMemorySettings?: (request: { + readonly enabled?: boolean; + readonly proactive?: boolean; + }) => Promise; + /** 「在会话中创建」. Optional on purpose: without a host that can receive the + * hand-off the menu item is not rendered at all, rather than rendered and + * quietly doing nothing when clicked. */ + readonly onCreateInSession?: (input: MemoryHandoff) => void; +} + +/** + * 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(props: PersonalizationSettingsProps): ReactElement { + return ( +
+ + + +
+ ); +} + +function GlobalInstructionsSection({ + 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 ( +
+ + + +
+