From 37bfade1cab996a5497c278faf9903cc9de7df0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:32:29 +0800 Subject: [PATCH 01/11] feat(plugins): expose the selected release kind for personal configuration --- api/openapi.json | 13 +++++++++++++ docs/plugins/marketplace.en.md | 2 ++ docs/plugins/marketplace.md | 1 + frontend/src/api/generated.schemas.ts | 15 +++++++++++++++ integration/agent_configuration_catalog_test.go | 5 ++++- internal/contract/openapi.go | 1 + internal/core/plugin_maintenance.go | 3 +++ 7 files changed, 39 insertions(+), 1 deletion(-) diff --git a/api/openapi.json b/api/openapi.json index 569a7e6c8..5ea191738 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -4978,6 +4978,19 @@ "nullable": true, "type": "string" }, + "kind": { + "enum": [ + "workbench", + "agent", + "webview", + "skill", + "mcp", + "hook", + "pack", + "workflow" + ], + "type": "string" + }, "observedState": { "enum": [ "pending", diff --git a/docs/plugins/marketplace.en.md b/docs/plugins/marketplace.en.md index 30f0e44e4..43fbfaccb 100644 --- a/docs/plugins/marketplace.en.md +++ b/docs/plugins/marketplace.en.md @@ -51,6 +51,8 @@ to start. ## Installing and removing +The summary's optional `kind` comes from the selected release, never the latest catalog. The web screen uses it to offer My Configuration for selected Agents. Current members edit their own values; space installation/removal still requires administrator authority. + | Endpoint | Description | |---|---| | `GET .../spaces/:spaceId/plugins` | The space's plugin selections and progress summaries; readable by any current member | diff --git a/docs/plugins/marketplace.md b/docs/plugins/marketplace.md index 31d90a41f..ada2c2673 100644 --- a/docs/plugins/marketplace.md +++ b/docs/plugins/marketplace.md @@ -49,6 +49,7 @@ Cloud server 自己维护目录(`internal/pluginmarket`),启动时同步 - 选择被接受时,Cloud 为当时**已经存在**的每个存活运行时写一行待处理的实例;之后新建的运行时不会被追加到这次维护里,而是在自己创建或启动时 补装(见下文)。空间里没有存活运行时时,选择立即视为完成。 - 汇总只给出受影响、已完成、等待启动等计数,不暴露别的运行时的失败输出。 +- 汇总的可选 `kind` 来自空间选定的 release,不从最新目录推断。网页据此给已选 Agent 提供「我的配置」;当前成员可配置自己的值,空间安装/移除仍需管理员权限。 ## 运行时里的执行 diff --git a/frontend/src/api/generated.schemas.ts b/frontend/src/api/generated.schemas.ts index 85f5259ee..15696f6cd 100644 --- a/frontend/src/api/generated.schemas.ts +++ b/frontend/src/api/generated.schemas.ts @@ -2212,6 +2212,20 @@ export const SpacePluginDesiredState = { removed: 'removed', } as const; +export type SpacePluginKind = typeof SpacePluginKind[keyof typeof SpacePluginKind]; + + +export const SpacePluginKind = { + workbench: 'workbench', + agent: 'agent', + webview: 'webview', + skill: 'skill', + mcp: 'mcp', + hook: 'hook', + pack: 'pack', + workflow: 'workflow', +} as const; + export type SpacePluginObservedState = typeof SpacePluginObservedState[keyof typeof SpacePluginObservedState]; @@ -2236,6 +2250,7 @@ export interface SpacePlugin { identifier: string; /** @nullable */ installError: string | null; + kind?: SpacePluginKind; observedState: SpacePluginObservedState; /** @nullable */ observedVersion: string | null; diff --git a/integration/agent_configuration_catalog_test.go b/integration/agent_configuration_catalog_test.go index 5540664eb..1c4ecbe8e 100644 --- a/integration/agent_configuration_catalog_test.go +++ b/integration/agent_configuration_catalog_test.go @@ -142,7 +142,10 @@ func TestAgentConfigurationCatalogSyncFreezesAndDispatches(t *testing.T) { // An administrator's selection freezes the association with the exact release chosen. sid := f.defaultSpaceID() - f.call("POST", f.pluginSpacePath(sid)+"/plugins", core.Object{"identifier": "official/declared-agent"}, "select-declared", 200) + selection := f.call("POST", f.pluginSpacePath(sid)+"/plugins", core.Object{"identifier": "official/declared-agent"}, "select-declared", 200) + if selection.O("resource").S("kind") != "agent" { + t.Fatalf("selection must expose the frozen release kind: %+v", selection) + } frozen := f.store.Pool.QueryRow(`SELECT selected_release::text FROM space_plugins WHERE source_namespace='official' AND identifier='declared-agent'`) var frozenText string must(t, frozen.Scan(&frozenText)) diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index 06a0a257f..880f0df4f 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -290,6 +290,7 @@ func Document() map[string]any { s["PluginCatalog"] = object(obj{"items": array(ref("PluginCatalogEntry")), "syncedAt": optional(timestamp())}, "items") s["SpacePlugin"] = resource("id spaceId tenantId sourceNamespace identifier desiredState desiredVersion observedState observedVersion installError version createdAt updatedAt", "observedVersion installError") spacePluginProps := properties(s, "SpacePlugin") + spacePluginProps["kind"] = enumeration("workbench", "agent", "webview", "skill", "mcp", "hook", "pack", "workflow") for _, key := range []string{"affectedCount", "completedCount", "waitingStartCount", "waitingControlCount", "unavailableCount", "failedCount", "desiredRevision"} { spacePluginProps[key] = number() } diff --git a/internal/core/plugin_maintenance.go b/internal/core/plugin_maintenance.go index 276b50be8..af2ac89b4 100644 --- a/internal/core/plugin_maintenance.go +++ b/internal/core/plugin_maintenance.go @@ -50,6 +50,9 @@ func schedulePluginMaintenance(t *transaction) { // pluginSelection is a shared safe summary; runtime-specific errors and identifiers stay private. func pluginSelection(t *transaction, sid, namespace, identifier string) Object { row := t.spacePluginRow(sid, namespace, identifier) + if kind := row.O("selectedRelease").S("kind"); kind != "" { + row["kind"] = kind + } delete(row, "selectedRelease") row["installError"] = nil // Shared summaries never expose another runtime's failure output. counts := t.one("SELECT count(*) AS affected_count,count(*) FILTER(WHERE wi.observed_state IN ('installed','removed')) AS completed_count,count(*) FILTER(WHERE wi.pending_reason='waiting_start') AS waiting_start_count,count(*) FILTER(WHERE wi.pending_reason IN ('waiting_control','waiting_execution','waiting_lifecycle')) AS waiting_control_count,count(*) FILTER(WHERE wi.pending_reason='executor_capability_unavailable') AS unavailable_count,count(*) FILTER(WHERE wi.observed_state='failed') AS failed_count FROM workspace_plugin_instances wi JOIN workspaces w ON w.id=wi.workspace_id JOIN projects p ON p.id=w.project_id WHERE p.space_id=$1 AND wi.source_namespace=$2 AND wi.identifier=$3 AND w.deleted_at IS NULL", sid, namespace, identifier) From 4713392c183948d03d673775462dcc87b6aa50aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:32:29 +0800 Subject: [PATCH 02/11] feat(web): configure personal Agent options and model connections --- .../agent-configurations/README.en.md | 23 ++ .../features/agent-configurations/README.md | 23 ++ .../src/features/agent-configurations/api.ts | 96 +++++++ .../agent-configurations/connections.tsx | 89 ++++++ .../features/agent-configurations/dialog.tsx | 62 +++++ .../agent-configurations/draft.test.ts | 66 +++++ .../features/agent-configurations/draft.ts | 112 ++++++++ .../agent-configurations/editor-state.ts | 101 +++++++ .../agent-configurations/editor.test.tsx | 255 ++++++++++++++++++ .../features/agent-configurations/editor.tsx | 50 ++++ .../agent-configurations/options-form.tsx | 116 ++++++++ .../agent-configurations/reconcile.tsx | 36 +++ .../agent-configurations/setting-fields.tsx | 55 ++++ .../agent-configurations/test-fixtures.ts | 76 ++++++ .../features/model-connections/README.en.md | 2 +- .../src/features/model-connections/README.md | 2 +- .../src/features/model-connections/api.ts | 6 +- .../model-connections/connection-form.tsx | 23 +- .../model-connections/default-form.tsx | 28 +- .../model-connections-page.test.tsx | 27 ++ .../model-connections-page.tsx | 6 +- frontend/src/features/plugins/README.en.md | 7 +- frontend/src/features/plugins/README.md | 5 +- .../features/plugins/plugins-page.test.tsx | 23 ++ .../src/features/plugins/plugins-page.tsx | 2 + 25 files changed, 1272 insertions(+), 19 deletions(-) create mode 100644 frontend/src/features/agent-configurations/README.en.md create mode 100644 frontend/src/features/agent-configurations/README.md create mode 100644 frontend/src/features/agent-configurations/api.ts create mode 100644 frontend/src/features/agent-configurations/connections.tsx create mode 100644 frontend/src/features/agent-configurations/dialog.tsx create mode 100644 frontend/src/features/agent-configurations/draft.test.ts create mode 100644 frontend/src/features/agent-configurations/draft.ts create mode 100644 frontend/src/features/agent-configurations/editor-state.ts create mode 100644 frontend/src/features/agent-configurations/editor.test.tsx create mode 100644 frontend/src/features/agent-configurations/editor.tsx create mode 100644 frontend/src/features/agent-configurations/options-form.tsx create mode 100644 frontend/src/features/agent-configurations/reconcile.tsx create mode 100644 frontend/src/features/agent-configurations/setting-fields.tsx create mode 100644 frontend/src/features/agent-configurations/test-fixtures.ts diff --git a/frontend/src/features/agent-configurations/README.en.md b/frontend/src/features/agent-configurations/README.en.md new file mode 100644 index 000000000..f97582923 --- /dev/null +++ b/frontend/src/features/agent-configurations/README.en.md @@ -0,0 +1,23 @@ +# agent-configurations: personal plugin configuration + +[English](README.en.md) | [中文](README.md) + +Owns My Configuration for the space's selected Agent release: non-secret options, personal connection selection and explicit repair. Space selection, execution and protocol conversion belong to their respective modules. + +| File | Responsibility | +| --- | --- | +| `api.ts` | Personally scoped queries, versioned writes and bounded failure messages | +| `draft.ts` | Typed options, defaults, edit baselines and removed-field comparison | +| `dialog.tsx` | Member-accessible entry that reads only when opened | +| `editor-state.ts` | Editing sessions, explicit conflict reconciliation and clear confirmation | +| `editor.tsx` | Composition of options and separate connection forms | +| `options-form.tsx` / `setting-fields.tsx` | Typed fields and repair/clear actions | +| `reconcile.tsx` | Latest-value comparison with retained drafts or server adoption | +| `connections.tsx` | Protocol-compatible model choices, inline creation and credential forms | +| `*.test.*` / `test-fixtures.ts` | Synthetic contexts, option and HTTP form behavior tests | + +Depends on generated clients, `spaces`, `model-connections`, `lib` and UI primitives; consumed by `plugins`. Lower layers must not depend on it. Connections and credentials reuse the personal library. OpenCode reuses the global default without copying model references into plugin options. + +Edit baselines advance only after a successful write or explicit reconciliation. Background refreshes and conflicts retain drafts. Declaration changes require confirmed repair; unknown fields are never silently migrated or discarded. Clearing options does not delete connections. API keys use the existing write-only boundary and never enter configuration requests, query/mutation caches or persistent storage. Forms are siblings. + +Tests use production generated clients and MSW for types, repair, conflicts, inline connections and credential isolation. The `plugins` tests cover lazy reading through the member entry. diff --git a/frontend/src/features/agent-configurations/README.md b/frontend/src/features/agent-configurations/README.md new file mode 100644 index 000000000..6fc1e71ff --- /dev/null +++ b/frontend/src/features/agent-configurations/README.md @@ -0,0 +1,23 @@ +# agent-configurations:插件个人配置 + +[中文](README.md) | [English](README.en.md) + +负责空间已选 Agent 版本的「我的配置」:非秘密选项、个人连接选择与显式修复。空间选择、运行启动及协议转换由各自模块负责。 + +| 文件 | 职责 | +| --- | --- | +| `api.ts` | 个人作用域查询、版本化写入与有界故障文案 | +| `draft.ts` | 类型化选项、默认值、编辑基线与移除字段核对 | +| `dialog.tsx` | 普通成员可用、打开时读取的入口 | +| `editor-state.ts` | 编辑会话、冲突后显式核对与清除确认 | +| `editor.tsx` | 选项与独立连接表单的组合 | +| `options-form.tsx` / `setting-fields.tsx` | 类型化字段与修复/清除操作 | +| `reconcile.tsx` | 最新值比较、保留草稿或采用服务器值 | +| `connections.tsx` | 匹配协议的模型选择、内联创建与凭据表单 | +| `*.test.*` / `test-fixtures.ts` | 合成上下文、选项与 HTTP 表单行为测试 | + +依赖生成客户端、`spaces`、`model-connections`、`lib` 与 UI 原语;由 `plugins` 消费,下层不得反向依赖。连接与凭据沿用个人连接库;OpenCode 默认模型沿用全局默认选择,不在插件配置中复制引用。 + +编辑基线只在成功写入或用户明确核对时推进。背景刷新与 409 保留草稿;声明变化必须确认修复,不自动迁移或丢弃未知字段。清除个人选项不删除连接。API Key 只经过复用的写入边界,不进入配置请求、查询/变更缓存或持久存储。所有表单互为同级。 + +测试使用真实生成客户端和 MSW,覆盖类型、修复、版本冲突、内联连接与凭据隔离;成员入口的延迟读取由 `plugins` 测试覆盖。 diff --git a/frontend/src/features/agent-configurations/api.ts b/frontend/src/features/agent-configurations/api.ts new file mode 100644 index 000000000..2f6b4bb59 --- /dev/null +++ b/frontend/src/features/agent-configurations/api.ts @@ -0,0 +1,96 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { + PersonalAgentConfigurationContext, + PutApiV1MeAgentConfigurationsSpaceIdPluginNamespacePluginIdentifierBody, +} from '@/api/generated.schemas' +import { + deleteApiV1MeAgentConfigurationsSpaceIdPluginNamespacePluginIdentifier as clearConfiguration, + getApiV1MeAgentConfigurationsSpaceIdPluginNamespacePluginIdentifier as getConfiguration, + postApiV1MeAgentConfigurationsSpaceIdPluginNamespacePluginIdentifierRepair as repairConfiguration, + putApiV1MeAgentConfigurationsSpaceIdPluginNamespacePluginIdentifier as putConfiguration, +} from '@/api/me/me' +import { mutationHeaders, useIdempotencyKeys } from '@/features/spaces/api' +import { faultCode } from '@/lib/api-client' + +/** One user's configuration, resolved in the selected space's pinned release context. */ +export type AgentConfiguration = PersonalAgentConfigurationContext +/** Complete, versioned non-secret options submitted to the server. */ +export type AgentConfigurationWrite = + PutApiV1MeAgentConfigurationsSpaceIdPluginNamespacePluginIdentifierBody +/** An editing context; canonical plugin identity includes both source and identifier. */ +export type AgentConfigurationScope = { spaceId: string; pluginId: string } + +/** Stable cache scope; personal data is discarded by the session's QueryClient reset. */ +export function agentConfigurationKey(scope: AgentConfigurationScope) { + return ['personal-agent-configuration', scope.spaceId, scope.pluginId] +} + +function pluginIdentity(pluginId: string): [string, string] { + const parts = pluginId.split('/') + const source = parts[0] + const identifier = parts[1] + if (parts.length !== 2 || !source || !identifier) throw new Error('invalid_plugin_id') + return [source, identifier] +} + +/** Reads the authenticated user's values; it never starts an Agent or reads a credential. */ +export function useAgentConfiguration(scope: AgentConfigurationScope, enabled: boolean) { + const [source, identifier] = pluginIdentity(scope.pluginId) + return useQuery({ + queryKey: agentConfigurationKey(scope), + queryFn: ({ signal }) => getConfiguration(scope.spaceId, source, identifier, undefined, signal), + enabled, + retry: false, + }) +} + +/** Writes only non-secret configuration. Conflicts are surfaced without automatic retries. */ +export function useSaveAgentConfiguration(scope: AgentConfigurationScope) { + const queryClient = useQueryClient() + const keyFor = useIdempotencyKeys() + const [source, identifier] = pluginIdentity(scope.pluginId) + return useMutation({ + mutationFn: (input: { body: AgentConfigurationWrite; repair: boolean }) => { + const options = { headers: mutationHeaders(keyFor(input)) } + const write = input.repair ? repairConfiguration : putConfiguration + return write(scope.spaceId, source, identifier, input.body, options) + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: agentConfigurationKey(scope) }), + }) +} + +/** Clearing options advances their revision; the shared personal connection remains intact. */ +export function useClearAgentConfiguration(scope: AgentConfigurationScope) { + const queryClient = useQueryClient() + const keyFor = useIdempotencyKeys() + const [source, identifier] = pluginIdentity(scope.pluginId) + return useMutation({ + mutationFn: (body: { revision: number }) => + clearConfiguration(scope.spaceId, source, identifier, body, { + headers: mutationHeaders(keyFor(body)), + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: agentConfigurationKey(scope) }), + }) +} + +const FAILURE_MESSAGES: Record = { + revision_conflict: '配置已在别处修改,当前草稿已保留。请重新核对后保存。', + agent_release_conflict: '空间选用的插件版本已变化,当前草稿已保留。请重新核对。', + agent_declaration_conflict: '插件配置要求已变化,当前草稿已保留。请重新核对。', + agent_configuration_repair_required: '请按当前插件版本确认修复配置。', + agent_declaration_required: '这个插件版本尚未提供个人配置,请联系空间管理员升级插件。', + agent_declaration_invalid: '这个插件版本的配置声明不可用,请联系空间管理员。', + agent_plugin_unavailable: '当前空间尚未选用可配置的插件版本。', + agent_setting_required: '请填写必填选项。', + invalid_agent_setting_type: '选项类型不正确,请检查输入。', + unknown_agent_setting: '插件已移除部分选项,请重新核对。', + model_not_found: '所选模型已不可用,请重新选择。', + model_connection_disabled: '所选连接已停用,请重新选择。', + agent_model_protocol_mismatch: '连接协议与此插件不匹配,请重新选择。', + not_found: '配置或个人连接已不可用,请重新加载。', +} + +/** Converts bounded public codes to user-facing text, without exposing request/error objects. */ +export function agentConfigurationFailure(error: unknown): string { + return FAILURE_MESSAGES[faultCode(error) ?? ''] ?? '配置操作失败,请稍后重试。' +} diff --git a/frontend/src/features/agent-configurations/connections.tsx b/frontend/src/features/agent-configurations/connections.tsx new file mode 100644 index 000000000..bb58102b2 --- /dev/null +++ b/frontend/src/features/agent-configurations/connections.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import type { ModelConnection } from '@/features/model-connections/api' +import { ConnectionForm } from '@/features/model-connections/connection-form' +import { CredentialForm } from '@/features/model-connections/credential-form' +import { ConnectionChoice } from '@/features/model-connections/fields' +import type { AgentConfiguration } from './api' +import type { AgentConfigurationDraft } from './draft' + +/** Selects only enabled connections whose protocol the selected declaration accepts. */ +export function AgentConnectionPicker({ + context, + draft, + connections, + onChange, +}: { + context: AgentConfiguration + draft: AgentConfigurationDraft + connections: ModelConnection[] + onChange: (connectionId: string, modelId: string) => void +}) { + const compatible = connections.filter( + (connection) => + connection.enabled && context.declaration.modelAccess.protocols.includes(connection.protocol), + ) + const selected = compatible.find((connection) => connection.id === draft.connectionId) + return ( +
+ 个人模型 + ({ + value: connection.id, + label: connection.name + (connection.credentialConfigured ? '' : '(待配置 API Key)'), + }))} + onChange={(id) => onChange(id, '')} + /> + ({ + value: model.id, + label: model.name + ' (' + model.id + ')', + }))} + onChange={(modelId) => onChange(draft.connectionId, modelId)} + /> + {!selected && ( +

请选择匹配协议的已启用连接,或在下方新建。

+ )} +
+ ) +} + +/** Reuses the connection library and its write-only credential boundary inside the plugin entry. */ +export function AgentConnectionTools({ + context, + selected, + onCreated, +}: { + context: AgentConfiguration + selected: ModelConnection | undefined + onCreated: (connection: ModelConnection) => void +}) { + const [creating, setCreating] = useState(false) + const protocols = context.declaration.modelAccess.protocols + const initialProtocol = + protocols.includes('anthropic-messages') && !protocols.includes('openai-completions') + ? 'anthropic-messages' + : 'openai-completions' + return ( +
+ + {creating && ( + setCreating(false)} + /> + )} + {selected && ( + + )} +
+ ) +} diff --git a/frontend/src/features/agent-configurations/dialog.tsx b/frontend/src/features/agent-configurations/dialog.tsx new file mode 100644 index 000000000..c40a3f7ba --- /dev/null +++ b/frontend/src/features/agent-configurations/dialog.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { useCurrentSpace } from '@/features/spaces/current-space' +import { + agentConfigurationFailure, + useAgentConfiguration, + type AgentConfigurationScope, +} from './api' +import { AgentConfigurationEditor } from './editor' + +/** Opens the current user's configuration independently of space administrator capabilities. */ +export function AgentConfigurationDialog({ pluginId }: { pluginId: string }) { + const { space } = useCurrentSpace() + const [open, setOpen] = useState(false) + return ( + + + 我的配置 + + } + /> + + + 我的配置 + {pluginId} · 连接与选项仅对当前账号可见。 + + {open && space && } + + + ) +} + +function ConfigurationContent({ scope }: { scope: AgentConfigurationScope }) { + const query = useAgentConfiguration(scope, true) + const [editor, setEditor] = useState(0) + if (!query.data) { + if (query.isError) return

{agentConfigurationFailure(query.error)}

+ return

正在读取个人配置…

+ } + return ( + setEditor((current) => current + 1)} + onReload={async () => { + const result = await query.refetch() + if (result.error) throw result.error + }} + /> + ) +} diff --git a/frontend/src/features/agent-configurations/draft.test.ts b/frontend/src/features/agent-configurations/draft.test.ts new file mode 100644 index 000000000..402b9c179 --- /dev/null +++ b/frontend/src/features/agent-configurations/draft.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { agentConfigurationBody, agentConfigurationDraft } from './draft' +import { agentContext } from './test-fixtures' + +describe('personal Agent configuration drafts', () => { + it('filters removed/type-changed values, resolves defaults and preserves an intermediate numeric edit', () => { + const context = agentContext({ settings: { style: 'mine', budget: true, removed: 'old' } }) + const draft = agentConfigurationDraft(context) + expect(draft.values).toEqual({ style: 'mine', budget: 5, verbose: false }) + expect(draft.removedFields).toEqual(['removed']) + draft.values['budget'] = '12.5' + expect(agentConfigurationDraft(agentContext({ revision: 2 }), draft).values['budget']).toBe( + '12.5', + ) + expect(draft.revision).toBe(1) + }) + + it('uses strict scalar types and does not turn a blank optional number into zero', () => { + const draft = agentConfigurationDraft(agentContext()) + draft.values['budget'] = '' + expect(agentConfigurationBody(draft)?.settings).toEqual({ style: 'concise', verbose: false }) + draft.values['budget'] = 'not-a-number' + expect(agentConfigurationBody(draft)).toBeUndefined() + draft.values['budget'] = Infinity + expect(agentConfigurationBody(draft)).toBeUndefined() + draft.values['budget'] = Number.MAX_SAFE_INTEGER + 1 + expect(agentConfigurationBody(draft)).toBeUndefined() + draft.values['budget'] = false + expect(agentConfigurationBody(draft)).toBeUndefined() + draft.values['budget'] = 1.5 + expect(agentConfigurationBody(draft)?.settings['budget']).toBe(1.5) + }) + + it('keeps false as a supplied boolean and refuses absent required values and model references', () => { + const draft = agentConfigurationDraft(agentContext()) + delete draft.values['style'] + expect(agentConfigurationBody(draft)).toBeUndefined() + draft.values['style'] = '' + expect(agentConfigurationBody(draft)).toBeUndefined() + draft.values['style'] = 'valid' + draft.connectionId = '' + expect(agentConfigurationBody(draft)).toBeUndefined() + draft.connectionId = 'mc1' + draft.modelId = '' + expect(agentConfigurationBody(draft)).toBeUndefined() + draft.modelId = 'vendor/model' + expect(agentConfigurationBody(draft)?.settings['verbose']).toBe(false) + }) + + it('does not copy the OpenCode global model into plugin options and starts cleared values afresh', () => { + const draft = agentConfigurationDraft( + agentContext({ + cleared: true, + modelDefault: { connectionId: 'global', modelId: 'global/model', version: 7 }, + }), + ) + expect(draft.values['style']).toBeUndefined() + draft.values['style'] = 'new' + expect(agentConfigurationBody(draft)).toEqual({ + revision: 1, + selectionVersion: 3, + declarationFingerprint: 'a'.repeat(64), + settings: { style: 'new', budget: 5, verbose: false }, + }) + }) +}) diff --git a/frontend/src/features/agent-configurations/draft.ts b/frontend/src/features/agent-configurations/draft.ts new file mode 100644 index 000000000..306eae340 --- /dev/null +++ b/frontend/src/features/agent-configurations/draft.ts @@ -0,0 +1,112 @@ +import type { AgentConfiguration, AgentConfigurationWrite } from './api' + +/** Scalar values supported by the static declaration, kept in an editable draft only. */ +export type AgentSettingValue = string | number | boolean +/** A control value can remain blank while the user edits a number or optional setting. */ +export type AgentSettingDraft = Record +/** An edit's baseline is frozen until an explicit user reconciliation or successful save. */ +export type AgentConfigurationDraft = { + revision: number + selectionVersion: number + fingerprint: string + connectionId: string + modelId: string + values: AgentSettingDraft + schema: AgentSetting[] + usesGlobalDefault: boolean + requiresRepair: boolean + removedFields: string[] +} +/** One compiled field from the selected release. */ +export type AgentSetting = AgentConfiguration['declaration']['settings'][number] + +function matches(setting: AgentSetting, value: unknown): value is AgentSettingValue { + return typeof value === setting.settingType +} + +/** Builds a draft from server values, filtering removed/type-changed fields before repair. */ +export function agentConfigurationDraft( + context: AgentConfiguration, + kept?: AgentConfigurationDraft, +): AgentConfigurationDraft { + const source = kept?.values ?? (context.cleared ? {} : context.settings) + return { + revision: context.revision, + selectionVersion: context.selectionVersion, + fingerprint: context.declaration.fingerprint, + connectionId: kept?.connectionId ?? context.connectionId ?? '', + modelId: kept?.modelId ?? context.modelId ?? '', + values: draftSettingValues(context, source, kept), + schema: context.declaration.settings, + usesGlobalDefault: context.modelDefault !== undefined, + requiresRepair: context.requiresRepair, + removedFields: Object.keys(source).filter( + (id) => !context.declaration.settings.some((setting) => setting.id === id), + ), + } +} + +function draftSettingValues( + context: AgentConfiguration, + source: AgentSettingDraft, + kept: AgentConfigurationDraft | undefined, +): AgentSettingDraft { + const values: AgentSettingDraft = {} + for (const setting of context.declaration.settings) { + const value = source[setting.id] + const previousType = kept?.schema.find((field) => field.id === setting.id)?.settingType + if ( + matches(setting, value) || + (previousType === 'number' && setting.settingType === 'number' && typeof value === 'string') + ) { + values[setting.id] = value + } else if (matches(setting, setting.default)) values[setting.id] = setting.default + } + return values +} + +type SettingWrite = + { kind: 'value'; value: AgentSettingValue } | { kind: 'missing' } | { kind: 'invalid' } + +function settingWrite(setting: AgentSetting, raw: AgentSettingValue | undefined): SettingWrite { + if (raw === undefined || (raw === '' && setting.settingType !== 'string')) + return { kind: 'missing' } + const value = setting.settingType === 'number' && typeof raw === 'string' ? Number(raw) : raw + if (!matches(setting, value)) return { kind: 'invalid' } + if ( + typeof value === 'number' && + (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) + ) { + return { kind: 'invalid' } + } + if (setting.required && value === '') return { kind: 'invalid' } + return { kind: 'value', value } +} + +/** Resolves form controls into strict scalar JSON without coercing blank numbers to zero. */ +export function agentConfigurationBody( + draft: AgentConfigurationDraft, +): AgentConfigurationWrite | undefined { + const settings: Record = {} + for (const setting of draft.schema) { + const resolved = settingWrite(setting, draft.values[setting.id]) + if (resolved.kind === 'invalid') return undefined + if (resolved.kind === 'missing') { + if (setting.required) return undefined + continue + } + settings[setting.id] = resolved.value + } + const body: AgentConfigurationWrite = { + revision: draft.revision, + selectionVersion: draft.selectionVersion, + declarationFingerprint: draft.fingerprint, + settings, + } + if (!draft.usesGlobalDefault) { + if (!draft.connectionId || !draft.modelId) return undefined + body.connectionId = draft.connectionId + body.modelId = draft.modelId + } + return body +} diff --git a/frontend/src/features/agent-configurations/editor-state.ts b/frontend/src/features/agent-configurations/editor-state.ts new file mode 100644 index 000000000..7ee4f8f67 --- /dev/null +++ b/frontend/src/features/agent-configurations/editor-state.ts @@ -0,0 +1,101 @@ +import { useState } from 'react' +import { + useModelConnections, + useModelDefault, + type ModelConnection, +} from '@/features/model-connections/api' +import { + useSaveAgentConfiguration, + useClearAgentConfiguration, + type AgentConfiguration, + type AgentConfigurationScope, +} from './api' +import { agentConfigurationBody, agentConfigurationDraft, type AgentSettingValue } from './draft' + +/** Input to an editing session; successful writes are the only implicit remount boundary. */ +export type AgentEditorProps = { + context: AgentConfiguration + scope: AgentConfigurationScope + onReload: () => Promise + onSaved: () => void +} + +/** Owns draft baselines and explicit reconciliation without copying live server state. */ +export function useAgentEditor({ context, scope, onReload, onSaved }: AgentEditorProps) { + const [draft, setDraft] = useState(() => agentConfigurationDraft(context)) + const [acceptedRepair, setAcceptedRepair] = useState(false) + const [comparing, setComparing] = useState(false) + const [confirmClear, setConfirmClear] = useState(false) + const [reloadFailed, setReloadFailed] = useState(false) + const save = useSaveAgentConfiguration(scope) + const clear = useClearAgentConfiguration(scope) + const connections = useModelConnections() + const globalDefault = useModelDefault(draft.usesGlobalDefault) + const body = agentConfigurationBody(draft) + const busy = save.isPending || clear.isPending + const canSave = + !busy && body !== undefined && (!draft.requiresRepair || acceptedRepair) && !comparing + async function reload() { + setReloadFailed(false) + try { + await onReload() + setComparing(true) + } catch { + setReloadFailed(true) + } + } + function choose(keepDraft: boolean) { + setDraft(agentConfigurationDraft(context, keepDraft ? draft : undefined)) + setAcceptedRepair(false) + setComparing(false) + save.reset() + clear.reset() + } + function changeSetting(id: string, value: AgentSettingValue | undefined) { + setDraft((current) => { + const values = { ...current.values } + if (value === undefined) delete values[id] + else values[id] = value + return { ...current, values } + }) + } + function selectConnection(connectionId: string, modelId: string) { + setDraft((current) => ({ ...current, connectionId, modelId })) + } + function createdConnection(connection: ModelConnection) { + selectConnection(connection.id, connection.models[0]?.id ?? '') + } + function submit(event: React.FormEvent) { + event.preventDefault() + if (canSave && body) save.mutate({ body, repair: draft.requiresRepair }, { onSuccess: onSaved }) + } + function clearOptions() { + clear.mutate({ revision: draft.revision }, { onSuccess: onSaved }) + } + return { + draft, + body, + busy, + canSave, + acceptedRepair, + setAcceptedRepair, + comparing, + confirmClear, + setConfirmClear, + reloadFailed, + save, + clear, + connections, + globalDefault, + reload, + choose, + changeSetting, + selectConnection, + createdConnection, + submit, + clearOptions, + } +} + +/** State and named actions shared only by the editor's presentational pieces. */ +export type AgentEditorState = ReturnType diff --git a/frontend/src/features/agent-configurations/editor.test.tsx b/frontend/src/features/agent-configurations/editor.test.tsx new file mode 100644 index 000000000..19d2461dc --- /dev/null +++ b/frontend/src/features/agent-configurations/editor.test.tsx @@ -0,0 +1,255 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { describe, expect, it, vi } from 'vitest' +import { server } from '@/test/msw-server' +import type { AgentConfiguration } from './api' +import { AgentConfigurationEditor } from './editor' +import { agentConnection, agentContext } from './test-fixtures' + +const PATH = '/api/v1/me/agent-configurations/space1/official/ora-space.claude' +const CONNECTIONS = '/api/v1/me/model-connections' + +function serveConnections() { + server.use( + http.get(CONNECTIONS, () => HttpResponse.json({ items: [agentConnection()], nextCursor: '' })), + ) +} + +function mountEditor(context = agentContext()) { + serveConnections() + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const onSaved = vi.fn<() => void>() + const onReload = vi.fn<() => Promise>(async () => {}) + const scope = { spaceId: 'space1', pluginId: 'official/ora-space.claude' } + function element(current: AgentConfiguration) { + return ( + + + + ) + } + const rendered = render(element(context)) + return { + client, + onSaved, + onReload, + rerender: (current: AgentConfiguration) => rendered.rerender(element(current)), + } +} + +async function enter(user: ReturnType, label: string, value: string) { + const input = screen.getByLabelText(label) + await user.clear(input) + await user.type(input, value) +} + +describe('personal Agent configuration editor', () => { + it('creates a library connection inline and selects its model without saving options implicitly', async () => { + const created = agentConnection({ + id: 'mc2', + name: 'New Messages', + credentialConfigured: false, + }) + let payload: unknown + let submitted: unknown + server.use( + http.post(CONNECTIONS, async ({ request }) => { + payload = await request.json() + server.use( + http.get(CONNECTIONS, () => + HttpResponse.json({ items: [agentConnection(), created], nextCursor: '' }), + ), + ) + return HttpResponse.json({ resource: created }) + }), + http.put(PATH, async ({ request }) => { + submitted = await request.json() + return HttpResponse.json({ resource: { revision: 2, cleared: false, settings: {} } }) + }), + ) + mountEditor() + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: '新建个人连接' })) + expect(screen.getByRole('combobox', { name: '协议' })).toHaveTextContent('Anthropic Messages') + await enter(user, '连接名称', 'New Messages') + await enter(user, '服务地址', 'https://models.example.invalid') + await enter(user, '模型标识', 'vendor/model') + await enter(user, '显示名称', 'Private Model') + await user.click(screen.getByRole('button', { name: '保存连接' })) + expect(await screen.findByRole('combobox', { name: '模型连接' })).toHaveTextContent( + 'New Messages', + ) + expect(submitted).toBeUndefined() + expect(payload).toEqual( + expect.objectContaining({ name: 'New Messages', protocol: 'anthropic-messages' }), + ) + await user.click(screen.getByRole('button', { name: '保存我的配置' })) + await waitFor(() => + expect(submitted).toEqual( + expect.objectContaining({ connectionId: 'mc2', modelId: 'vendor/model' }), + ), + ) + expect(document.querySelectorAll('form form')).toHaveLength(0) + }) + + it('saves typed values and a frozen edit baseline with a stable idempotency key', async () => { + let submitted: unknown + let key: string | null = null + server.use( + http.put(PATH, async ({ request }) => { + submitted = await request.json() + key = request.headers.get('Idempotency-Key') + return HttpResponse.json({ resource: { revision: 2, cleared: false, settings: {} } }) + }), + ) + const { onSaved } = mountEditor() + const user = userEvent.setup() + await screen.findByRole('combobox', { name: '模型连接' }) + await enter(user, '风格 *', 'literal $(not-a-command)') + await enter(user, '预算', '2.5') + await user.selectOptions(screen.getByLabelText('详细输出'), 'true') + await user.click(screen.getByRole('button', { name: '保存我的配置' })) + await waitFor(() => expect(onSaved).toHaveBeenCalledOnce()) + expect(submitted).toEqual({ + revision: 1, + selectionVersion: 3, + declarationFingerprint: 'a'.repeat(64), + connectionId: 'mc1', + modelId: 'vendor/model', + settings: { style: 'literal $(not-a-command)', budget: 2.5, verbose: true }, + }) + expect(key).toEqual(expect.any(String)) + expect(document.querySelectorAll('form form')).toHaveLength(0) + }) + + it('preserves a stale draft and requires an explicit reconciliation before advancing revision', async () => { + const writes: unknown[] = [] + server.use( + http.put(PATH, async ({ request }) => { + writes.push(await request.json()) + return writes.length === 1 + ? HttpResponse.json({ code: 'revision_conflict', params: {} }, { status: 409 }) + : HttpResponse.json({ resource: { revision: 3, cleared: false, settings: {} } }) + }), + ) + const page = mountEditor() + const user = userEvent.setup() + await enter(user, '风格 *', 'my retained draft') + page.rerender(agentContext({ revision: 2, settings: { style: 'server winner' } })) + expect(screen.getByLabelText('风格 *')).toHaveValue('my retained draft') + await user.click(screen.getByRole('button', { name: '保存我的配置' })) + expect(await screen.findByRole('alert')).toHaveTextContent('草稿已保留') + await user.click(screen.getByRole('button', { name: '重新核对最新配置' })) + expect(await screen.findByText('server winner')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '保存我的配置' })).toBeDisabled() + await user.click(screen.getByRole('button', { name: '保留草稿,按最新要求核对' })) + expect(screen.getByLabelText('风格 *')).toHaveValue('my retained draft') + await user.click(screen.getByRole('button', { name: '保存我的配置' })) + await waitFor(() => expect(writes).toHaveLength(2)) + expect(writes[0]).toEqual(expect.objectContaining({ revision: 1 })) + expect(writes[1]).toEqual( + expect.objectContaining({ + revision: 2, + settings: expect.objectContaining({ style: 'my retained draft' }), + }), + ) + }) + + it('repairs only after confirmation, names removed fields and can adopt the server values', async () => { + let submitted: unknown + server.use( + http.post(PATH + '/repair', async ({ request }) => { + submitted = await request.json() + return HttpResponse.json({ resource: { revision: 2, cleared: false, settings: {} } }) + }), + ) + const context = agentContext({ + requiresRepair: true, + settings: { style: 'old', retiredOption: true }, + }) + const page = mountEditor(context) + const user = userEvent.setup() + expect(screen.getByText(/retiredOption/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: '确认修复并保存' })).toBeDisabled() + await user.click(screen.getByRole('checkbox', { name: '我已核对当前版本的选项和模型' })) + await user.click(screen.getByRole('button', { name: '确认修复并保存' })) + await waitFor(() => expect(page.onSaved).toHaveBeenCalledOnce()) + expect(submitted).toEqual( + expect.objectContaining({ settings: { style: 'old', budget: 5, verbose: false } }), + ) + await enter(user, '风格 *', 'unsaved') + page.rerender(agentContext({ revision: 2, settings: { style: 'new server' } })) + await user.click(screen.getByRole('button', { name: '重新核对最新配置' })) + await user.click(await screen.findByRole('button', { name: '采用服务器配置' })) + expect(screen.getByLabelText('风格 *')).toHaveValue('new server') + }) + + it('clears only personal options with confirmation and preserves the connection library', async () => { + let submitted: unknown + server.use( + http.delete(PATH, async ({ request }) => { + submitted = await request.json() + return HttpResponse.json({ resource: { revision: 2, cleared: true, settings: {} } }) + }), + ) + const page = mountEditor() + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: '清除个人选项' })) + await user.click(screen.getByRole('button', { name: '取消清除' })) + expect(submitted).toBeUndefined() + await user.click(screen.getByRole('button', { name: '清除个人选项' })) + await user.click(screen.getByRole('button', { name: '确认清除' })) + await waitFor(() => expect(page.onSaved).toHaveBeenCalledOnce()) + expect(submitted).toEqual({ revision: 1 }) + expect(await screen.findByText('API Key 已配置', { exact: false })).toBeInTheDocument() + }) + + it('keeps write-only credentials outside query and mutation caches and clears input before I/O', async () => { + const secret = 'synthetic-' + crypto.randomUUID() + const page = mountEditor() + let requestInputWasCleared = false + server.use( + http.put(CONNECTIONS + '/mc1/credential', async ({ request }) => { + requestInputWasCleared = screen.getByLabelText('API Key').getAttribute('value') !== secret + const body = await request.json() + expect(body).toEqual({ apiKey: secret, version: 1 }) + expect(screen.getByLabelText('API Key')).toHaveValue('') + return HttpResponse.json( + { code: 'invalid_model_credential', params: { privateValue: secret } }, + { status: 400 }, + ) + }), + ) + const user = userEvent.setup() + await user.type(await screen.findByLabelText('API Key'), secret) + await user.click(screen.getByRole('button', { name: '保存 API Key' })) + expect(await screen.findByRole('alert')).toHaveTextContent('API Key 不可为空') + expect(requestInputWasCleared).toBe(true) + expect( + JSON.stringify( + page.client + .getQueryCache() + .getAll() + .map((query) => query.state.data), + ), + ).not.toContain(secret) + expect( + JSON.stringify( + page.client + .getMutationCache() + .getAll() + .map((mutation) => mutation.state.variables), + ), + ).not.toContain(secret) + expect(screen.queryByText(secret)).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/agent-configurations/editor.tsx b/frontend/src/features/agent-configurations/editor.tsx new file mode 100644 index 000000000..736cf7fa5 --- /dev/null +++ b/frontend/src/features/agent-configurations/editor.tsx @@ -0,0 +1,50 @@ +import { ModelDefaultForm } from '@/features/model-connections/default-form' +import { agentConfigurationFailure } from './api' +import { AgentConnectionTools } from './connections' +import { useAgentEditor, type AgentEditorProps, type AgentEditorState } from './editor-state' +import { AgentOptionsForm, ClearAgentOptions } from './options-form' +import { AgentConfigurationReconcile } from './reconcile' + +/** Composes independent option, default-selection and credential forms for one editing session. */ +export function AgentConfigurationEditor(props: AgentEditorProps) { + const editor = useAgentEditor(props) + const { draft, globalDefault, connections } = editor + return ( +
+

+ 配置只属于当前账号。修改影响之后的新运行;保存成功还需通过运行时的能力和模型检查。 +

+ {draft.usesGlobalDefault && globalDefault.data && connections.data && ( + + )} + + + {editor.comparing && ( + + )} + {editor.confirmClear && } + connection.id === draft.connectionId)} + onCreated={editor.createdConnection} + /> +
+ ) +} + +function EditorMessages({ editor }: { editor: AgentEditorState }) { + return ( + <> + {(editor.save.isError || editor.clear.isError) && ( +

+ {agentConfigurationFailure(editor.save.error ?? editor.clear.error)} +

+ )} + {editor.reloadFailed &&

最新配置读取失败,草稿仍保留,请重试。

} + {(editor.connections.isError || + (editor.globalDefault.isError && editor.draft.usesGlobalDefault)) && ( +

个人模型信息读取失败,请稍后重试。

+ )} + + ) +} diff --git a/frontend/src/features/agent-configurations/options-form.tsx b/frontend/src/features/agent-configurations/options-form.tsx new file mode 100644 index 000000000..4b00d5024 --- /dev/null +++ b/frontend/src/features/agent-configurations/options-form.tsx @@ -0,0 +1,116 @@ +import { Button } from '@/components/ui/button' +import type { AgentConfiguration } from './api' +import { AgentConnectionPicker } from './connections' +import type { AgentEditorState } from './editor-state' +import { AgentSettingFields } from './setting-fields' + +/** Renders an edit's fixed declaration and explicit save/reload/clear actions. */ +export function AgentOptionsForm({ + context, + editor, +}: { + context: AgentConfiguration + editor: AgentEditorState +}) { + return ( +
+ {!editor.draft.usesGlobalDefault && ( + + )} + + {editor.draft.requiresRepair && ( + + )} + {!editor.body && ( +

+ 请完成模型和必填选项;数字需在浏览器可精确表示的范围内。 +

+ )} +
+ + + +
+ + ) +} + +function RepairNotice({ + removedFields, + accepted, + onChange, +}: { + removedFields: string[] + accepted: boolean + onChange: (value: boolean) => void +}) { + return ( +
+

+ 插件配置要求已变化,请按本版本重新确认。此配置在其他空间也会按该版本检查。 +

+ {removedFields.length > 0 && ( +

本次确认将移除旧选项:{removedFields.join('、')}

+ )} + +
+ ) +} + +/** Confirmation is separate from the options form, so credential and connection forms stay valid HTML. */ +export function ClearAgentOptions({ editor }: { editor: AgentEditorState }) { + return ( +
+

清除插件选项与模型引用?个人连接、API Key 和已有运行仍保留。

+ + +
+ ) +} diff --git a/frontend/src/features/agent-configurations/reconcile.tsx b/frontend/src/features/agent-configurations/reconcile.tsx new file mode 100644 index 000000000..79d93ef21 --- /dev/null +++ b/frontend/src/features/agent-configurations/reconcile.tsx @@ -0,0 +1,36 @@ +import { Button } from '@/components/ui/button' +import type { AgentConfiguration } from './api' + +/** Requires an explicit choice before a stale draft adopts the server's newer write conditions. */ +export function AgentConfigurationReconcile({ + latest, + onChoose, +}: { + latest: AgentConfiguration + onChoose: (keepDraft: boolean) => void +}) { + return ( +
+

最新保存版本 {latest.revision}

+

+ 当前草稿仍保留。请比较最新配置,再选择如何继续;选择后仍需手动保存。 +

+
+ {Object.entries(latest.settings).map(([name, value]) => ( +
+
{name}
+
{String(value)}
+
+ ))} +
+
+ + +
+
+ ) +} diff --git a/frontend/src/features/agent-configurations/setting-fields.tsx b/frontend/src/features/agent-configurations/setting-fields.tsx new file mode 100644 index 000000000..f27de52d0 --- /dev/null +++ b/frontend/src/features/agent-configurations/setting-fields.tsx @@ -0,0 +1,55 @@ +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { AgentSetting, AgentSettingDraft, AgentSettingValue } from './draft' + +/** Renders scalar declarations as labelled controls, treating author text as plain text. */ +export function AgentSettingFields({ + settings, + values, + onChange, +}: { + settings: AgentSetting[] + values: AgentSettingDraft + onChange: (id: string, value: AgentSettingValue | undefined) => void +}) { + return settings.map((setting) => { + const id = 'agent-setting-' + setting.id + const value = values[setting.id] + return ( +
+ + {setting.settingType === 'boolean' ? ( + + ) : ( + onChange(setting.id, event.target.value)} + /> + )} +

{setting.description}

+
+ ) + }) +} diff --git a/frontend/src/features/agent-configurations/test-fixtures.ts b/frontend/src/features/agent-configurations/test-fixtures.ts new file mode 100644 index 000000000..8954ae26e --- /dev/null +++ b/frontend/src/features/agent-configurations/test-fixtures.ts @@ -0,0 +1,76 @@ +import type { ModelConnection } from '@/features/model-connections/api' +import type { AgentConfiguration } from './api' + +/** Synthetic owned connection used by this module's protocol-boundary tests. */ +export function agentConnection(overrides: Partial = {}): ModelConnection { + return { + id: 'mc1', + name: 'Personal Messages', + protocol: 'anthropic-messages', + baseUrl: 'https://models.example.invalid', + authMode: 'bearer', + enabled: true, + credentialConfigured: true, + version: 1, + models: [{ id: 'vendor/model', name: 'Private Model', contextWindow: 128000, maxTokens: 8192 }], + createdAt: '2026-10-01T00:00:00Z', + updatedAt: '2026-10-01T00:00:00Z', + ...overrides, + } +} + +/** A fresh compiled declaration/context; callers may emulate another revision without shared state. */ +export function agentContext(overrides: Partial = {}): AgentConfiguration { + return { + pluginId: 'official/ora-space.claude', + spaceId: 'space1', + pluginVersion: '1.0.0', + revision: 1, + selectionVersion: 3, + declarationFingerprint: 'a'.repeat(64), + cleared: false, + requiresRepair: false, + settings: { style: 'concise' }, + connectionId: 'mc1', + modelId: 'vendor/model', + declaration: { + schemaVersion: 2, + fingerprint: 'a'.repeat(64), + modelAccess: { + contractVersion: 1, + modelCardinality: 'single', + protocols: ['anthropic-messages'], + }, + settings: [ + { + id: 'style', + title: '风格', + description: '输出风格', + settingType: 'string', + required: true, + order: 1, + default: null, + }, + { + id: 'budget', + title: '预算', + description: '可选数值', + settingType: 'number', + required: false, + order: 2, + default: 5, + }, + { + id: 'verbose', + title: '详细输出', + description: '可选开关', + settingType: 'boolean', + required: false, + order: null, + default: false, + }, + ], + }, + ...overrides, + } +} diff --git a/frontend/src/features/model-connections/README.en.md b/frontend/src/features/model-connections/README.en.md index 409ff6587..1f27b642d 100644 --- a/frontend/src/features/model-connections/README.en.md +++ b/frontend/src/features/model-connections/README.en.md @@ -18,7 +18,7 @@ The signed-in user's private multi-connection, multi-model settings and default ## Dependencies and invariants -Depends on generated `api/me`, idempotency helpers from `features/spaces/api`, fault/pagination adapters in `lib`, and UI primitives. Only application routes consume this module; lower layers must not depend on it. TanStack Query owns server state; resource versions remount editing drafts, while refused writes preserve drafts. Slash-containing model identifiers remain unchanged. Secret inputs are never prefilled or persisted in browser storage and never enter TanStack mutation variables. Inputs clear before submission, and HTTP failures retain only safe fault messages, avoiding Axios request configuration containing the original key. Disabled controls never substitute for server authorization. +Depends on generated `api/me`, idempotency helpers from `features/spaces/api`, fault/pagination adapters in `lib`, and UI primitives. Application routes and the personal entry in `agent-configurations` consume it; connection forms can return safe metadata after saving. Lower layers must not depend on it. TanStack Query owns server state. Default-model drafts retain their editing version across refreshes and conflicts; explicit reconciliation advances that baseline. Slash-containing model identifiers remain unchanged. Secret inputs are never prefilled or persisted in browser storage and never enter TanStack mutation variables. Inputs clear before submission, and HTTP failures retain only safe fault messages, avoiding Axios request configuration containing the original key. Disabled controls never substitute for server authorization. Credential PUT also requires an idempotency key. The shared adapter generates it from safe connection/version metadata without retaining the secret as idempotency variables. diff --git a/frontend/src/features/model-connections/README.md b/frontend/src/features/model-connections/README.md index 59ab96727..75a611cc0 100644 --- a/frontend/src/features/model-connections/README.md +++ b/frontend/src/features/model-connections/README.md @@ -18,7 +18,7 @@ ## 依赖与不变量 -依赖生成的 `api/me`、`features/spaces/api` 的幂等键、`lib` 的故障/分页适配、UI 原语;只由应用路由消费。下层不得反向依赖本模块。服务端状态保留在 TanStack Query,表单按资源版本重建编辑草稿;拒绝保存保留用户草稿。模型标识中的 `/` 原样发送。密钥输入不回填、不写入浏览器持久存储、不进入 TanStack mutation variables;提交时清空输入,HTTP 拒绝只保留白名单故障文案,避免 Axios 请求配置中的原始密钥被缓存。界面的禁用状态不替代服务器授权。 +依赖生成的 `api/me`、`features/spaces/api` 的幂等键、`lib` 的故障/分页适配、UI 原语;由应用路由和 `agent-configurations` 的个人入口消费,连接表单可在保存后回传安全元数据。下层不得反向依赖本模块。服务端状态保留在 TanStack Query;默认模型保留编辑时版本,背景刷新和冲突不重置草稿,明确核对后才采用新版本。模型标识中的 `/` 原样发送。密钥输入不回填、不写入浏览器持久存储、不进入 TanStack mutation variables;提交时清空输入,HTTP 拒绝只保留白名单故障文案,避免 Axios 请求配置中的原始密钥被缓存。界面的禁用状态不替代服务器授权。 凭据 PUT 也必须带幂等键;该键通过共享适配器以连接/版本元数据生成,不保留密钥作为幂等变量。 diff --git a/frontend/src/features/model-connections/api.ts b/frontend/src/features/model-connections/api.ts index 9054cffcf..e9a803500 100644 --- a/frontend/src/features/model-connections/api.ts +++ b/frontend/src/features/model-connections/api.ts @@ -47,10 +47,11 @@ export function useModelConnections() { } /** Reads the default used only when a new model-backed run is created. */ -export function useModelDefault() { +export function useModelDefault(enabled = true) { return useQuery({ queryKey: getGetApiV1MeModelDefaultQueryKey(), queryFn: ({ signal }) => getApiV1MeModelDefault(undefined, signal), + enabled, }) } @@ -97,6 +98,7 @@ export function useSaveModelDefault() { return useMutation({ mutationFn: (input: ModelDefault) => putApiV1MeModelDefault(input), onSuccess: (result) => queryClient.setQueryData(getGetApiV1MeModelDefaultQueryKey(), result), + onError: () => queryClient.invalidateQueries({ queryKey: getGetApiV1MeModelDefaultQueryKey() }), }) } @@ -136,7 +138,7 @@ export async function clearCredential(connection: ModelConnection): Promise = { - version_conflict: '配置已在别处修改,请刷新后重试', + version_conflict: '配置已在别处修改,草稿已保留,请核对最新配置后重试', invalid_model_connection: '请检查连接地址、认证方式和模型信息', invalid_model_protocol: '请选择支持的模型协议', invalid_model_auth: '该协议不支持所选认证方式', diff --git a/frontend/src/features/model-connections/connection-form.tsx b/frontend/src/features/model-connections/connection-form.tsx index 8f41359a5..316a61a15 100644 --- a/frontend/src/features/model-connections/connection-form.tsx +++ b/frontend/src/features/model-connections/connection-form.tsx @@ -108,10 +108,13 @@ function ModelFields({ ) } -function useConnectionDraft(connection: ModelConnection | undefined) { +function useConnectionDraft( + connection: ModelConnection | undefined, + initialProtocol: ConnectionDraft['protocol'] | undefined, +) { const [metadata, setMetadata] = useState>({ name: connection?.name ?? '', - protocol: connection?.protocol ?? 'openai-completions', + protocol: connection?.protocol ?? initialProtocol ?? 'openai-completions', baseUrl: connection?.baseUrl ?? '', authMode: connection?.authMode ?? 'bearer', enabled: connection?.enabled ?? true, @@ -136,16 +139,28 @@ function useConnectionDraft(connection: ModelConnection | undefined) { export function ConnectionForm({ connection, onClose, + initialProtocol, + onSaved, }: { connection: ModelConnection | undefined onClose: () => void + initialProtocol?: ConnectionDraft['protocol'] + onSaved?: (connection: ModelConnection) => void }) { - const { metadata, setMetadata, models, setModels, addModel } = useConnectionDraft(connection) + const { metadata, setMetadata, models, setModels, addModel } = useConnectionDraft( + connection, + initialProtocol, + ) const save = useSaveConnection(connection) function submit(event: React.FormEvent) { event.preventDefault() const draft = { ...metadata, models: models.map(({ key: _key, ...model }) => model) } - save.mutate(draft, { onSuccess: onClose }) + save.mutate(draft, { + onSuccess: (saved) => { + onSaved?.(saved) + onClose() + }, + }) } return (
diff --git a/frontend/src/features/model-connections/default-form.tsx b/frontend/src/features/model-connections/default-form.tsx index 1532d548a..ca6ecef5c 100644 --- a/frontend/src/features/model-connections/default-form.tsx +++ b/frontend/src/features/model-connections/default-form.tsx @@ -30,6 +30,7 @@ export function ModelDefaultForm({ } const [connectionId, setConnectionId] = useState(selection.connectionId) const [modelId, setModelId] = useState(selection.modelId) + const [version, setVersion] = useState(selection.version) const models = ready.find((connection) => connection.id === connectionId)?.models ?? [] const save = useSaveModelDefault() const valid = models.some((model) => model.id === modelId) @@ -38,7 +39,13 @@ export function ModelDefaultForm({ className="space-y-3 rounded-lg border p-4" onSubmit={(event) => { event.preventDefault() - if (valid) save.mutate({ connectionId, modelId, version: selection.version }) + if (valid) + save.mutate( + { connectionId, modelId, version }, + { + onSuccess: (saved) => setVersion(saved.version), + }, + ) }} >

默认模型

@@ -80,6 +87,14 @@ export function ModelDefaultForm({ {modelConnectionFailure(save.error)}

)} + {version !== selection.version && ( + { + setVersion(selection.version) + save.reset() + }} + /> + )} {save.isSuccess && (

默认模型已保存 @@ -88,3 +103,14 @@ export function ModelDefaultForm({

) } + +function DefaultReconciliation({ onConfirm }: { onConfirm: () => void }) { + return ( +
+

服务器的默认选择已更新。当前选择草稿仍保留,请先核对上方最新默认值。

+ +
+ ) +} diff --git a/frontend/src/features/model-connections/model-connections-page.test.tsx b/frontend/src/features/model-connections/model-connections-page.test.tsx index dba60586c..571326688 100644 --- a/frontend/src/features/model-connections/model-connections-page.test.tsx +++ b/frontend/src/features/model-connections/model-connections-page.test.tsx @@ -64,6 +64,33 @@ function fault(code: string, status: number) { } describe('ModelConnectionsPage', () => { + it('retains the default edit version across a conflict until the user reconciles explicitly', async () => { + serve([connection()]) + let version = 1 + const writes: unknown[] = [] + server.use( + http.get(DEFAULT, () => + HttpResponse.json({ connectionId: 'mc1', modelId: 'vendor/model', version }), + ), + http.put(DEFAULT, async ({ request }) => { + writes.push(await request.json()) + version = 2 + return fault('version_conflict', 409) + }), + ) + renderPage() + const user = userEvent.setup() + await user.click(await screen.findByRole('button', { name: '保存默认模型' })) + await user.click(await screen.findByRole('button', { name: '已核对,保留草稿继续编辑' })) + expect(screen.getByRole('combobox', { name: '默认模型' })).toHaveTextContent('Model One') + await user.click(screen.getByRole('button', { name: '保存默认模型' })) + await waitFor(() => expect(writes).toHaveLength(2)) + expect(writes).toEqual([ + { connectionId: 'mc1', modelId: 'vendor/model', version: 1 }, + { connectionId: 'mc1', modelId: 'vendor/model', version: 2 }, + ]) + }) + it.each([ { protocol: 'openai-completions', diff --git a/frontend/src/features/model-connections/model-connections-page.tsx b/frontend/src/features/model-connections/model-connections-page.tsx index 5f28477af..500315fe4 100644 --- a/frontend/src/features/model-connections/model-connections-page.tsx +++ b/frontend/src/features/model-connections/model-connections-page.tsx @@ -105,11 +105,7 @@ export function ModelConnectionsPage() { {!failed && (!connections.data || !selection.data) && } {connections.data && selection.data && ( <> - `${item.id}:${item.version}`).join(',')}`} - connections={connections.data} - selection={selection.data} - /> + diff --git a/frontend/src/features/plugins/README.en.md b/frontend/src/features/plugins/README.en.md index 6afa9f4a6..68fd41d96 100644 --- a/frontend/src/features/plugins/README.en.md +++ b/frontend/src/features/plugins/README.en.md @@ -5,8 +5,8 @@ The workspace sidebar's "Plugins" screen and its data layer: the **selected plugins** list (desired/observed state badges + removal) on top, the **workflow asset** import results (one row per document, separate from install state) below it, and the **marketplace** catalog grid -(search, kind filter, install) last. It expresses only the cloud-authoritative selection state; -activation, stop, configuration and logs belong to the Node runtime plane and stay out of this +(search, kind filter, install) last. Selected Agents delegate a personal entry to `agent-configurations`. It expresses the cloud-authoritative selection state; +activation, stop and logs belong to the Node runtime plane and stay out of this module. ## Files @@ -22,7 +22,7 @@ module. ## Dependencies and callers - Depends on: `src/api` (orval-generated client), `features/spaces` (current-space, idempotency - keys, SSE invalidation), `components/ui`. + keys, SSE invalidation), `components/ui`, `features/agent-configurations`. - Called by: `routes.tsx` (`/w/:slug/plugins`), `components/layout/app-sidebar.tsx` (nav entry). - Tests depend on: `mocks/handlers/plugins.ts`, `test/cloud-handlers.ts`. @@ -38,6 +38,7 @@ module. - Import rows poll only while `pending`/`importing`; once terminal the interval stops and SSE or the next mount refreshes stale data. - `pack` entries are not installable in v1: the button stays disabled and reads "暂不支持". +- My Configuration uses the selected release's `kind`, not the latest catalog's type; members can open it, and personal data is read only then. - Every async update stays inside query/mutation act boundaries (clean-stderr gate). ## Tests diff --git a/frontend/src/features/plugins/README.md b/frontend/src/features/plugins/README.md index 98db30db8..f92cf6d69 100644 --- a/frontend/src/features/plugins/README.md +++ b/frontend/src/features/plugins/README.md @@ -4,7 +4,7 @@ 工作区侧栏"插件"栏的页面与数据层:上半是**已选插件**列表(desired/observed 状态徽章 + 移除), 中间是**工作流资产**导入结果(逐文档结果,与安装状态分开),下半是**插件市场**目录网格(搜索、 -kind 筛选、安装)。它只表达 cloud 的权威选择状态;激活、停止、配置与日志属于 Node 运行时平面, +kind 筛选、安装)。已选 Agent 提供个人配置入口,交给 `agent-configurations`;它表达 cloud 的权威选择状态;激活、停止与日志属于 Node 运行时平面, 不在本模块。 ## 文件 @@ -19,7 +19,7 @@ kind 筛选、安装)。它只表达 cloud 的权威选择状态;激活、停止 ## 依赖与调用方 - 依赖:`src/api`(orval 生成客户端)、`features/spaces`(current-space、幂等键、SSE 失效)、 - `components/ui`。 + `components/ui`、`features/agent-configurations`。 - 调用方:`routes.tsx`(`/w/:slug/plugins`)、`components/layout/app-sidebar.tsx`(导航入口)。 - 测试依赖:`mocks/handlers/plugins.ts`、`test/cloud-handlers.ts`。 @@ -31,6 +31,7 @@ kind 筛选、安装)。它只表达 cloud 的权威选择状态;激活、停止 文档失败码是稳定契约,前端只做文案映射,未知码原样展示。 - 导入行只在 `pending`/`importing` 时轮询;全部终态后停止,过期结果由 SSE 或下次挂载刷新。 - `pack` 条目 v1 禁装:按钮恒禁用并显示"暂不支持"。 +- 「我的配置」依据已选版本的 `kind` 展示,普通成员可用,打开前不读取个人值;不依赖最新目录的类型。 - 所有异步更新都在查询/变更的 act 边界内(clean-stderr 门禁)。 ## 测试 diff --git a/frontend/src/features/plugins/plugins-page.test.tsx b/frontend/src/features/plugins/plugins-page.test.tsx index 8322cf0b4..13403c8c5 100644 --- a/frontend/src/features/plugins/plugins-page.test.tsx +++ b/frontend/src/features/plugins/plugins-page.test.tsx @@ -183,6 +183,29 @@ describe('PluginsPage', () => { }) describe('plugin selection authority', () => { + it('opens personal configuration lazily for a member using the selected release kind', async () => { + let reads = 0 + installFixtures({ plugins: [spacePlugin('official/hello-world', { kind: 'agent' })] }) + installCloudSpaceHandlers('member') + server.use( + http.get('/api/v1/me/agent-configurations/:spaceId/official/hello-world', () => { + reads += 1 + return HttpResponse.json( + { code: 'agent_configuration_unavailable', params: {} }, + { status: 409 }, + ) + }), + ) + renderWithProviders(, { slug: 'cloud-dev' }) + const button = await screen.findByRole('button', { name: '我的配置' }) + expect(reads).toBe(0) + expect(button).toBeEnabled() + await userEvent.setup().click(button) + expect(await screen.findByRole('alert')).toHaveTextContent('配置') + expect(reads).toBe(1) + expect(screen.getByRole('dialog')).toHaveTextContent('连接与选项仅对当前账号可见') + }) + it('allows members to browse while refusing selection controls', async () => { installFixtures({ plugins: [spacePlugin('official/hello-world')] }) installCloudSpaceHandlers('member') diff --git a/frontend/src/features/plugins/plugins-page.tsx b/frontend/src/features/plugins/plugins-page.tsx index 4a06a373f..ddd5d0519 100644 --- a/frontend/src/features/plugins/plugins-page.tsx +++ b/frontend/src/features/plugins/plugins-page.tsx @@ -7,6 +7,7 @@ import type { WorkflowAssetImport, } from '@/api/generated.schemas' import { useCurrentSpace } from '@/features/spaces/current-space' +import { AgentConfigurationDialog } from '@/features/agent-configurations/dialog' import { PageHeader } from '@/components/layout/page-header' import { AlertDialog, @@ -176,6 +177,7 @@ function SelectedPluginRow({ {plugin.waitingControlCount ?? 0} · 等待启动 {plugin.waitingStartCount ?? 0} · 暂不可执行{' '} {plugin.unavailableCount ?? 0} · 失败 {plugin.failedCount ?? 0}

+ {plugin.kind === 'agent' && } Date: Tue, 20 Oct 2026 18:03:13 +0800 Subject: [PATCH 03/11] fix(web): preserve exact numeric Agent settings through forms --- .../agent-configurations/README.en.md | 1 + .../features/agent-configurations/README.md | 1 + .../src/features/agent-configurations/api.ts | 30 ++++++-- .../features/agent-configurations/draft.ts | 38 +++++++--- .../agent-configurations/editor-state.ts | 13 +++- .../agent-configurations/editor.test.tsx | 30 +++++++- .../agent-configurations/numbers.test.ts | 61 ++++++++++++++++ .../features/agent-configurations/numbers.ts | 69 +++++++++++++++++++ .../agent-configurations/options-form.tsx | 4 +- 9 files changed, 227 insertions(+), 20 deletions(-) create mode 100644 frontend/src/features/agent-configurations/numbers.test.ts create mode 100644 frontend/src/features/agent-configurations/numbers.ts diff --git a/frontend/src/features/agent-configurations/README.en.md b/frontend/src/features/agent-configurations/README.en.md index f97582923..05be4fcb4 100644 --- a/frontend/src/features/agent-configurations/README.en.md +++ b/frontend/src/features/agent-configurations/README.en.md @@ -8,6 +8,7 @@ Owns My Configuration for the space's selected Agent release: non-secret options | --- | --- | | `api.ts` | Personally scoped queries, versioned writes and bounded failure messages | | `draft.ts` | Typed options, defaults, edit baselines and removed-field comparison | +| `numbers.ts` | Numeric response literals and exact request serialization for integers and decimals | | `dialog.tsx` | Member-accessible entry that reads only when opened | | `editor-state.ts` | Editing sessions, explicit conflict reconciliation and clear confirmation | | `editor.tsx` | Composition of options and separate connection forms | diff --git a/frontend/src/features/agent-configurations/README.md b/frontend/src/features/agent-configurations/README.md index 6fc1e71ff..1e96b2718 100644 --- a/frontend/src/features/agent-configurations/README.md +++ b/frontend/src/features/agent-configurations/README.md @@ -8,6 +8,7 @@ | --- | --- | | `api.ts` | 个人作用域查询、版本化写入与有界故障文案 | | `draft.ts` | 类型化选项、默认值、编辑基线与移除字段核对 | +| `numbers.ts` | 响应数值原文与精确请求序列化,保留大整数和高精度小数 | | `dialog.tsx` | 普通成员可用、打开时读取的入口 | | `editor-state.ts` | 编辑会话、冲突后显式核对与清除确认 | | `editor.tsx` | 选项与独立连接表单的组合 | diff --git a/frontend/src/features/agent-configurations/api.ts b/frontend/src/features/agent-configurations/api.ts index 2f6b4bb59..ccb6e4109 100644 --- a/frontend/src/features/agent-configurations/api.ts +++ b/frontend/src/features/agent-configurations/api.ts @@ -11,9 +11,17 @@ import { } from '@/api/me/me' import { mutationHeaders, useIdempotencyKeys } from '@/features/spaces/api' import { faultCode } from '@/lib/api-client' +import { + agentConfigurationResponse, + serializeAgentConfiguration, + type AgentNumberLiterals, +} from './numbers' /** One user's configuration, resolved in the selected space's pinned release context. */ -export type AgentConfiguration = PersonalAgentConfigurationContext +export type AgentConfiguration = PersonalAgentConfigurationContext & { + numericValues?: AgentNumberLiterals + numericDefaults?: AgentNumberLiterals +} /** Complete, versioned non-secret options submitted to the server. */ export type AgentConfigurationWrite = PutApiV1MeAgentConfigurationsSpaceIdPluginNamespacePluginIdentifierBody @@ -38,7 +46,14 @@ export function useAgentConfiguration(scope: AgentConfigurationScope, enabled: b const [source, identifier] = pluginIdentity(scope.pluginId) return useQuery({ queryKey: agentConfigurationKey(scope), - queryFn: ({ signal }) => getConfiguration(scope.spaceId, source, identifier, undefined, signal), + queryFn: ({ signal }) => + getConfiguration( + scope.spaceId, + source, + identifier, + { transformResponse: agentConfigurationResponse }, + signal, + ), enabled, retry: false, }) @@ -50,8 +65,15 @@ export function useSaveAgentConfiguration(scope: AgentConfigurationScope) { const keyFor = useIdempotencyKeys() const [source, identifier] = pluginIdentity(scope.pluginId) return useMutation({ - mutationFn: (input: { body: AgentConfigurationWrite; repair: boolean }) => { - const options = { headers: mutationHeaders(keyFor(input)) } + mutationFn: (input: { + body: AgentConfigurationWrite + repair: boolean + numbers: AgentNumberLiterals + }) => { + const options = { + headers: mutationHeaders(keyFor(input)), + transformRequest: () => serializeAgentConfiguration(input.body, input.numbers), + } const write = input.repair ? repairConfiguration : putConfiguration return write(scope.spaceId, source, identifier, input.body, options) }, diff --git a/frontend/src/features/agent-configurations/draft.ts b/frontend/src/features/agent-configurations/draft.ts index 306eae340..59c399843 100644 --- a/frontend/src/features/agent-configurations/draft.ts +++ b/frontend/src/features/agent-configurations/draft.ts @@ -1,4 +1,5 @@ import type { AgentConfiguration, AgentConfigurationWrite } from './api' +import { agentNumberLiteral, type AgentNumberLiterals } from './numbers' /** Scalar values supported by the static declaration, kept in an editable draft only. */ export type AgentSettingValue = string | number | boolean @@ -54,13 +55,16 @@ function draftSettingValues( const values: AgentSettingDraft = {} for (const setting of context.declaration.settings) { const value = source[setting.id] + const exact = kept ? undefined : context.numericValues?.[setting.id] + const defaultLiteral = context.numericDefaults?.[setting.id] const previousType = kept?.schema.find((field) => field.id === setting.id)?.settingType if ( matches(setting, value) || (previousType === 'number' && setting.settingType === 'number' && typeof value === 'string') ) { - values[setting.id] = value - } else if (matches(setting, setting.default)) values[setting.id] = setting.default + values[setting.id] = exact ?? value + } else if (matches(setting, setting.default)) + values[setting.id] = defaultLiteral ?? setting.default } return values } @@ -71,18 +75,34 @@ type SettingWrite = function settingWrite(setting: AgentSetting, raw: AgentSettingValue | undefined): SettingWrite { if (raw === undefined || (raw === '' && setting.settingType !== 'string')) return { kind: 'missing' } - const value = setting.settingType === 'number' && typeof raw === 'string' ? Number(raw) : raw - if (!matches(setting, value)) return { kind: 'invalid' } + if (setting.settingType === 'number') return numericSettingWrite(raw) + if (!matches(setting, raw) || (setting.required && raw === '')) return { kind: 'invalid' } + return { kind: 'value', value: raw } +} + +function numericSettingWrite(raw: AgentSettingValue): SettingWrite { + if (typeof raw === 'boolean') return { kind: 'invalid' } + if (typeof raw === 'string' && !agentNumberLiteral(raw)) return { kind: 'invalid' } + const value = Number(raw) if ( - typeof value === 'number' && - (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) - ) { + !Number.isFinite(value) || + (typeof raw === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) + ) return { kind: 'invalid' } - } - if (setting.required && value === '') return { kind: 'invalid' } return { kind: 'value', value } } +/** The validated numeric controls remain exact when the HTTP boundary builds JSON. */ +export function agentConfigurationNumbers(draft: AgentConfigurationDraft): AgentNumberLiterals { + const numbers: AgentNumberLiterals = {} + for (const setting of draft.schema) { + const value = draft.values[setting.id] + if (setting.settingType === 'number' && typeof value === 'string' && agentNumberLiteral(value)) + numbers[setting.id] = value + } + return numbers +} + /** Resolves form controls into strict scalar JSON without coercing blank numbers to zero. */ export function agentConfigurationBody( draft: AgentConfigurationDraft, diff --git a/frontend/src/features/agent-configurations/editor-state.ts b/frontend/src/features/agent-configurations/editor-state.ts index 7ee4f8f67..89e14e7cd 100644 --- a/frontend/src/features/agent-configurations/editor-state.ts +++ b/frontend/src/features/agent-configurations/editor-state.ts @@ -10,7 +10,12 @@ import { type AgentConfiguration, type AgentConfigurationScope, } from './api' -import { agentConfigurationBody, agentConfigurationDraft, type AgentSettingValue } from './draft' +import { + agentConfigurationBody, + agentConfigurationDraft, + agentConfigurationNumbers, + type AgentSettingValue, +} from './draft' /** Input to an editing session; successful writes are the only implicit remount boundary. */ export type AgentEditorProps = { @@ -67,7 +72,11 @@ export function useAgentEditor({ context, scope, onReload, onSaved }: AgentEdito } function submit(event: React.FormEvent) { event.preventDefault() - if (canSave && body) save.mutate({ body, repair: draft.requiresRepair }, { onSuccess: onSaved }) + if (canSave && body) + save.mutate( + { body, repair: draft.requiresRepair, numbers: agentConfigurationNumbers(draft) }, + { onSuccess: onSaved }, + ) } function clearOptions() { clear.mutate({ revision: draft.revision }, { onSuccess: onSaved }) diff --git a/frontend/src/features/agent-configurations/editor.test.tsx b/frontend/src/features/agent-configurations/editor.test.tsx index 19d2461dc..9947a7c8b 100644 --- a/frontend/src/features/agent-configurations/editor.test.tsx +++ b/frontend/src/features/agent-configurations/editor.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { http, HttpResponse } from 'msw' import { describe, expect, it, vi } from 'vitest' @@ -49,10 +49,36 @@ function mountEditor(context = agentContext()) { async function enter(user: ReturnType, label: string, value: string) { const input = screen.getByLabelText(label) await user.clear(input) - await user.type(input, value) + await user.paste(value) } describe('personal Agent configuration editor', () => { + it('keeps exact numeric text through the rendered control and the real HTTP serialization', async () => { + let submitted = '' + server.use( + http.put(PATH, async ({ request }) => { + submitted = await request.text() + return HttpResponse.json({ resource: { revision: 2, cleared: false, settings: {} } }) + }), + ) + mountEditor( + agentContext({ + settings: { style: 'exact', budget: 9007199254740992 }, + numericValues: { budget: '9007199254740993' }, + }), + ) + const user = userEvent.setup() + expect(screen.getByLabelText('预算')).toHaveAttribute('value', '9007199254740993') + fireEvent.change(screen.getByLabelText('预算'), { + target: { value: '0.123456789012345678901' }, + }) + const save = screen.getByRole('button', { name: '保存我的配置' }) + expect(save).toBeEnabled() + expect(save.closest('form')?.checkValidity()).toBe(true) + await user.click(save) + await waitFor(() => expect(submitted).toContain('"budget":0.123456789012345678901')) + }) + it('creates a library connection inline and selects its model without saving options implicitly', async () => { const created = agentConnection({ id: 'mc2', diff --git a/frontend/src/features/agent-configurations/numbers.test.ts b/frontend/src/features/agent-configurations/numbers.test.ts new file mode 100644 index 000000000..1b8d5c614 --- /dev/null +++ b/frontend/src/features/agent-configurations/numbers.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { + agentConfigurationResponse, + agentNumberLiteral, + serializeAgentConfiguration, +} from './numbers' + +describe('exact personal setting numbers', () => { + it('retains large integers and precise decimals in saved values and declaration defaults', () => { + const result = agentConfigurationResponse( + '{"revision":1,"settings":{"counter":9007199254740993,"ratio":0.123456789012345678901,"style":"literal"},"declaration":{"settings":[{"id":"budget","default":9007199254740995}]}}', + ) + expect(result).toEqual( + expect.objectContaining({ + revision: 1, + numericValues: { + counter: '9007199254740993', + ratio: '0.123456789012345678901', + }, + numericDefaults: { budget: '9007199254740995' }, + }), + ) + expect(() => agentConfigurationResponse(undefined)).toThrow( + 'invalid_agent_configuration_response', + ) + }) + + it('serializes number literals exactly and JSON encodes every key and non-number value', () => { + expect( + serializeAgentConfiguration( + { + revision: 1, + selectionVersion: 2, + declarationFingerprint: 'f', + settings: { + counter: 9007199254740992, + ratio: 0.123, + style: 'literal $(text)', + verbose: false, + }, + }, + { counter: '9007199254740993', ratio: '0.123456789012345678901' }, + ), + ).toBe( + '{"revision":1,"selectionVersion":2,"declarationFingerprint":"f","settings":{"counter":9007199254740993,"ratio":0.123456789012345678901,"style":"literal $(text)","verbose":false}}', + ) + expect(() => + serializeAgentConfiguration( + { revision: 1, selectionVersion: 2, declarationFingerprint: 'f', settings: { budget: 2 } }, + { budget: '2,"other":3' }, + ), + ).toThrow('invalid_agent_number') + }) + + it('accepts JSON exponent numbers and rejects blank, leading zeroes and non-finite input', () => { + for (const value of ['-1', '0.125', '2e3', '9007199254740993']) + expect(agentNumberLiteral(value)).toBe(true) + for (const value of ['', ' 2', '01', 'NaN', '1e400', '0x10']) + expect(agentNumberLiteral(value)).toBe(false) + }) +}) diff --git a/frontend/src/features/agent-configurations/numbers.ts b/frontend/src/features/agent-configurations/numbers.ts new file mode 100644 index 000000000..d4f6788e4 --- /dev/null +++ b/frontend/src/features/agent-configurations/numbers.ts @@ -0,0 +1,69 @@ +import type { PutApiV1MeAgentConfigurationsSpaceIdPluginNamespacePluginIdentifierBody as AgentConfigurationWrite } from '@/api/generated.schemas' + +const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/ + +/** Numeric setting literals kept apart from JavaScript's approximate number representation. */ +export type AgentNumberLiterals = Record + +/** Checks a bounded JSON number before it can enter an exact request serialization. */ +export function agentNumberLiteral(value: string): boolean { + return value.length <= 1024 && JSON_NUMBER.test(value) && Number.isFinite(Number(value)) +} + +function record(value: unknown): Record { + return isRecord(value) ? value : {} +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function literal(value: unknown): string | undefined { + const source = record(value)['numberLiteral'] + return typeof source === 'string' && agentNumberLiteral(source) ? source : undefined +} + +/** Axios response boundary: retain exact numeric settings/defaults without changing schema types. */ +export function agentConfigurationResponse(data: unknown): unknown { + if (typeof data !== 'string') throw new Error('invalid_agent_configuration_response') + const parsed: unknown = JSON.parse(data) + const exact: unknown = JSON.parse( + data, + (_key: string, value: unknown, context?: { source?: string }) => { + if (typeof value !== 'number') return value + if (context?.source === undefined) throw new Error('exact_json_numbers_unavailable') + return { numberLiteral: context.source } + }, + ) + const values: AgentNumberLiterals = {} + for (const [key, value] of Object.entries(record(record(exact)['settings']))) { + const source = literal(value) + if (source !== undefined) values[key] = source + } + const defaults: AgentNumberLiterals = {} + const fields = record(record(exact)['declaration'])['settings'] + if (Array.isArray(fields)) { + for (const field of fields) { + const id = record(field)['id'] + const source = literal(record(field)['default']) + if (typeof id === 'string' && source !== undefined) defaults[id] = source + } + } + return { ...record(parsed), numericValues: values, numericDefaults: defaults } +} + +/** Serializes only declared numeric settings verbatim; keys and all other fields stay JSON encoded. */ +export function serializeAgentConfiguration( + body: AgentConfigurationWrite, + numbers: AgentNumberLiterals, +): string { + const { settings, ...fields } = body + const values = Object.entries(settings).map(([key, value]) => { + const source = numbers[key] + if (source !== undefined && (typeof value !== 'number' || !agentNumberLiteral(source))) { + throw new Error('invalid_agent_number') + } + return JSON.stringify(key) + ':' + (source ?? JSON.stringify(value)) + }) + return JSON.stringify(fields).slice(0, -1) + ',"settings":{' + values.join(',') + '}}' +} diff --git a/frontend/src/features/agent-configurations/options-form.tsx b/frontend/src/features/agent-configurations/options-form.tsx index 4b00d5024..bc2f628ba 100644 --- a/frontend/src/features/agent-configurations/options-form.tsx +++ b/frontend/src/features/agent-configurations/options-form.tsx @@ -35,9 +35,7 @@ export function AgentOptionsForm({ /> )} {!editor.body && ( -

- 请完成模型和必填选项;数字需在浏览器可精确表示的范围内。 -

+

请完成模型和必填选项,并检查数字格式。

)}
diff --git a/frontend/src/features/agent-configurations/editor-state.ts b/frontend/src/features/agent-configurations/editor-state.ts index 89e14e7cd..0c15fb809 100644 --- a/frontend/src/features/agent-configurations/editor-state.ts +++ b/frontend/src/features/agent-configurations/editor-state.ts @@ -14,9 +14,30 @@ import { agentConfigurationBody, agentConfigurationDraft, agentConfigurationNumbers, + type AgentConfigurationDraft, type AgentSettingValue, } from './draft' +/** + * Resolves the connection whose write-only credential controls this session shows. + * Global-default plugins persist no connection, so the default draft or an inline + * creation names the target until the user chooses another connection there. + */ +function useCredentialTarget(draft: AgentConfigurationDraft) { + const [focus, setFocus] = useState() + const connections = useModelConnections() + const globalDefault = useModelDefault(draft.usesGlobalDefault) + const connectionId = draft.usesGlobalDefault + ? (focus ?? globalDefault.data?.connectionId) + : draft.connectionId + return { + connections, + globalDefault, + target: connections.data?.find((connection) => connection.id === connectionId), + focus: setFocus, + } +} + /** Input to an editing session; successful writes are the only implicit remount boundary. */ export type AgentEditorProps = { context: AgentConfiguration @@ -34,8 +55,7 @@ export function useAgentEditor({ context, scope, onReload, onSaved }: AgentEdito const [reloadFailed, setReloadFailed] = useState(false) const save = useSaveAgentConfiguration(scope) const clear = useClearAgentConfiguration(scope) - const connections = useModelConnections() - const globalDefault = useModelDefault(draft.usesGlobalDefault) + const { connections, globalDefault, target, focus } = useCredentialTarget(draft) const body = agentConfigurationBody(draft) const busy = save.isPending || clear.isPending const canSave = @@ -68,6 +88,10 @@ export function useAgentEditor({ context, scope, onReload, onSaved }: AgentEdito setDraft((current) => ({ ...current, connectionId, modelId })) } function createdConnection(connection: ModelConnection) { + if (draft.usesGlobalDefault) { + focus(connection.id) + return + } selectConnection(connection.id, connection.models[0]?.id ?? '') } function submit(event: React.FormEvent) { @@ -96,11 +120,13 @@ export function useAgentEditor({ context, scope, onReload, onSaved }: AgentEdito clear, connections, globalDefault, + credentialTarget: target, reload, choose, changeSetting, selectConnection, createdConnection, + followDefaultConnection: focus, submit, clearOptions, } diff --git a/frontend/src/features/agent-configurations/editor.test.tsx b/frontend/src/features/agent-configurations/editor.test.tsx index 5f38ac80e..ddab1fb50 100644 --- a/frontend/src/features/agent-configurations/editor.test.tsx +++ b/frontend/src/features/agent-configurations/editor.test.tsx @@ -3,28 +3,39 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { http, HttpResponse } from 'msw' import { describe, expect, it, vi } from 'vitest' +import type { ModelConnection } from '@/features/model-connections/api' import { server } from '@/test/msw-server' import type { AgentConfiguration } from './api' import { AgentConfigurationEditor } from './editor' -import { agentConnection, agentContext } from './test-fixtures' +import { agentConnection, agentContext, opencodeContext } from './test-fixtures' const PATH = '/api/v1/me/agent-configurations/space1/official/ora-space.claude' const CONNECTIONS = '/api/v1/me/model-connections' +const MODEL_DEFAULT = '/api/v1/me/model-default' -function serveConnections() { - server.use( - http.get(CONNECTIONS, () => HttpResponse.json({ items: [agentConnection()], nextCursor: '' })), - ) +function serveConnections(items: ModelConnection[] = [agentConnection()]) { + server.use(http.get(CONNECTIONS, () => HttpResponse.json({ items, nextCursor: '' }))) } -function mountEditor(context = agentContext()) { - serveConnections() +function mountEditor( + context = agentContext(), + { + pluginId = 'official/ora-space.claude', + connections = [agentConnection()], + }: { pluginId?: string; connections?: ModelConnection[] } = {}, +) { + serveConnections(connections) + server.use( + http.get(MODEL_DEFAULT, () => + HttpResponse.json({ connectionId: 'mc1', modelId: 'vendor/model', version: 2 }), + ), + ) const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) const onSaved = vi.fn<() => void>() const onReload = vi.fn<() => Promise>(async () => {}) - const scope = { spaceId: 'space1', pluginId: 'official/ora-space.claude' } + const scope = { spaceId: 'space1', pluginId } function element(current: AgentConfiguration) { return ( @@ -286,3 +297,60 @@ describe('personal Agent configuration editor', () => { expect(screen.queryByText(secret)).not.toBeInTheDocument() }) }) + +describe('global default credentials', () => { + it('targets the existing default connection although the plugin stores none', async () => { + let cleared = '' + server.use( + http.delete(CONNECTIONS + '/mc1/credential', ({ request }) => { + cleared = request.url + return HttpResponse.json({ resource: agentConnection({ credentialConfigured: false }) }) + }), + ) + mountEditor(opencodeContext(), { pluginId: 'official/ora-space.opencode' }) + const user = userEvent.setup() + expect(await screen.findByText('API Key 已配置', { exact: false })).toBeInTheDocument() + expect(screen.getByText('API Key 属于所选连接:Personal Messages')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: '清除 API Key' })) + await waitFor(() => expect(cleared).toContain('/mc1/credential')) + }) + + it('offers the write-only control when the default connection has no key', async () => { + let submitted: unknown + const keyless = agentConnection({ credentialConfigured: false }) + server.use( + http.put(CONNECTIONS + '/mc1/credential', async ({ request }) => { + submitted = await request.json() + return HttpResponse.json({ resource: agentConnection({ credentialConfigured: true }) }) + }), + ) + mountEditor(opencodeContext(), { + pluginId: 'official/ora-space.opencode', + connections: [keyless], + }) + const user = userEvent.setup() + expect(await screen.findByText('API Key 未配置', { exact: false })).toBeInTheDocument() + await user.type(screen.getByLabelText('API Key'), 'synthetic-key') + await user.click(screen.getByRole('button', { name: '保存 API Key' })) + await waitFor(() => expect(submitted).toEqual({ apiKey: 'synthetic-key', version: 1 })) + }) + + it('follows the connection chosen in the default draft', async () => { + const second = agentConnection({ + id: 'mc2', + name: 'Second Messages', + models: [ + { id: 'vendor/second', name: 'Second Model', contextWindow: 64000, maxTokens: 4096 }, + ], + }) + mountEditor(opencodeContext(), { + pluginId: 'official/ora-space.opencode', + connections: [agentConnection(), second], + }) + const user = userEvent.setup() + expect(await screen.findByText('API Key 属于所选连接:Personal Messages')).toBeInTheDocument() + await user.click(screen.getByRole('combobox', { name: '默认连接' })) + await user.click(await screen.findByRole('option', { name: 'Second Messages' })) + expect(screen.getByText('API Key 属于所选连接:Second Messages')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/agent-configurations/editor.tsx b/frontend/src/features/agent-configurations/editor.tsx index 736cf7fa5..b04ab69e6 100644 --- a/frontend/src/features/agent-configurations/editor.tsx +++ b/frontend/src/features/agent-configurations/editor.tsx @@ -15,7 +15,11 @@ export function AgentConfigurationEditor(props: AgentEditorProps) { 配置只属于当前账号。修改影响之后的新运行;保存成功还需通过运行时的能力和模型检查。

{draft.usesGlobalDefault && globalDefault.data && connections.data && ( - + )} @@ -25,7 +29,7 @@ export function AgentConfigurationEditor(props: AgentEditorProps) { {editor.confirmClear && } connection.id === draft.connectionId)} + selected={editor.credentialTarget} onCreated={editor.createdConnection} /> diff --git a/frontend/src/features/agent-configurations/test-fixtures.ts b/frontend/src/features/agent-configurations/test-fixtures.ts index 8954ae26e..5e0ccf9f6 100644 --- a/frontend/src/features/agent-configurations/test-fixtures.ts +++ b/frontend/src/features/agent-configurations/test-fixtures.ts @@ -19,6 +19,20 @@ export function agentConnection(overrides: Partial = {}): Model } } +/** + * OpenCode context: the plugin stores no connection, so its model comes from the + * user's shared global default and credentials follow that default. + */ +export function opencodeContext(overrides: Partial = {}): AgentConfiguration { + return agentContext({ + pluginId: 'official/ora-space.opencode', + connectionId: null, + modelId: null, + modelDefault: { connectionId: 'mc1', modelId: 'vendor/model', version: 2 }, + ...overrides, + }) +} + /** A fresh compiled declaration/context; callers may emulate another revision without shared state. */ export function agentContext(overrides: Partial = {}): AgentConfiguration { return { diff --git a/frontend/src/features/model-connections/default-form.tsx b/frontend/src/features/model-connections/default-form.tsx index ca6ecef5c..949e66290 100644 --- a/frontend/src/features/model-connections/default-form.tsx +++ b/frontend/src/features/model-connections/default-form.tsx @@ -8,13 +8,19 @@ import { } from './api' import { ConnectionChoice } from './fields' -/** Versioned default selector; only enabled, credential-backed connections are offered. */ +/** + * Versioned default selector; only enabled, credential-backed connections are offered. + * The optional callback reports the connection the draft now points at, so a plugin + * entry can offer credential controls for the same shared default without copying it. + */ export function ModelDefaultForm({ connections, selection, + onConnectionChange, }: { connections: ModelConnection[] selection: ModelDefault + onConnectionChange?: (connectionId: string) => void }) { const ready = connections.filter( (connection) => connection.enabled && connection.credentialConfigured, @@ -66,6 +72,7 @@ export function ModelDefaultForm({ onChange={(id) => { setConnectionId(id) setModelId('') + onConnectionChange?.(id) }} /> Date: Tue, 20 Oct 2026 19:19:33 +0800 Subject: [PATCH 06/11] fix(web): advance the option baseline without rebuilding the editor --- .../agent-configurations/dialog.test.tsx | 167 ++++++++++++++++++ .../features/agent-configurations/dialog.tsx | 16 +- .../agent-configurations/editor-state.ts | 33 +++- .../agent-configurations/editor.test.tsx | 4 +- 4 files changed, 206 insertions(+), 14 deletions(-) create mode 100644 frontend/src/features/agent-configurations/dialog.test.tsx diff --git a/frontend/src/features/agent-configurations/dialog.test.tsx b/frontend/src/features/agent-configurations/dialog.test.tsx new file mode 100644 index 000000000..7cc8fd5f6 --- /dev/null +++ b/frontend/src/features/agent-configurations/dialog.test.tsx @@ -0,0 +1,167 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' +import { installCloudSpaceHandlers, TEST_SPACE_ID } from '@/test/cloud-handlers' +import { server } from '@/test/msw-server' +import { renderWithProviders } from '@/test/render' +import { AgentConfigurationDialog } from './dialog' +import { agentConnection, opencodeContext } from './test-fixtures' + +const PLUGIN = 'official/ora-space.opencode' +const PATH = `/api/v1/me/agent-configurations/${TEST_SPACE_ID}/${PLUGIN}` +const CONNECTIONS = '/api/v1/me/model-connections' +const MODEL_DEFAULT = '/api/v1/me/model-default' + +const SECOND = agentConnection({ + id: 'mc2', + name: 'Second Messages', + models: [{ id: 'vendor/second', name: 'Second Model', contextWindow: 64000, maxTokens: 4096 }], +}) + +/** Mutable server state one dialog test reads back through both the read and write boundaries. */ +type PluginState = { revision: number; cleared: boolean; settings: Record } + +/** + * Serves one plugin with a shared default and two ready connections, so a dialog + * test can separate the plugin-option write from the independent default draft. + */ +function servePlugin(state: PluginState) { + server.use( + http.get(PATH, () => + HttpResponse.json( + opencodeContext({ + spaceId: TEST_SPACE_ID, + revision: state.revision, + cleared: state.cleared, + settings: state.settings, + modelDefault: { connectionId: 'mc1', modelId: 'vendor/model', version: 2 }, + }), + ), + ), + http.get(CONNECTIONS, () => + HttpResponse.json({ items: [agentConnection(), SECOND], nextCursor: '' }), + ), + http.get(MODEL_DEFAULT, () => + HttpResponse.json({ connectionId: 'mc1', modelId: 'vendor/model', version: 2 }), + ), + ) +} + +/** Reads the settings object a submitted write carried, without asserting the wire shape. */ +function submittedSettings(body: unknown): Record { + if (typeof body !== 'object' || body === null || !('settings' in body)) return {} + const settings = body.settings + if (typeof settings !== 'object' || settings === null) return {} + return Object.fromEntries(Object.entries(settings).map(([key, value]) => [key, String(value)])) +} + +function serveWrite(state: PluginState) { + const writes: unknown[] = [] + server.use( + http.put(PATH, async ({ request }) => { + const body: unknown = await request.json() + writes.push(body) + state.revision += 1 + state.cleared = false + state.settings = submittedSettings(body) + return HttpResponse.json({ + resource: { revision: state.revision, cleared: false, settings: state.settings }, + }) + }), + ) + return writes +} + +async function openDialog() { + const user = userEvent.setup() + renderWithProviders(, { slug: 'cloud-dev' }) + await user.click(screen.getByRole('button', { name: '我的配置' })) + await screen.findByRole('combobox', { name: '默认连接' }) + return user +} + +/** Edits only the default draft: neither choice is saved before the scenario acts. */ +async function draftDefaultSelection(user: ReturnType) { + await user.click(screen.getByRole('combobox', { name: '默认连接' })) + await user.click(await screen.findByRole('option', { name: 'Second Messages' })) + await user.click(screen.getByRole('combobox', { name: '默认模型' })) + await user.click(await screen.findByRole('option', { name: 'Second Model (vendor/second)' })) +} + +function expectDefaultDraftPreserved() { + expect(screen.getByRole('combobox', { name: '默认连接' })).toHaveTextContent('Second Messages') + expect(screen.getByRole('combobox', { name: '默认模型' })).toHaveTextContent('Second Model') + expect(screen.getByRole('button', { name: '保存默认模型' })).toBeEnabled() +} +describe('personal Agent configuration dialog', () => { + it('advances the option baseline on save without remounting the default draft', async () => { + installCloudSpaceHandlers('member') + const state = { revision: 1, cleared: false, settings: { style: 'initial' } } + servePlugin(state) + const writes = serveWrite(state) + const defaults: unknown[] = [] + server.use( + http.put(MODEL_DEFAULT, async ({ request }) => { + defaults.push(await request.json()) + return HttpResponse.json({ connectionId: 'mc2', modelId: 'vendor/second', version: 3 }) + }), + ) + const user = await openDialog() + await draftDefaultSelection(user) + await user.type(screen.getByLabelText('风格 *'), ' from the dialog') + await user.click(screen.getByRole('button', { name: '保存我的配置' })) + await waitFor(() => expect(writes).toHaveLength(1)) + expect(writes[0]).toEqual( + expect.objectContaining({ + revision: 1, + settings: expect.objectContaining({ style: 'initial from the dialog' }), + }), + ) + + expectDefaultDraftPreserved() + await user.click(screen.getByRole('button', { name: '保存默认模型' })) + await waitFor(() => + expect(defaults).toEqual([{ connectionId: 'mc2', modelId: 'vendor/second', version: 2 }]), + ) + + await user.click(screen.getByRole('button', { name: '保存我的配置' })) + await waitFor(() => expect(writes).toHaveLength(2)) + expect(writes[1]).toEqual(expect.objectContaining({ revision: 2 })) + }) + + it('advances the option baseline on clear without remounting the default draft', async () => { + installCloudSpaceHandlers('member') + const state: PluginState = { revision: 1, cleared: false, settings: { style: 'initial' } } + servePlugin(state) + const writes = serveWrite(state) + const removals: unknown[] = [] + server.use( + http.delete(PATH, async ({ request }) => { + removals.push(await request.json()) + state.revision += 1 + state.cleared = true + state.settings = {} + return HttpResponse.json({ + resource: { revision: state.revision, cleared: true, settings: {} }, + }) + }), + ) + const user = await openDialog() + await draftDefaultSelection(user) + await user.click(screen.getByRole('button', { name: '清除个人选项' })) + await user.click(screen.getByRole('button', { name: '确认清除' })) + await waitFor(() => expect(removals).toEqual([{ revision: 1 }])) + + expectDefaultDraftPreserved() + await user.type(screen.getByLabelText('风格 *'), 'after clearing') + await user.click(screen.getByRole('button', { name: '保存我的配置' })) + await waitFor(() => expect(writes).toHaveLength(1)) + expect(writes[0]).toEqual( + expect.objectContaining({ + revision: 2, + settings: expect.objectContaining({ style: 'after clearing' }), + }), + ) + }) +}) diff --git a/frontend/src/features/agent-configurations/dialog.tsx b/frontend/src/features/agent-configurations/dialog.tsx index c40a3f7ba..7ed4c5706 100644 --- a/frontend/src/features/agent-configurations/dialog.tsx +++ b/frontend/src/features/agent-configurations/dialog.tsx @@ -12,6 +12,7 @@ import { useCurrentSpace } from '@/features/spaces/current-space' import { agentConfigurationFailure, useAgentConfiguration, + type AgentConfiguration, type AgentConfigurationScope, } from './api' import { AgentConfigurationEditor } from './editor' @@ -42,21 +43,22 @@ export function AgentConfigurationDialog({ pluginId }: { pluginId: string }) { function ConfigurationContent({ scope }: { scope: AgentConfigurationScope }) { const query = useAgentConfiguration(scope, true) - const [editor, setEditor] = useState(0) + async function readLatest(): Promise { + const result = await query.refetch() + if (result.error) throw result.error + if (!result.data) throw new Error('agent_configuration_unavailable') + return result.data + } if (!query.data) { if (query.isError) return

{agentConfigurationFailure(query.error)}

return

正在读取个人配置…

} return ( setEditor((current) => current + 1)} - onReload={async () => { - const result = await query.refetch() - if (result.error) throw result.error - }} + onSaved={readLatest} + onReload={readLatest} /> ) } diff --git a/frontend/src/features/agent-configurations/editor-state.ts b/frontend/src/features/agent-configurations/editor-state.ts index 0c15fb809..0bf79b09d 100644 --- a/frontend/src/features/agent-configurations/editor-state.ts +++ b/frontend/src/features/agent-configurations/editor-state.ts @@ -38,12 +38,15 @@ function useCredentialTarget(draft: AgentConfigurationDraft) { } } -/** Input to an editing session; successful writes are the only implicit remount boundary. */ +/** + * Input to an editing session. The dialog owns the read query, so both callbacks + * resolve with the freshest authoritative context instead of triggering a remount. + */ export type AgentEditorProps = { context: AgentConfiguration scope: AgentConfigurationScope - onReload: () => Promise - onSaved: () => void + onReload: () => Promise + onSaved: () => Promise } /** Owns draft baselines and explicit reconciliation without copying live server state. */ @@ -94,16 +97,36 @@ export function useAgentEditor({ context, scope, onReload, onSaved }: AgentEdito } selectConnection(connection.id, connection.models[0]?.id ?? '') } + /** + * Advances only this session's option baseline. Values stay as submitted (or fall back + * to defaults after a clear), and the default-model draft keeps its own state and CAS + * version, so an unsaved default selection never silently reverts. + */ + async function adoptWrite(written: { revision: number; cleared: boolean }) { + setDraft((current) => ({ ...current, revision: written.revision, requiresRepair: false })) + try { + const latest = await onSaved() + setDraft((current) => agentConfigurationDraft(latest, written.cleared ? undefined : current)) + setAcceptedRepair(false) + setConfirmClear(false) + setReloadFailed(false) + } catch { + setReloadFailed(true) + } + } function submit(event: React.FormEvent) { event.preventDefault() if (canSave && body) save.mutate( { body, repair: draft.requiresRepair, numbers: agentConfigurationNumbers(draft) }, - { onSuccess: onSaved }, + { onSuccess: (written) => void adoptWrite(written.resource) }, ) } function clearOptions() { - clear.mutate({ revision: draft.revision }, { onSuccess: onSaved }) + clear.mutate( + { revision: draft.revision }, + { onSuccess: (written) => void adoptWrite(written.resource) }, + ) } return { draft, diff --git a/frontend/src/features/agent-configurations/editor.test.tsx b/frontend/src/features/agent-configurations/editor.test.tsx index ddab1fb50..671b67f05 100644 --- a/frontend/src/features/agent-configurations/editor.test.tsx +++ b/frontend/src/features/agent-configurations/editor.test.tsx @@ -33,8 +33,8 @@ function mountEditor( const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) - const onSaved = vi.fn<() => void>() - const onReload = vi.fn<() => Promise>(async () => {}) + const onSaved = vi.fn<() => Promise>(async () => context) + const onReload = vi.fn<() => Promise>(async () => context) const scope = { spaceId: 'space1', pluginId } function element(current: AgentConfiguration) { return ( From 73d82965b2a7ece2090bf8ed35b412395c37c907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:19:37 +0800 Subject: [PATCH 07/11] docs(web): describe credential targeting and in-place baseline adoption --- .../src/features/agent-configurations/README.en.md | 14 +++++++------- .../src/features/agent-configurations/README.md | 14 +++++++------- .../src/features/model-connections/README.en.md | 4 ++-- frontend/src/features/model-connections/README.md | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/frontend/src/features/agent-configurations/README.en.md b/frontend/src/features/agent-configurations/README.en.md index 05be4fcb4..4c1404f5e 100644 --- a/frontend/src/features/agent-configurations/README.en.md +++ b/frontend/src/features/agent-configurations/README.en.md @@ -9,16 +9,16 @@ Owns My Configuration for the space's selected Agent release: non-secret options | `api.ts` | Personally scoped queries, versioned writes and bounded failure messages | | `draft.ts` | Typed options, defaults, edit baselines and removed-field comparison | | `numbers.ts` | Numeric response literals and exact request serialization for integers and decimals | -| `dialog.tsx` | Member-accessible entry that reads only when opened | -| `editor-state.ts` | Editing sessions, explicit conflict reconciliation and clear confirmation | +| `dialog.tsx` | Member-accessible entry that reads only when opened and returns the latest context after a write | +| `editor-state.ts` | Editing sessions, explicit conflict reconciliation, clear confirmation and the credential target | | `editor.tsx` | Composition of options and separate connection forms | | `options-form.tsx` / `setting-fields.tsx` | Typed fields and repair/clear actions | | `reconcile.tsx` | Latest-value comparison with retained drafts or server adoption | -| `connections.tsx` | Protocol-compatible model choices, inline creation and credential forms | -| `*.test.*` / `test-fixtures.ts` | Synthetic contexts, option and HTTP form behavior tests | +| `connections.tsx` | Protocol-compatible model choices, inline creation and the credential form of the selected connection | +| `dialog.test.tsx` / `*.test.*` / `test-fixtures.ts` | Production-dialog, option and HTTP form behavior tests with synthetic contexts | -Depends on generated clients, `spaces`, `model-connections`, `lib` and UI primitives; consumed by `plugins`. Lower layers must not depend on it. Connections and credentials reuse the personal library. OpenCode reuses the global default without copying model references into plugin options. +Depends on generated clients, `spaces`, `model-connections`, `lib` and UI primitives; consumed by `plugins`. Lower layers must not depend on it. Connections and credentials reuse the personal library. OpenCode reuses the global default without copying model references into plugin options, and its credential controls follow the connection chosen in that default draft. -Edit baselines advance only after a successful write or explicit reconciliation. Background refreshes and conflicts retain drafts. Declaration changes require confirmed repair; unknown fields are never silently migrated or discarded. Clearing options does not delete connections. API keys use the existing write-only boundary and never enter configuration requests, query/mutation caches or persistent storage. Forms are siblings. +Edit baselines advance only after a successful write or explicit reconciliation; a write advances the option baseline in place from the freshly read context instead of rebuilding the editing session, so the independent default-model draft and its CAS version never revert silently. Background refreshes and conflicts retain drafts. Declaration changes require confirmed repair; unknown fields are never silently migrated or discarded. Clearing options does not delete connections. API keys use the existing write-only boundary and never enter configuration requests, query/mutation caches or persistent storage. Forms are siblings. -Tests use production generated clients and MSW for types, repair, conflicts, inline connections and credential isolation. The `plugins` tests cover lazy reading through the member entry. +Tests use production generated clients and MSW for types, repair, conflicts, inline connections and credential isolation. `dialog.test.tsx` drives the production dialog to prove that save and clear advance only the option baseline, and the `plugins` tests cover lazy reading through the member entry. diff --git a/frontend/src/features/agent-configurations/README.md b/frontend/src/features/agent-configurations/README.md index 1e96b2718..3e10b1ae5 100644 --- a/frontend/src/features/agent-configurations/README.md +++ b/frontend/src/features/agent-configurations/README.md @@ -9,16 +9,16 @@ | `api.ts` | 个人作用域查询、版本化写入与有界故障文案 | | `draft.ts` | 类型化选项、默认值、编辑基线与移除字段核对 | | `numbers.ts` | 响应数值原文与精确请求序列化,保留大整数和高精度小数 | -| `dialog.tsx` | 普通成员可用、打开时读取的入口 | -| `editor-state.ts` | 编辑会话、冲突后显式核对与清除确认 | +| `dialog.tsx` | 普通成员可用、打开时读取,并在写入后取回最新上下文交给编辑会话 | +| `editor-state.ts` | 编辑会话、冲突后显式核对、清除确认与凭据目标 | | `editor.tsx` | 选项与独立连接表单的组合 | | `options-form.tsx` / `setting-fields.tsx` | 类型化字段与修复/清除操作 | | `reconcile.tsx` | 最新值比较、保留草稿或采用服务器值 | -| `connections.tsx` | 匹配协议的模型选择、内联创建与凭据表单 | -| `*.test.*` / `test-fixtures.ts` | 合成上下文、选项与 HTTP 表单行为测试 | +| `connections.tsx` | 匹配协议的模型选择、内联创建与按所选连接展示的凭据表单 | +| `dialog.test.tsx` / `*.test.*` / `test-fixtures.ts` | 生产对话框、选项与 HTTP 表单行为测试,含合成上下文 | -依赖生成客户端、`spaces`、`model-connections`、`lib` 与 UI 原语;由 `plugins` 消费,下层不得反向依赖。连接与凭据沿用个人连接库;OpenCode 默认模型沿用全局默认选择,不在插件配置中复制引用。 +依赖生成客户端、`spaces`、`model-connections`、`lib` 与 UI 原语;由 `plugins` 消费,下层不得反向依赖。连接与凭据沿用个人连接库;OpenCode 默认模型沿用全局默认选择,不在插件配置中复制引用,其凭据入口跟随默认草稿所选连接。 -编辑基线只在成功写入或用户明确核对时推进。背景刷新与 409 保留草稿;声明变化必须确认修复,不自动迁移或丢弃未知字段。清除个人选项不删除连接。API Key 只经过复用的写入边界,不进入配置请求、查询/变更缓存或持久存储。所有表单互为同级。 +编辑基线只在成功写入或用户明确核对时推进;写入后以最新读取的上下文原地推进选项基线,不重建编辑会话,因此独立的默认模型草稿及其 CAS 版本不会无提示回退。背景刷新与 409 保留草稿;声明变化必须确认修复,不自动迁移或丢弃未知字段。清除个人选项不删除连接。API Key 只经过复用的写入边界,不进入配置请求、查询/变更缓存或持久存储。所有表单互为同级。 -测试使用真实生成客户端和 MSW,覆盖类型、修复、版本冲突、内联连接与凭据隔离;成员入口的延迟读取由 `plugins` 测试覆盖。 +测试使用真实生成客户端和 MSW,覆盖类型、修复、版本冲突、内联连接与凭据隔离;`dialog.test.tsx` 用生产对话框验证保存和清除只推进选项基线,`plugins` 测试覆盖成员入口的延迟读取。 diff --git a/frontend/src/features/model-connections/README.en.md b/frontend/src/features/model-connections/README.en.md index 1f27b642d..215e59e2a 100644 --- a/frontend/src/features/model-connections/README.en.md +++ b/frontend/src/features/model-connections/README.en.md @@ -12,13 +12,13 @@ The signed-in user's private multi-connection, multi-model settings and default | `fields.tsx` | Labelled metadata inputs and bounded choices | | `connection-form.tsx` | Metadata draft for two protocols, authentication modes and removable model rows | | `credential-form.tsx` | Password input, immediate clearing before submission and safe outcome text | -| `default-form.tsx` | Offers only enabled connections with credentials and their models | +| `default-form.tsx` | Offers only enabled connections with credentials and their models, and reports the draft's connection so a plugin entry can follow it | | `model-connections-page.tsx` | List, editing, deletion confirmation and page composition | | `model-connections-page.test.tsx` | Production generated-client/MSW tests for forms, requests, versions and credential cache exclusion | ## Dependencies and invariants -Depends on generated `api/me`, idempotency helpers from `features/spaces/api`, fault/pagination adapters in `lib`, and UI primitives. Application routes and the personal entry in `agent-configurations` consume it; connection forms can return safe metadata after saving. Lower layers must not depend on it. TanStack Query owns server state. Default-model drafts retain their editing version across refreshes and conflicts; explicit reconciliation advances that baseline. Slash-containing model identifiers remain unchanged. Secret inputs are never prefilled or persisted in browser storage and never enter TanStack mutation variables. Inputs clear before submission, and HTTP failures retain only safe fault messages, avoiding Axios request configuration containing the original key. Disabled controls never substitute for server authorization. +Depends on generated `api/me`, idempotency helpers from `features/spaces/api`, fault/pagination adapters in `lib`, and UI primitives. Application routes and the personal entry in `agent-configurations` consume it; connection forms can return safe metadata after saving. Lower layers must not depend on it. TanStack Query owns server state. Default-model drafts retain their editing version across refreshes and conflicts; explicit reconciliation advances that baseline. The default is a single shared resource: the plugin entry learns from a callback which connection the draft points at and offers that connection's credential controls without copying the selection. Slash-containing model identifiers remain unchanged. Secret inputs are never prefilled or persisted in browser storage and never enter TanStack mutation variables. Inputs clear before submission, and HTTP failures retain only safe fault messages, avoiding Axios request configuration containing the original key. Disabled controls never substitute for server authorization. Credential PUT also requires an idempotency key. The shared adapter generates it from safe connection/version metadata without retaining the secret as idempotency variables. diff --git a/frontend/src/features/model-connections/README.md b/frontend/src/features/model-connections/README.md index 75a611cc0..1d9a1c58e 100644 --- a/frontend/src/features/model-connections/README.md +++ b/frontend/src/features/model-connections/README.md @@ -12,13 +12,13 @@ | `fields.tsx` | 有标签的元数据输入和有限选项选择 | | `connection-form.tsx` | 两种协议、认证方式、可增删模型行的元数据草稿 | | `credential-form.tsx` | 密码输入、发送前清空与安全结果文案 | -| `default-form.tsx` | 只提供已启用且有凭据的连接和其中模型 | +| `default-form.tsx` | 只提供已启用且有凭据的连接和其中模型,并回传草稿所选连接供插件入口跟随 | | `model-connections-page.tsx` | 列表、编辑、删除确认与页面组合 | | `model-connections-page.test.tsx` | 通过生产生成客户端和 MSW 验证表单、请求、版本与凭据不进入缓存 | ## 依赖与不变量 -依赖生成的 `api/me`、`features/spaces/api` 的幂等键、`lib` 的故障/分页适配、UI 原语;由应用路由和 `agent-configurations` 的个人入口消费,连接表单可在保存后回传安全元数据。下层不得反向依赖本模块。服务端状态保留在 TanStack Query;默认模型保留编辑时版本,背景刷新和冲突不重置草稿,明确核对后才采用新版本。模型标识中的 `/` 原样发送。密钥输入不回填、不写入浏览器持久存储、不进入 TanStack mutation variables;提交时清空输入,HTTP 拒绝只保留白名单故障文案,避免 Axios 请求配置中的原始密钥被缓存。界面的禁用状态不替代服务器授权。 +依赖生成的 `api/me`、`features/spaces/api` 的幂等键、`lib` 的故障/分页适配、UI 原语;由应用路由和 `agent-configurations` 的个人入口消费,连接表单可在保存后回传安全元数据。下层不得反向依赖本模块。服务端状态保留在 TanStack Query;默认模型保留编辑时版本,背景刷新和冲突不重置草稿,明确核对后才采用新版本。默认模型只有一份共享资源:插件入口通过回调得知草稿当前指向的连接,据此提供该连接的凭据控件,不复制默认选择。模型标识中的 `/` 原样发送。密钥输入不回填、不写入浏览器持久存储、不进入 TanStack mutation variables;提交时清空输入,HTTP 拒绝只保留白名单故障文案,避免 Axios 请求配置中的原始密钥被缓存。界面的禁用状态不替代服务器授权。 凭据 PUT 也必须带幂等键;该键通过共享适配器以连接/版本元数据生成,不保留密钥作为幂等变量。 From 02eed4391da41e48e4947db5efbe9717c71a6de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=BE=99=E5=AE=89?= <297888591+wanglongan587@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:58:42 +0800 Subject: [PATCH 08/11] fix(web): keep credential targeting on the default edit baseline --- .../agent-configurations/editor-state.ts | 4 +++ .../agent-configurations/editor.test.tsx | 29 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/agent-configurations/editor-state.ts b/frontend/src/features/agent-configurations/editor-state.ts index 0bf79b09d..99752eabd 100644 --- a/frontend/src/features/agent-configurations/editor-state.ts +++ b/frontend/src/features/agent-configurations/editor-state.ts @@ -27,6 +27,10 @@ function useCredentialTarget(draft: AgentConfigurationDraft) { const [focus, setFocus] = useState() const connections = useModelConnections() const globalDefault = useModelDefault(draft.usesGlobalDefault) + // This editing baseline is captured when the default form can first mount; later reads keep its draft. + if (draft.usesGlobalDefault && focus === undefined && connections.data && globalDefault.data) { + setFocus(globalDefault.data.connectionId) + } const connectionId = draft.usesGlobalDefault ? (focus ?? globalDefault.data?.connectionId) : draft.connectionId diff --git a/frontend/src/features/agent-configurations/editor.test.tsx b/frontend/src/features/agent-configurations/editor.test.tsx index 671b67f05..695d61034 100644 --- a/frontend/src/features/agent-configurations/editor.test.tsx +++ b/frontend/src/features/agent-configurations/editor.test.tsx @@ -1,9 +1,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { http, HttpResponse } from 'msw' import { describe, expect, it, vi } from 'vitest' import type { ModelConnection } from '@/features/model-connections/api' +import { getGetApiV1MeModelDefaultQueryKey } from '@/api/me/me' import { server } from '@/test/msw-server' import type { AgentConfiguration } from './api' import { AgentConfigurationEditor } from './editor' @@ -299,6 +300,32 @@ describe('personal Agent configuration editor', () => { }) describe('global default credentials', () => { + it('keeps credentials on the mounted default draft when the server default changes in the background', async () => { + const second = agentConnection({ id: 'mc2', name: 'Second Messages' }) + const page = mountEditor(opencodeContext(), { + pluginId: 'official/ora-space.opencode', + connections: [agentConnection(), second], + }) + await screen.findByText('API Key 属于所选连接:Personal Messages') + server.use( + http.get(MODEL_DEFAULT, () => + HttpResponse.json({ + connectionId: 'mc2', + modelId: 'vendor/model', + version: 3, + }), + ), + ) + await act(async () => { + await page.client.invalidateQueries({ queryKey: getGetApiV1MeModelDefaultQueryKey() }) + }) + expect(screen.getByRole('combobox', { name: '默认连接' })).toHaveTextContent( + 'Personal Messages', + ) + expect(await screen.findByText('当前默认:Second Messages · Private Model')).toBeInTheDocument() + expect(screen.getByText('API Key 属于所选连接:Personal Messages')).toBeInTheDocument() + }) + it('targets the existing default connection although the plugin stores none', async () => { let cleared = '' server.use( From 1c749954c6b1b068139af58cf1c78e4c2fb7dd51 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 02:34:46 +0000 Subject: [PATCH 09/11] fix(web): adopt only the revision a save confirmed A save advanced the option baseline to whatever revision the follow-up read returned, so a retained draft could be paired with another editor's later revision and overwrite it on the next save. Adopt the read in place only when it is the confirmed revision in the same release context; otherwise keep the draft and require explicit reconciliation. --- .../agent-configurations/README.en.md | 4 +- .../features/agent-configurations/README.md | 4 +- .../agent-configurations/dialog.test.tsx | 40 +++++++++++++++++++ .../agent-configurations/editor-state.ts | 18 +++++++-- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/agent-configurations/README.en.md b/frontend/src/features/agent-configurations/README.en.md index 4c1404f5e..17ac89055 100644 --- a/frontend/src/features/agent-configurations/README.en.md +++ b/frontend/src/features/agent-configurations/README.en.md @@ -19,6 +19,6 @@ Owns My Configuration for the space's selected Agent release: non-secret options Depends on generated clients, `spaces`, `model-connections`, `lib` and UI primitives; consumed by `plugins`. Lower layers must not depend on it. Connections and credentials reuse the personal library. OpenCode reuses the global default without copying model references into plugin options, and its credential controls follow the connection chosen in that default draft. -Edit baselines advance only after a successful write or explicit reconciliation; a write advances the option baseline in place from the freshly read context instead of rebuilding the editing session, so the independent default-model draft and its CAS version never revert silently. Background refreshes and conflicts retain drafts. Declaration changes require confirmed repair; unknown fields are never silently migrated or discarded. Clearing options does not delete connections. API keys use the existing write-only boundary and never enter configuration requests, query/mutation caches or persistent storage. Forms are siblings. +Edit baselines advance only after a successful write or explicit reconciliation. A write advances the option baseline only to the revision its response confirmed: when the follow-up read is that revision in the same release context it is adopted in place instead of rebuilding the editing session, so the independent default-model draft and its CAS version never revert silently; when the read shows another editor's later write or a changed selection, the draft is retained and must be reconciled explicitly, never paired automatically with the newer revision. Background refreshes and conflicts retain drafts. Declaration changes require confirmed repair; unknown fields are never silently migrated or discarded. Clearing options does not delete connections. API keys use the existing write-only boundary and never enter configuration requests, query/mutation caches or persistent storage. Forms are siblings. -Tests use production generated clients and MSW for types, repair, conflicts, inline connections and credential isolation. `dialog.test.tsx` drives the production dialog to prove that save and clear advance only the option baseline, and the `plugins` tests cover lazy reading through the member entry. +Tests use production generated clients and MSW for types, repair, conflicts, inline connections and credential isolation. `dialog.test.tsx` drives the production dialog to prove that save and clear advance only the option baseline and that a concurrent write seen after a save requires reconciliation, and the `plugins` tests cover lazy reading through the member entry. diff --git a/frontend/src/features/agent-configurations/README.md b/frontend/src/features/agent-configurations/README.md index 3e10b1ae5..74976106d 100644 --- a/frontend/src/features/agent-configurations/README.md +++ b/frontend/src/features/agent-configurations/README.md @@ -19,6 +19,6 @@ 依赖生成客户端、`spaces`、`model-connections`、`lib` 与 UI 原语;由 `plugins` 消费,下层不得反向依赖。连接与凭据沿用个人连接库;OpenCode 默认模型沿用全局默认选择,不在插件配置中复制引用,其凭据入口跟随默认草稿所选连接。 -编辑基线只在成功写入或用户明确核对时推进;写入后以最新读取的上下文原地推进选项基线,不重建编辑会话,因此独立的默认模型草稿及其 CAS 版本不会无提示回退。背景刷新与 409 保留草稿;声明变化必须确认修复,不自动迁移或丢弃未知字段。清除个人选项不删除连接。API Key 只经过复用的写入边界,不进入配置请求、查询/变更缓存或持久存储。所有表单互为同级。 +编辑基线只在成功写入或用户明确核对时推进。写入只把选项基线推进到写响应确认的版本:随后读取的仍是该版本且发布上下文未变时原地采用,不重建编辑会话,因此独立的默认模型草稿及其 CAS 版本不会无提示回退;读到其他编辑器的后续写入或选用变化时保留草稿并要求显式核对,不把新版本号与旧草稿自动拼接。背景刷新与 409 保留草稿;声明变化必须确认修复,不自动迁移或丢弃未知字段。清除个人选项不删除连接。API Key 只经过复用的写入边界,不进入配置请求、查询/变更缓存或持久存储。所有表单互为同级。 -测试使用真实生成客户端和 MSW,覆盖类型、修复、版本冲突、内联连接与凭据隔离;`dialog.test.tsx` 用生产对话框验证保存和清除只推进选项基线,`plugins` 测试覆盖成员入口的延迟读取。 +测试使用真实生成客户端和 MSW,覆盖类型、修复、版本冲突、内联连接与凭据隔离;`dialog.test.tsx` 用生产对话框验证保存和清除只推进选项基线、保存后读到并发写入时要求核对,`plugins` 测试覆盖成员入口的延迟读取。 diff --git a/frontend/src/features/agent-configurations/dialog.test.tsx b/frontend/src/features/agent-configurations/dialog.test.tsx index 7cc8fd5f6..22d7f8052 100644 --- a/frontend/src/features/agent-configurations/dialog.test.tsx +++ b/frontend/src/features/agent-configurations/dialog.test.tsx @@ -130,6 +130,46 @@ describe('personal Agent configuration dialog', () => { expect(writes[1]).toEqual(expect.objectContaining({ revision: 2 })) }) + it('requires reconciliation when the read after a save shows a later write by another editor', async () => { + installCloudSpaceHandlers('member') + const state: PluginState = { revision: 1, cleared: false, settings: { style: 'initial' } } + servePlugin(state) + const writes: unknown[] = [] + server.use( + http.put(PATH, async ({ request }) => { + const body: unknown = await request.json() + writes.push(body) + const confirmed = state.revision + 1 + // Another editor's save lands between this write's response and the follow-up read. + state.revision = confirmed + 1 + state.settings = { style: 'other editor' } + return HttpResponse.json({ + resource: { revision: confirmed, cleared: false, settings: submittedSettings(body) }, + }) + }), + ) + const user = await openDialog() + await user.type(screen.getByLabelText('风格 *'), ' my edit') + await user.click(screen.getByRole('button', { name: '保存我的配置' })) + await waitFor(() => expect(writes).toHaveLength(1)) + + expect(await screen.findByRole('heading', { name: '最新保存版本 3' })).toBeInTheDocument() + expect(screen.getByText('other editor')).toBeInTheDocument() + expect(screen.getByLabelText('风格 *')).toHaveValue('initial my edit') + expect(screen.getByRole('button', { name: '保存我的配置' })).toBeDisabled() + + await user.click(screen.getByRole('button', { name: '采用服务器配置' })) + expect(screen.getByLabelText('风格 *')).toHaveValue('other editor') + await user.click(screen.getByRole('button', { name: '保存我的配置' })) + await waitFor(() => expect(writes).toHaveLength(2)) + expect(writes[1]).toEqual( + expect.objectContaining({ + revision: 3, + settings: expect.objectContaining({ style: 'other editor' }), + }), + ) + }) + it('advances the option baseline on clear without remounting the default draft', async () => { installCloudSpaceHandlers('member') const state: PluginState = { revision: 1, cleared: false, settings: { style: 'initial' } } diff --git a/frontend/src/features/agent-configurations/editor-state.ts b/frontend/src/features/agent-configurations/editor-state.ts index 99752eabd..7f452c562 100644 --- a/frontend/src/features/agent-configurations/editor-state.ts +++ b/frontend/src/features/agent-configurations/editor-state.ts @@ -102,15 +102,25 @@ export function useAgentEditor({ context, scope, onReload, onSaved }: AgentEdito selectConnection(connection.id, connection.models[0]?.id ?? '') } /** - * Advances only this session's option baseline. Values stay as submitted (or fall back - * to defaults after a clear), and the default-model draft keeps its own state and CAS - * version, so an unsaved default selection never silently reverts. + * Advances only this session's option baseline, and only to the revision this write + * confirmed. The follow-up read is adopted in place when it is that revision in the same + * release context; values stay as submitted (or fall back to defaults after a clear). A + * read showing another editor's later write or a changed selection keeps the draft on the + * confirmed revision and requires an explicit reconciliation, so a retained draft never + * gains a newer CAS revision it was not compared against. The default-model draft keeps + * its own state and CAS version, so an unsaved default selection never silently reverts. */ async function adoptWrite(written: { revision: number; cleared: boolean }) { setDraft((current) => ({ ...current, revision: written.revision, requiresRepair: false })) try { const latest = await onSaved() - setDraft((current) => agentConfigurationDraft(latest, written.cleared ? undefined : current)) + const confirmed = + latest.revision === written.revision && + latest.selectionVersion === draft.selectionVersion && + latest.declaration.fingerprint === draft.fingerprint + if (confirmed) + setDraft((current) => agentConfigurationDraft(latest, written.cleared ? undefined : current)) + setComparing(!confirmed) setAcceptedRepair(false) setConfirmClear(false) setReloadFailed(false) From 4340f0b526003e211fa49cd155c0a9c1fe6ae969 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 02:39:35 +0000 Subject: [PATCH 10/11] style(web): format the confirmed-revision adoption --- frontend/src/features/agent-configurations/editor-state.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/agent-configurations/editor-state.ts b/frontend/src/features/agent-configurations/editor-state.ts index 7f452c562..d015bfff8 100644 --- a/frontend/src/features/agent-configurations/editor-state.ts +++ b/frontend/src/features/agent-configurations/editor-state.ts @@ -119,7 +119,9 @@ export function useAgentEditor({ context, scope, onReload, onSaved }: AgentEdito latest.selectionVersion === draft.selectionVersion && latest.declaration.fingerprint === draft.fingerprint if (confirmed) - setDraft((current) => agentConfigurationDraft(latest, written.cleared ? undefined : current)) + setDraft((current) => + agentConfigurationDraft(latest, written.cleared ? undefined : current), + ) setComparing(!confirmed) setAcceptedRepair(false) setConfirmClear(false) From 2c76a574735f2fc80345d0d4c9f709a5dfb0e864 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Wed, 21 Oct 2026 02:41:19 +0000 Subject: [PATCH 11/11] refactor(web): name the confirmed-write check outside the editor hook Lifting the write confirmation and setting update into module helpers keeps useAgentEditor within the 100-line function limit without changing behavior. --- .../agent-configurations/editor-state.ts | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/frontend/src/features/agent-configurations/editor-state.ts b/frontend/src/features/agent-configurations/editor-state.ts index d015bfff8..ff4749746 100644 --- a/frontend/src/features/agent-configurations/editor-state.ts +++ b/frontend/src/features/agent-configurations/editor-state.ts @@ -42,6 +42,34 @@ function useCredentialTarget(draft: AgentConfigurationDraft) { } } +/** + * Whether a read after a write shows exactly the revision that write confirmed, in the + * release context the draft was edited against. Anything else is another editor's write or + * a changed selection the user has not compared their draft with. + */ +function confirmsWrite( + latest: AgentConfiguration, + revision: number, + draft: AgentConfigurationDraft, +) { + return ( + latest.revision === revision && + latest.selectionVersion === draft.selectionVersion && + latest.declaration.fingerprint === draft.fingerprint + ) +} + +function withSetting( + draft: AgentConfigurationDraft, + id: string, + value: AgentSettingValue | undefined, +): AgentConfigurationDraft { + const values = { ...draft.values } + if (value === undefined) delete values[id] + else values[id] = value + return { ...draft, values } +} + /** * Input to an editing session. The dialog owns the read query, so both callbacks * resolve with the freshest authoritative context instead of triggering a remount. @@ -84,12 +112,7 @@ export function useAgentEditor({ context, scope, onReload, onSaved }: AgentEdito clear.reset() } function changeSetting(id: string, value: AgentSettingValue | undefined) { - setDraft((current) => { - const values = { ...current.values } - if (value === undefined) delete values[id] - else values[id] = value - return { ...current, values } - }) + setDraft((current) => withSetting(current, id, value)) } function selectConnection(connectionId: string, modelId: string) { setDraft((current) => ({ ...current, connectionId, modelId })) @@ -114,10 +137,7 @@ export function useAgentEditor({ context, scope, onReload, onSaved }: AgentEdito setDraft((current) => ({ ...current, revision: written.revision, requiresRepair: false })) try { const latest = await onSaved() - const confirmed = - latest.revision === written.revision && - latest.selectionVersion === draft.selectionVersion && - latest.declaration.fingerprint === draft.fingerprint + const confirmed = confirmsWrite(latest, written.revision, draft) if (confirmed) setDraft((current) => agentConfigurationDraft(latest, written.cleared ? undefined : current),