Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4904,6 +4904,19 @@
"nullable": true,
"type": "string"
},
"kind": {
"enum": [
"workbench",
"agent",
"webview",
"skill",
"mcp",
"hook",
"pack",
"workflow"
],
"type": "string"
},
"observedState": {
"enum": [
"pending",
Expand Down
2 changes: 2 additions & 0 deletions docs/plugins/marketplace.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/plugins/marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Cloud server 自己维护目录(`internal/pluginmarket`),启动时同步
- 选择被接受时,Cloud 为当时**已经存在**的每个存活运行时写一行待处理的实例;之后新建的运行时不会被追加到这次维护里,而是在自己创建或启动时
补装(见下文)。空间里没有存活运行时时,选择立即视为完成。
- 汇总只给出受影响、已完成、等待启动等计数,不暴露别的运行时的失败输出。
- 汇总的可选 `kind` 来自空间选定的 release,不从最新目录推断。网页据此给已选 Agent 提供「我的配置」;当前成员可配置自己的值,空间安装/移除仍需管理员权限。

## 运行时里的执行

Expand Down
15 changes: 15 additions & 0 deletions frontend/src/api/generated.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2178,6 +2178,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];


Expand All @@ -2202,6 +2216,7 @@ export interface SpacePlugin {
identifier: string;
/** @nullable */
installError: string | null;
kind?: SpacePluginKind;
observedState: SpacePluginObservedState;
/** @nullable */
observedVersion: string | null;
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/features/agent-configurations/README.en.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 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 |
| `numbers.ts` | Numeric response literals and exact request serialization for integers and decimals |
| `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 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, 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 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 that a concurrent write seen after a save requires reconciliation, and the `plugins` tests cover lazy reading through the member entry.
24 changes: 24 additions & 0 deletions frontend/src/features/agent-configurations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# agent-configurations:插件个人配置

[中文](README.md) | [English](README.en.md)

负责空间已选 Agent 版本的「我的配置」:非秘密选项、个人连接选择与显式修复。空间选择、运行启动及协议转换由各自模块负责。

| 文件 | 职责 |
| --- | --- |
| `api.ts` | 个人作用域查询、版本化写入与有界故障文案 |
| `draft.ts` | 类型化选项、默认值、编辑基线与移除字段核对 |
| `numbers.ts` | 响应数值原文与精确请求序列化,保留大整数和高精度小数 |
| `dialog.tsx` | 普通成员可用、打开时读取,并在写入后取回最新上下文交给编辑会话 |
| `editor-state.ts` | 编辑会话、冲突后显式核对、清除确认与凭据目标 |
| `editor.tsx` | 选项与独立连接表单的组合 |
| `options-form.tsx` / `setting-fields.tsx` | 类型化字段与修复/清除操作 |
| `reconcile.tsx` | 最新值比较、保留草稿或采用服务器值 |
| `connections.tsx` | 匹配协议的模型选择、内联创建与按所选连接展示的凭据表单 |
| `dialog.test.tsx` / `*.test.*` / `test-fixtures.ts` | 生产对话框、选项与 HTTP 表单行为测试,含合成上下文 |

依赖生成客户端、`spaces`、`model-connections`、`lib` 与 UI 原语;由 `plugins` 消费,下层不得反向依赖。连接与凭据沿用个人连接库;OpenCode 默认模型沿用全局默认选择,不在插件配置中复制引用,其凭据入口跟随默认草稿所选连接。

编辑基线只在成功写入或用户明确核对时推进。写入只把选项基线推进到写响应确认的版本:随后读取的仍是该版本且发布上下文未变时原地采用,不重建编辑会话,因此独立的默认模型草稿及其 CAS 版本不会无提示回退;读到其他编辑器的后续写入或选用变化时保留草稿并要求显式核对,不把新版本号与旧草稿自动拼接。背景刷新与 409 保留草稿;声明变化必须确认修复,不自动迁移或丢弃未知字段。清除个人选项不删除连接。API Key 只经过复用的写入边界,不进入配置请求、查询/变更缓存或持久存储。所有表单互为同级。

测试使用真实生成客户端和 MSW,覆盖类型、修复、版本冲突、内联连接与凭据隔离;`dialog.test.tsx` 用生产对话框验证保存和清除只推进选项基线、保存后读到并发写入时要求核对,`plugins` 测试覆盖成员入口的延迟读取。
118 changes: 118 additions & 0 deletions frontend/src/features/agent-configurations/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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'
import {
agentConfigurationResponse,
serializeAgentConfiguration,
type AgentNumberLiterals,
} from './numbers'

/** One user's configuration, resolved in the selected space's pinned release context. */
export type AgentConfiguration = PersonalAgentConfigurationContext & {
numericValues?: AgentNumberLiterals
numericDefaults?: AgentNumberLiterals
}
/** 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,
{ transformResponse: agentConfigurationResponse },
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
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)
},
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<string, string> = {
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) ?? ''] ?? '配置操作失败,请稍后重试。'
}
95 changes: 95 additions & 0 deletions frontend/src/features/agent-configurations/connections.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 (
<fieldset className="space-y-3">
<legend className="text-sm font-medium">个人模型</legend>
<ConnectionChoice
label="模型连接"
value={draft.connectionId}
options={compatible.map((connection) => ({
value: connection.id,
label: connection.name + (connection.credentialConfigured ? '' : '(待配置 API Key)'),
}))}
onChange={(id) => onChange(id, '')}
/>
<ConnectionChoice
label="模型"
value={draft.modelId}
options={(selected?.models ?? []).map((model) => ({
value: model.id,
label: model.name + ' (' + model.id + ')',
}))}
onChange={(modelId) => onChange(draft.connectionId, modelId)}
/>
{!selected && (
<p className="text-xs text-muted-foreground">请选择匹配协议的已启用连接,或在下方新建。</p>
)}
</fieldset>
)
}

/** 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 (
<section aria-label="个人连接管理" className="space-y-4 border-t pt-4">
<h3 className="text-sm font-medium">个人连接管理</h3>
<p className="text-xs text-muted-foreground">
{selected
? `API Key 属于所选连接:${selected.name}`
: '选择模型连接或新建一个,再配置它的 API Key。'}
</p>
<Button type="button" variant="outline" onClick={() => setCreating((current) => !current)}>
新建个人连接
</Button>
{creating && (
<ConnectionForm
connection={undefined}
initialProtocol={initialProtocol}
onSaved={onCreated}
onClose={() => setCreating(false)}
/>
)}
{selected && (
<CredentialForm key={selected.id + ':' + selected.version} connection={selected} />
)}
</section>
)
}
Loading
Loading