From 3a8d64e8ce1aaee0dda820c5de764d3d0b13c1e3 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 16 Jul 2026 10:00:01 +0800 Subject: [PATCH 1/7] fix(providers): omit tool_choice on openai-completions requests without tools Strict OpenAI-compatible endpoints (xAI-style gateways/relays) reject requests that set tool_choice without a tools array ("A tool_choice was set on the request but no tools were specified"). Text-only calls such as compaction summaries and title generation carry no tools but always sent tool_choice: "none", so compaction on gpt-5.6 via chat-completions relays failed with 400. Gate tool_choice at the openai-completions dispatch point: only forward it when the request actually carries tools, matching the existing guard in agentRunner. tool_choice is meaningless without tools, so behavior is unchanged on lenient endpoints. Co-Authored-By: Claude Fable 5 --- .../src/lib/providers/runtime/streamByApi.ts | 8 +- .../test/providers/request-options.test.mjs | 7 +- .../stream-by-api-tool-choice.test.mjs | 126 ++++++++++++++++++ 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 crates/agent-gui/test/providers/stream-by-api-tool-choice.test.mjs diff --git a/crates/agent-gui/src/lib/providers/runtime/streamByApi.ts b/crates/agent-gui/src/lib/providers/runtime/streamByApi.ts index 96afb9d67..6b5c2fc6c 100644 --- a/crates/agent-gui/src/lib/providers/runtime/streamByApi.ts +++ b/crates/agent-gui/src/lib/providers/runtime/streamByApi.ts @@ -148,10 +148,16 @@ export function streamSimpleByApi(model: Model, context: Context, options: const openAICompletionsContext = openAICompletionsOptions.deepSeekProviderAdapter ? normalizeStructuredToolCallHistoryForDeepSeek(context) : context; + // 严格校验的 OpenAI 兼容端点(xAI/各类中转网关)对「带 tool_choice 但没带 + // tools」的请求直接 400("A tool_choice was set on the request but no tools + // were specified")——compaction 摘要、标题生成等 text-only 请求没有工具, + // 会踩中。tool_choice 在无工具时本就无意义,只在请求真正携带 tools 时下发。 const openAIOptions: OpenAICompletionsOptions = { ...buildOpenAIBaseOptions(model, openAICompletionsOptions), reasoningEffort: clampOpenAIReasoningEffort(model, openAICompletionsOptions.reasoning), - toolChoice: mapToolChoiceToOpenAI(openAICompletionsOptions.toolChoice), + toolChoice: openAICompletionsContext.tools?.length + ? mapToolChoiceToOpenAI(openAICompletionsOptions.toolChoice) + : undefined, }; return withStreamRetry( () => streamOpenAICompletions(model as any, openAICompletionsContext, openAIOptions), diff --git a/crates/agent-gui/test/providers/request-options.test.mjs b/crates/agent-gui/test/providers/request-options.test.mjs index 48c1ec81d..972536806 100644 --- a/crates/agent-gui/test/providers/request-options.test.mjs +++ b/crates/agent-gui/test/providers/request-options.test.mjs @@ -355,7 +355,12 @@ test("Codex Chat Completions streams forward reasoning effort", async () => { const result = localProviders.streamSimpleByApi( model, - { messages: [] }, + { + // toolChoice 只在请求真正携带 tools 时下发(无工具下发会被严格 + // OpenAI 兼容端点 400),透传断言需要一个非空 tools。 + tools: [{ name: "echo", description: "Echo tool", parameters: { type: "object" } }], + messages: [], + }, { reasoning: "high", toolChoice: "auto" }, ); diff --git a/crates/agent-gui/test/providers/stream-by-api-tool-choice.test.mjs b/crates/agent-gui/test/providers/stream-by-api-tool-choice.test.mjs new file mode 100644 index 000000000..9d632473f --- /dev/null +++ b/crates/agent-gui/test/providers/stream-by-api-tool-choice.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +function createUsage() { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function createSourceStream() { + const assistant = { + role: "assistant", + content: [{ type: "text", text: "ok" }], + api: "openai-completions", + provider: "openai", + model: "gpt-5.6", + usage: createUsage(), + stopReason: "stop", + timestamp: Date.now(), + }; + const events = [ + { type: "start", partial: { ...assistant, content: [] } }, + { type: "done", reason: "stop", message: assistant }, + ]; + return { + async *[Symbol.asyncIterator]() { + for (const event of events) { + yield event; + } + }, + async result() { + return assistant; + }, + }; +} + +function createOpenAICompletionsModel() { + return { + id: "gpt-5.6", + name: "gpt-5.6", + api: "openai-completions", + provider: "openai", + baseUrl: "https://relay.example.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 272000, + maxTokens: 128000, + }; +} + +function createLoaderCapturingOptions(capturedOptions) { + return createTsModuleLoader({ + mocks: { + "@earendil-works/pi-ai/api/openai-completions": { + stream(_model, _context, options) { + capturedOptions.push(options); + return createSourceStream(); + }, + }, + }, + }); +} + +async function streamOnce(context, toolChoice) { + const capturedOptions = []; + const loader = createLoaderCapturingOptions(capturedOptions); + const { streamSimpleByApi } = loader.loadModule("src/lib/providers/runtime/streamByApi.ts"); + const stream = streamSimpleByApi(createOpenAICompletionsModel(), context, { + apiKey: "test-key", + toolChoice, + }); + await stream.result(); + assert.equal(capturedOptions.length, 1); + return capturedOptions[0]; +} + +const echoTool = { + name: "echo", + description: "Echo tool", + parameters: { type: "object", properties: {} }, +}; + +test("openai-completions: 无工具请求不下发 tool_choice(压缩摘要等 text-only 路径)", async () => { + const options = await streamOnce( + { messages: [{ role: "user", content: "compaction payload", timestamp: 1 }] }, + "none", + ); + assert.equal(options.toolChoice, undefined); +}); + +test("openai-completions: 无工具请求即使 toolChoice=auto 也不下发", async () => { + const options = await streamOnce( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + "auto", + ); + assert.equal(options.toolChoice, undefined); +}); + +test("openai-completions: 带工具请求保留 tool_choice=none", async () => { + const options = await streamOnce( + { + tools: [echoTool], + messages: [{ role: "user", content: "hi", timestamp: 1 }], + }, + "none", + ); + assert.equal(options.toolChoice, "none"); +}); + +test("openai-completions: 带工具请求 any 映射为 required", async () => { + const options = await streamOnce( + { + tools: [echoTool], + messages: [{ role: "user", content: "hi", timestamp: 1 }], + }, + "any", + ); + assert.equal(options.toolChoice, "required"); +}); From 53e3507f19941cac03325dae2aff990ab5b74338 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 16 Jul 2026 10:50:04 +0800 Subject: [PATCH 2/7] fix(providers): restore adaptive thinking for decorated anthropic model ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since ee8dba1 anthropic adaptive-thinking dispatch relies solely on the catalog's compat.forceAdaptiveThinking, so any model id that misses the exact catalog match (date suffixes, case variants, relay renames like claude-4.6-sonnet) degraded to budget mode: requests carried the removed budget_tokens field with no output_config.effort, which 4.7+/Fable-class endpoints reject with 400 and relays silently strip — the user's thinking level selection had no effect ("always Medium" reports). Fix in modelFactory with two fallbacks, both written onto the model object because pi-ai's raw stream() dispatches adaptive-vs-budget from model.compat as well: - Normalized catalog lookup (lowercase, strip @version, strip -YYYYMMDD) inherits full catalog metadata while keeping the user-configured id in the request body. - For ids the catalog still misses, restore the pre-refactor family heuristics (plus reversed naming like claude-4.6-sonnet/claude-5-sonnet, with a lookbehind so claude-3-5-sonnet stays budget) to set compat.forceAdaptiveThinking and the xhigh/max thinkingLevelMap tiers. Mirror the same resolution into the web settings module so the WebUI level dropdown matches what the desktop actually sends. Add wire-level regression tests capturing the real pi-ai anthropic payload per level. Co-Authored-By: Claude Fable 5 --- .../test/webui/web-settings.test.mjs | 41 +++++ .../web/src/lib/settings/index.ts | 94 ++++++++++- .../src/lib/providers/runtime/modelFactory.ts | 94 ++++++++++- .../model-factory-anthropic.test.mjs | 146 ++++++++++++++++++ 4 files changed, 368 insertions(+), 7 deletions(-) create mode 100644 crates/agent-gui/test/providers/model-factory-anthropic.test.mjs diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index 0dac60d4e..7b8531713 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -205,6 +205,47 @@ test("web chat runtime controls default and follow model-aware reasoning support assert.equal(settings.isThinkingAlwaysOnForModel("claude_code", "claude-opus-4-8"), false); assert.equal(settings.isThinkingAlwaysOnForModel("claude_code", undefined), false); + // 中转装饰过的 Anthropic id(日期后缀/大小写/@版本)按规范化后的目录条目解析, + // xhigh/max 档位与"思考不可关"语义不丢失;与桌面端 modelFactory 同步。 + assert.deepEqual( + settings.getChatRuntimeReasoningLevelsForProvider({ + providerId: "claude_code", + modelId: "claude-opus-4-8-20260213", + }), + ["minimal", "low", "medium", "high", "xhigh", "max"], + ); + assert.deepEqual( + settings.getChatRuntimeReasoningLevelsForProvider({ + providerId: "claude_code", + modelId: "claude-sonnet-4-6-20251114", + }), + ["minimal", "low", "medium", "high", "max"], + ); + assert.equal(settings.isThinkingAlwaysOnForModel("claude_code", "Claude-Fable-5"), true); + // 目录彻底未命中的三方改名 id 走 id 启发式补 xhigh/max。 + assert.deepEqual( + settings.getChatRuntimeReasoningLevelsForProvider({ + providerId: "claude_code", + modelId: "claude-4.6-sonnet", + }), + ["minimal", "low", "medium", "high", "max"], + ); + assert.deepEqual( + settings.getChatRuntimeReasoningLevelsForProvider({ + providerId: "claude_code", + modelId: "claude-5-sonnet", + }), + ["minimal", "low", "medium", "high", "xhigh", "max"], + ); + // 旧世代 id 不误判。 + assert.deepEqual( + settings.getChatRuntimeReasoningLevelsForProvider({ + providerId: "claude_code", + modelId: "claude-3-5-sonnet-20241022", + }), + ["minimal", "low", "medium", "high"], + ); + assert.deepEqual( settings.normalizeChatRuntimeControlsForProvider( { diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 953c8b20d..532f84876 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -927,6 +927,81 @@ function findKnownModel(providerId: ProviderId, modelId: string | undefined) { return getBuiltinModels(toKnownProvider(providerId)).find((model) => model.id === trimmedId); } +// —— 以下 Anthropic id 规范化与启发式与桌面端 modelFactory.ts 手动保持同步 —— +// 中转/网关常给官方 Anthropic 模型 id 加装饰(日期后缀、@版本、大小写变化), +// 逐字匹配漏检后档位列表会塌缩、丢掉 xhigh/max,与桌面端实际发送的档位脱节。 +function normalizeAnthropicModelIdCandidates(modelId: string): string[] { + const candidates: string[] = []; + const push = (value: string) => { + if (value && !candidates.includes(value)) candidates.push(value); + }; + push(modelId); + const lower = modelId.toLowerCase(); + push(lower); + const withoutAtVersion = lower.split("@")[0]; + push(withoutAtVersion); + push(withoutAtVersion.replace(/-20\d{6}$/, "")); + return candidates; +} + +function findKnownAnthropicModel(modelId: string) { + const models = getBuiltinModels("anthropic"); + for (const candidate of normalizeAnthropicModelIdCandidates(modelId)) { + const known = models.find((model) => model.id === candidate); + if (known) return known; + } + return undefined; +} + +function findKnownModelForThinking(providerId: ProviderId, modelId: string) { + return toKnownProvider(providerId) === "anthropic" + ? findKnownAnthropicModel(modelId) + : findKnownModel(providerId, modelId); +} + +function isClaudeFamilyVersionAtLeast( + normalizedModelId: string, + family: "opus" | "sonnet", + minimumMinor: number, +) { + // minor 限定 1-2 位数字,避免把日期后缀(如 claude-sonnet-4-20250514)误读成小版本号; + // 同时接受三方中转的倒序命名(claude-4.6-sonnet)。 + const match = normalizedModelId.match( + new RegExp(`(?:${family}[-.]4[-.](\\d{1,2})(?!\\d)|4[-.](\\d{1,2})(?!\\d)[-.]${family})`), + ); + if (!match) return false; + const minor = Number(match[1] ?? match[2]); + return Number.isFinite(minor) && minor >= minimumMinor; +} + +// Claude 5 起(sonnet-5 / fable-5 / mythos-5 等)整个家族都是 adaptive thinking 且支持 xhigh。 +// 倒序写法(claude-5-sonnet)用负向后行断言排除 3-5-sonnet 这类旧世代小版本号。 +function isClaudeFamilyMajorVersionAtLeast(normalizedModelId: string, minimumMajor: number) { + const match = normalizedModelId.match( + /(?:(?:opus|sonnet|haiku|fable|mythos)[-.](\d{1,2})(?!\d)|(?= minimumMajor; +} + +// 目录彻底未命中的三方改名 id(如 claude-4.6-sonnet)退回 id 启发式,推断 +// xhigh/max 档位声明;与桌面端 deriveAnthropicThinkingOverridesForCustomModel 同步。 +function deriveAnthropicThinkingLevelMapForCustomModel( + modelId: string, +): Record | undefined { + const id = modelId.trim().toLowerCase(); + const adaptive = + id.includes("mythos-preview") || + isClaudeFamilyVersionAtLeast(id, "opus", 6) || + isClaudeFamilyVersionAtLeast(id, "sonnet", 6) || + isClaudeFamilyMajorVersionAtLeast(id, 5); + if (!adaptive) return undefined; + const supportsXHigh = + isClaudeFamilyVersionAtLeast(id, "opus", 7) || isClaudeFamilyMajorVersionAtLeast(id, 5); + return supportsXHigh ? { xhigh: "xhigh", max: "max" } : { max: "max" }; +} + function getKnownModelLimits( providerId: ProviderId, modelId: string | undefined, @@ -942,17 +1017,22 @@ export function getKnownModelThinkingLevels( ): ReasoningLevel[] { const trimmedId = modelId?.trim(); if (!trimmedId) return []; - const known = findKnownModel(providerId, trimmedId); + const known = findKnownModelForThinking(providerId, trimmedId); // 目录之外的自定义模型(deepseek/glm 等三方聚合)无法从 id 判断推理能力, // 与桌面端 modelFactory 自定义分支一致按可推理处理:标准档位,xhigh/max - // 仍需目录 opt-in;deepseek 走 codex 时镜像桌面端 DeepSeek 适配层的 xhigh 档。 + // 仍需目录 opt-in;deepseek 走 codex 时镜像桌面端 DeepSeek 适配层的 xhigh 档, + // claude_code 走桌面端同款 id 启发式补 xhigh/max。 + const customThinkingLevelMap = + providerId === "codex" && trimmedId.toLowerCase().includes("deepseek") + ? { xhigh: "max" } + : toKnownProvider(providerId) === "anthropic" + ? deriveAnthropicThinkingLevelMapForCustomModel(trimmedId) + : undefined; const model = known ?? ({ reasoning: true, - ...(providerId === "codex" && trimmedId.toLowerCase().includes("deepseek") - ? { thinkingLevelMap: { xhigh: "max" } } - : {}), + ...(customThinkingLevelMap ? { thinkingLevelMap: customThinkingLevelMap } : {}), } as Parameters[0]); return getSupportedThinkingLevels(model).filter((level) => level !== "off"); } @@ -961,7 +1041,9 @@ export function isThinkingAlwaysOnForModel( providerId: ProviderId, modelId: string | undefined, ): boolean { - const known = findKnownModel(providerId, modelId); + const trimmedId = modelId?.trim(); + if (!trimmedId) return false; + const known = findKnownModelForThinking(providerId, trimmedId); return known ? !getSupportedThinkingLevels(known).includes("off") : false; } diff --git a/crates/agent-gui/src/lib/providers/runtime/modelFactory.ts b/crates/agent-gui/src/lib/providers/runtime/modelFactory.ts index bb0850113..b9ec0241d 100644 --- a/crates/agent-gui/src/lib/providers/runtime/modelFactory.ts +++ b/crates/agent-gui/src/lib/providers/runtime/modelFactory.ts @@ -28,6 +28,93 @@ function resolveKnownModel( return known?.api ? ({ ...known, baseUrl } as Model) : undefined; } +// --------------------------------------------------------------------------- +// Anthropic 目录回查与自定义模型思考能力推断 +// --------------------------------------------------------------------------- + +// 中转/网关常给官方 Anthropic 模型 id 加装饰(日期后缀、@版本、大小写变化),逐字 +// 匹配会漏检;漏检后模型丢失 compat.forceAdaptiveThinking,思考配置退化成 4.7+/ +// Fable 世代已删除的 budget_tokens(官方端点 400、中转剥字段后档位彻底失效)。 +// 先精确查,再按规范化候选回查目录;命中则继承完整目录元数据,但保留用户配置的 +// 原始 id——请求体里的 model 字段必须是端点认识的字符串。 +function normalizeAnthropicModelIdCandidates(modelId: string): string[] { + const candidates: string[] = []; + const push = (value: string) => { + if (value && !candidates.includes(value)) candidates.push(value); + }; + push(modelId); + const lower = modelId.toLowerCase(); + push(lower); + const withoutAtVersion = lower.split("@")[0]; + push(withoutAtVersion); + push(withoutAtVersion.replace(/-20\d{6}$/, "")); + return candidates; +} + +function resolveKnownAnthropicModel(modelId: string, baseUrl: string): Model | undefined { + for (const candidate of normalizeAnthropicModelIdCandidates(modelId)) { + const known = resolveKnownModel("anthropic", candidate, baseUrl); + if (known) { + return { ...known, id: modelId, name: modelId } as Model; + } + } + return undefined; +} + +function isAnthropicMythosPreview(normalizedModelId: string) { + return normalizedModelId.includes("mythos-preview"); +} + +function isClaudeFamilyVersionAtLeast( + normalizedModelId: string, + family: "opus" | "sonnet", + minimumMinor: number, +) { + // minor 限定 1-2 位数字,避免把日期后缀(如 claude-sonnet-4-20250514)误读成小版本号; + // 同时接受三方中转的倒序命名(claude-4.6-sonnet)。 + const match = normalizedModelId.match( + new RegExp(`(?:${family}[-.]4[-.](\\d{1,2})(?!\\d)|4[-.](\\d{1,2})(?!\\d)[-.]${family})`), + ); + if (!match) return false; + const minor = Number(match[1] ?? match[2]); + return Number.isFinite(minor) && minor >= minimumMinor; +} + +// Claude 5 起(sonnet-5 / fable-5 / mythos-5 等)整个家族都是 adaptive thinking 且支持 xhigh。 +// 倒序写法(claude-5-sonnet)用负向后行断言排除 3-5-sonnet 这类旧世代小版本号。 +function isClaudeFamilyMajorVersionAtLeast(normalizedModelId: string, minimumMajor: number) { + const match = normalizedModelId.match( + /(?:(?:opus|sonnet|haiku|fable|mythos)[-.](\d{1,2})(?!\d)|(?= minimumMajor; +} + +// 目录彻底未命中的三方改名 id(如 claude-4.6-sonnet)退回 ee8dba1 之前的 id 启发式: +// 能识别为 adaptive 家族的补上 compat.forceAdaptiveThinking 与 xhigh/max 档位声明, +// pi-ai stream() 与本地 thinkingLevels.ts 都以模型对象上的这两个字段为准。 +export function deriveAnthropicThinkingOverridesForCustomModel(modelId: string): { + compat?: Model<"anthropic-messages">["compat"]; + thinkingLevelMap?: Model<"anthropic-messages">["thinkingLevelMap"]; +} { + const id = modelId.trim().toLowerCase(); + const adaptive = + isAnthropicMythosPreview(id) || + isClaudeFamilyVersionAtLeast(id, "opus", 6) || + isClaudeFamilyVersionAtLeast(id, "sonnet", 6) || + isClaudeFamilyMajorVersionAtLeast(id, 5); + if (!adaptive) return {}; + + // xhigh:Opus 4.7+ 与 Claude 5 家族;Mythos Preview / Opus 4.6 / Sonnet 4.6 只到 max。 + const supportsXHigh = + isClaudeFamilyVersionAtLeast(id, "opus", 7) || isClaudeFamilyMajorVersionAtLeast(id, 5); + return { + compat: { forceAdaptiveThinking: true }, + thinkingLevelMap: supportsXHigh ? { xhigh: "xhigh", max: "max" } : { max: "max" }, + }; +} + function maybeAppendGeminiApiVersion(baseUrl: string) { try { const url = new URL(baseUrl); @@ -325,7 +412,7 @@ export function createModelFromConfig( return custom; } - const known = resolveKnownModel("anthropic", modelId, baseUrl); + const known = resolveKnownAnthropicModel(modelId, baseUrl); if (known) { return applyDeepSeekModelDefaults( { @@ -342,6 +429,7 @@ export function createModelFromConfig( ); } + const thinkingOverrides = deriveAnthropicThinkingOverridesForCustomModel(modelId); const custom: Model<"anthropic-messages"> = { id: modelId, name: modelId, @@ -353,6 +441,10 @@ export function createModelFromConfig( cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow, maxTokens, + ...(thinkingOverrides.compat ? { compat: thinkingOverrides.compat } : {}), + ...(thinkingOverrides.thinkingLevelMap + ? { thinkingLevelMap: thinkingOverrides.thinkingLevelMap } + : {}), }; return applyDeepSeekModelDefaults(custom, { providerId, diff --git a/crates/agent-gui/test/providers/model-factory-anthropic.test.mjs b/crates/agent-gui/test/providers/model-factory-anthropic.test.mjs new file mode 100644 index 000000000..c8181212d --- /dev/null +++ b/crates/agent-gui/test/providers/model-factory-anthropic.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// 走真实 pi-ai anthropic stream(),用 onPayload 截获请求体后中断, +// 断言的是最终线格式(thinking/output_config),不是中间结构。 +const realAnthropic = await import( + new URL( + "../../node_modules/@earendil-works/pi-ai/dist/api/anthropic-messages.js", + import.meta.url, + ).href +); + +const loader = createTsModuleLoader({ + mocks: { + "@earendil-works/pi-ai/api/anthropic-messages": { stream: realAnthropic.stream }, + }, +}); + +const { createModelFromConfig, getAvailableThinkingLevelsForModel } = loader.loadModule( + "src/lib/providers/runtime/modelFactory.ts", +); +const { streamSimpleByApi } = loader.loadModule("src/lib/providers/runtime/streamByApi.ts"); + +const RELAY_BASE_URL = "https://relay.example.com/v1"; + +function levelsFor(modelId) { + return getAvailableThinkingLevelsForModel("claude_code", modelId, RELAY_BASE_URL); +} + +async function captureWirePayload(modelId, reasoning) { + const model = createModelFromConfig("claude_code", modelId, RELAY_BASE_URL); + let captured; + const stream = streamSimpleByApi( + model, + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + { + apiKey: "sk-test", + reasoning, + onPayload: (payload) => { + captured = payload; + throw new Error("__capture_stop__"); + }, + }, + ); + try { + await stream.result(); + } catch { + // onPayload 抛错中断请求属预期。 + } + assert.ok(captured, `expected payload capture for ${modelId}`); + return captured; +} + +test("anthropic: 装饰过的目录模型 id(日期后缀/大小写/@版本)继承目录 adaptive 元数据", () => { + for (const [modelId, baseId] of [ + ["claude-opus-4-8-20260213", "claude-opus-4-8"], + ["Claude-Fable-5", "claude-fable-5"], + ["claude-sonnet-4-6-20251114", "claude-sonnet-4-6"], + ["claude-opus-4-5@20251101", "claude-opus-4-5"], + ]) { + const model = createModelFromConfig("claude_code", modelId, RELAY_BASE_URL); + const base = createModelFromConfig("claude_code", baseId, "https://api.anthropic.com"); + // 请求体 model 字段必须保留用户配置的原始 id。 + assert.equal(model.id, modelId); + assert.equal(model.baseUrl, RELAY_BASE_URL); + assert.equal( + model.compat?.forceAdaptiveThinking, + base.compat?.forceAdaptiveThinking, + `${modelId} should inherit adaptive flag from ${baseId}`, + ); + assert.deepEqual(model.thinkingLevelMap, base.thinkingLevelMap); + } +}); + +test("anthropic: 装饰 id 的可选档位与目录基础模型一致(xhigh/max 不丢失)", () => { + assert.deepEqual(levelsFor("claude-opus-4-8-20260213"), [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + assert.deepEqual(levelsFor("claude-sonnet-4-6-20251114"), [ + "minimal", + "low", + "medium", + "high", + "max", + ]); +}); + +test("anthropic: 目录未命中的三方改名 id 走启发式识别 adaptive 家族", () => { + // Opus 4.7+/Claude 5 家族:xhigh 直通。 + for (const modelId of ["claude-4.7-opus", "claude-5-sonnet", "custom-fable-5-relay"]) { + const model = createModelFromConfig("claude_code", modelId, RELAY_BASE_URL); + assert.equal(model.compat?.forceAdaptiveThinking, true, `${modelId} should be adaptive`); + assert.deepEqual(model.thinkingLevelMap, { xhigh: "xhigh", max: "max" }); + } + // Opus 4.6/Sonnet 4.6/Mythos Preview:只到 max。 + for (const modelId of ["claude-4.6-sonnet", "claude-mythos-preview"]) { + const model = createModelFromConfig("claude_code", modelId, RELAY_BASE_URL); + assert.equal(model.compat?.forceAdaptiveThinking, true, `${modelId} should be adaptive`); + assert.deepEqual(model.thinkingLevelMap, { max: "max" }); + } +}); + +test("anthropic: 旧世代/歧义 id 不误判为 adaptive,保持 budget 语义", () => { + for (const modelId of [ + "claude-3-5-sonnet-20241022", + "claude-3-7-sonnet-20250219", + "claude-4-5-sonnet", + "claude-sonnet-4-5-x", + "claude-3-haiku-20240307", + ]) { + const model = createModelFromConfig("claude_code", modelId, RELAY_BASE_URL); + assert.notEqual( + model.compat?.forceAdaptiveThinking, + true, + `${modelId} must stay budget-mode`, + ); + } +}); + +test("anthropic wire: 装饰 id 发送 adaptive + output_config.effort,档位随选择变化", async () => { + const high = await captureWirePayload("claude-opus-4-8-20260213", "high"); + assert.equal(high.thinking?.type, "adaptive"); + assert.equal(high.output_config?.effort, "high"); + assert.equal(high.model, "claude-opus-4-8-20260213"); + + const max = await captureWirePayload("claude-opus-4-8-20260213", "max"); + assert.equal(max.output_config?.effort, "max"); + + const low = await captureWirePayload("claude-4.7-opus", "low"); + assert.equal(low.thinking?.type, "adaptive"); + assert.equal(low.output_config?.effort, "low"); +}); + +test("anthropic wire: 旧世代 id 仍发送 budget_tokens 且不带 output_config", async () => { + const payload = await captureWirePayload("claude-3-7-sonnet-20250219", "high"); + assert.equal(payload.thinking?.type, "enabled"); + assert.equal(payload.thinking?.budget_tokens, 16_384); + assert.equal(payload.output_config, undefined); +}); From 666503c02c9f1fd176355630bbd2cb5a0e987be3 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 16 Jul 2026 11:26:55 +0800 Subject: [PATCH 3/7] fix(skills): make ClawHub installs reliable Resolve publisher-scoped ClawHub downloads and preserve source identity metadata. Normalize compatible legacy skill names and show install preparation progress immediately in GUI and WebUI. --- .../web/src/lib/skills/clawHub.ts | 117 ++++++++- .../agent-gateway/web/src/lib/skills/index.ts | 5 + .../src/pages/skills-hub/SkillsHubPage.tsx | 242 +++++++++++++----- .../src-tauri/src/services/skills/clawhub.rs | 38 +-- .../src-tauri/src/services/skills/install.rs | 153 ++++++++++- .../src-tauri/src/services/skills/jobs.rs | 6 + .../src-tauri/src/services/skills/library.rs | 12 + .../src-tauri/src/services/skills/tests.rs | 132 ++++++++-- .../src-tauri/src/services/skills/types.rs | 5 + crates/agent-gui/src/lib/skills/clawHub.ts | 117 ++++++++- crates/agent-gui/src/lib/skills/index.ts | 5 + .../src/pages/skills-hub/SkillsHubPage.tsx | 242 +++++++++++++----- .../test/skills/clawhub-contract.test.mjs | 74 ++++++ .../skills-store-install-feedback.test.mjs | 59 +++++ docs/features/skills-and-mcp.md | 12 +- 15 files changed, 1012 insertions(+), 207 deletions(-) create mode 100644 crates/agent-gui/test/skills/clawhub-contract.test.mjs create mode 100644 crates/agent-gui/test/skills/skills-store-install-feedback.test.mjs diff --git a/crates/agent-gateway/web/src/lib/skills/clawHub.ts b/crates/agent-gateway/web/src/lib/skills/clawHub.ts index 4d11599b9..592e7deef 100644 --- a/crates/agent-gateway/web/src/lib/skills/clawHub.ts +++ b/crates/agent-gateway/web/src/lib/skills/clawHub.ts @@ -43,10 +43,6 @@ function asString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } -function asNumber(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - function asNullableNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -62,7 +58,13 @@ function buildClawHubWebUrl(ownerHandle: string | null, slug: string) { return `${CLAWHUB_API_BASE}/${encodeURIComponent(ownerHandle)}/${encodeURIComponent(slug)}`; } -function normalizeSkillCard(raw: unknown): ClawHubSkillCard | null { +export function buildClawHubSkillKey(skill: Pick) { + const slug = skill.slug.trim().toLowerCase(); + const ownerHandle = skill.ownerHandle?.trim().replace(/^@+/, "").toLowerCase(); + return `clawhub:${ownerHandle || "?"}/${slug}`; +} + +export function normalizeClawHubSkillCard(raw: unknown): ClawHubSkillCard | null { const item = asRecord(raw); const slug = asString(item.slug); if (!slug) return null; @@ -78,9 +80,14 @@ function normalizeSkillCard(raw: unknown): ClawHubSkillCard | null { summary: asString(item.summary) ?? "", latestVersion: asString(latestVersion.version) ?? asString(tags.latest) ?? asString(item.version), - downloads: asNumber(stats.downloads), - stars: asNumber(stats.stars), - installsCurrent: asNumber(stats.installsCurrent), + downloads: asNullableNumber(item.downloads) ?? asNullableNumber(stats.downloads) ?? 0, + stars: asNullableNumber(item.stars) ?? asNullableNumber(stats.stars) ?? 0, + installsCurrent: + asNullableNumber(item.installsCurrent) ?? + asNullableNumber(item.installs) ?? + asNullableNumber(stats.installsCurrent) ?? + asNullableNumber(stats.installs) ?? + 0, updatedAt: asNullableNumber(item.updatedAt), ownerHandle, webUrl: asString(item.webUrl) ?? buildClawHubWebUrl(ownerHandle, slug), @@ -96,7 +103,7 @@ function normalizeSkillDetail(raw: unknown): ClawHubSkillDetail | null { const owner = asRecord(payload.owner ?? item.owner); const metadata = asRecord(payload.metadata ?? item.metadata); const moderation = asRecord(payload.moderation ?? item.moderation); - const card = normalizeSkillCard({ + const card = normalizeClawHubSkillCard({ ...item, latestVersion, owner, @@ -118,14 +125,28 @@ function normalizeSkillDetail(raw: unknown): ClawHubSkillDetail | null { }; } +export class ClawHubHttpError extends Error { + readonly status: number; + readonly body: string; + + constructor(status: number, body: string) { + const detail = body.trim(); + super(`ClawHub request failed with HTTP ${status}${detail ? `: ${detail}` : ""}`); + this.name = "ClawHubHttpError"; + this.status = status; + this.body = body; + } +} + async function fetchClawHubJson(url: URL): Promise { const response = await fetch(url.toString(), { headers: { Accept: "application/json" }, }); + const body = await response.text(); if (!response.ok) { - throw new Error(`ClawHub request failed with HTTP ${response.status}`); + throw new ClawHubHttpError(response.status, body); } - return response.json(); + return JSON.parse(body) as unknown; } export async function listClawHubSkills(params: { @@ -143,7 +164,9 @@ export async function listClawHubSkills(params: { const json = asRecord(await fetchClawHubJson(url)); const items = Array.isArray(json.items) - ? json.items.map(normalizeSkillCard).filter((item): item is ClawHubSkillCard => Boolean(item)) + ? json.items + .map(normalizeClawHubSkillCard) + .filter((item): item is ClawHubSkillCard => Boolean(item)) : []; return { items, @@ -162,10 +185,78 @@ export async function searchClawHubSkills(params: { const json = asRecord(await fetchClawHubJson(url)); return Array.isArray(json.results) - ? json.results.map(normalizeSkillCard).filter((item): item is ClawHubSkillCard => Boolean(item)) + ? json.results + .map(normalizeClawHubSkillCard) + .filter((item): item is ClawHubSkillCard => Boolean(item)) : []; } +function narrowOwnerCandidates( + candidates: ClawHubSkillCard[], + predicate: (candidate: ClawHubSkillCard) => boolean, +) { + const narrowed = candidates.filter(predicate); + return narrowed.length > 0 ? narrowed : candidates; +} + +export function selectClawHubOwnerCandidate( + skill: ClawHubSkillCard, + candidates: ClawHubSkillCard[], +): ClawHubSkillCard | null { + let exact = candidates.filter( + (candidate) => + candidate.slug.toLowerCase() === skill.slug.toLowerCase() && Boolean(candidate.ownerHandle), + ); + if (exact.length === 1) return exact[0]; + if (exact.length === 0) return null; + + if (skill.updatedAt !== null) { + exact = narrowOwnerCandidates(exact, (candidate) => candidate.updatedAt === skill.updatedAt); + if (exact.length === 1) return exact[0]; + } + if (skill.latestVersion) { + exact = narrowOwnerCandidates( + exact, + (candidate) => candidate.latestVersion === skill.latestVersion, + ); + if (exact.length === 1) return exact[0]; + } + if (skill.downloads > 0) { + exact = narrowOwnerCandidates(exact, (candidate) => candidate.downloads === skill.downloads); + if (exact.length === 1) return exact[0]; + } + if (skill.summary) { + exact = narrowOwnerCandidates(exact, (candidate) => candidate.summary === skill.summary); + if (exact.length === 1) return exact[0]; + } + if (skill.displayName) { + exact = narrowOwnerCandidates( + exact, + (candidate) => candidate.displayName === skill.displayName, + ); + } + return exact.length === 1 ? exact[0] : null; +} + +export async function resolveClawHubSkillOwner(skill: ClawHubSkillCard): Promise { + if (skill.ownerHandle) return skill; + + const candidates = await searchClawHubSkills({ query: skill.slug, limit: 50 }); + const resolved = selectClawHubOwnerCandidate(skill, candidates); + if (!resolved?.ownerHandle) { + throw new Error( + `ClawHub skill "${skill.slug}" has multiple publishers, but the catalog item does not identify one`, + ); + } + + return { + ...skill, + ownerHandle: resolved.ownerHandle, + webUrl: resolved.webUrl ?? buildClawHubWebUrl(resolved.ownerHandle, skill.slug), + downloadUrl: buildClawHubDownloadUrl(skill.slug, resolved.ownerHandle), + }; +} + export async function getClawHubSkillDetail( slug: string, ownerHandle?: string | null, diff --git a/crates/agent-gateway/web/src/lib/skills/index.ts b/crates/agent-gateway/web/src/lib/skills/index.ts index fa6ff5c86..78651a6e3 100644 --- a/crates/agent-gateway/web/src/lib/skills/index.ts +++ b/crates/agent-gateway/web/src/lib/skills/index.ts @@ -33,8 +33,12 @@ export type SkillSummary = { export type SkillSourceMetadata = { registry: string; slug: string; + ownerHandle?: string | null; version?: string | null; publishedAt?: number | null; + originalName?: string | null; + normalizedName?: string | null; + compatibilityTransform?: string | null; }; export type SkillDiscovery = { @@ -93,6 +97,7 @@ export type SkillInstallJobSnapshot = { source: string; label?: string | null; slug?: string | null; + ownerHandle?: string | null; version?: string | null; downloadedBytes: number; totalBytes?: number | null; diff --git a/crates/agent-gateway/web/src/pages/skills-hub/SkillsHubPage.tsx b/crates/agent-gateway/web/src/pages/skills-hub/SkillsHubPage.tsx index 16193bd8e..00ece7e94 100644 --- a/crates/agent-gateway/web/src/pages/skills-hub/SkillsHubPage.tsx +++ b/crates/agent-gateway/web/src/pages/skills-hub/SkillsHubPage.tsx @@ -46,11 +46,13 @@ import { } from "../../lib/skills"; import { buildClawHubDownloadUrl, + buildClawHubSkillKey, type ClawHubSkillCard, type ClawHubSkillDetail, type ClawHubSort, getClawHubSkillDetail, listClawHubSkills, + resolveClawHubSkillOwner, searchClawHubSkills, } from "../../lib/skills/clawHub"; @@ -76,6 +78,7 @@ const STORE_SORT_OPTIONS: Array<{ value: ClawHubSort; labelKey: string }> = [ type StoreSkillInstallState = { done: boolean; installing: boolean; + pending: boolean; terminalJob: boolean; job: SkillInstallJobSnapshot | undefined; progress: number | null; @@ -330,7 +333,11 @@ export function SkillsHubPage(props: SkillsHubPageProps) { const [storeLoadingMore, setStoreLoadingMore] = useState(false); const [storeError, setStoreError] = useState(null); const [installJobs, setInstallJobs] = useState>({}); - const [installingBySlug, setInstallingBySlug] = useState>({}); + const [installingByStoreKey, setInstallingByStoreKey] = useState>({}); + const [pendingInstallKeys, setPendingInstallKeys] = useState>( + () => new Set(), + ); + const pendingInstallTokensRef = useRef(new Map()); const [deletingSkillName, setDeletingSkillName] = useState(null); const [externalScans, setExternalScans] = useState(null); const [externalLoading, setExternalLoading] = useState(false); @@ -548,37 +555,58 @@ export function SkillsHubPage(props: SkillsHubPageProps) { }; }, [previewInstalledSkill, t]); - const installedBySlug = useMemo(() => { + const installedStoreState = useMemo(() => { const installed = new Map(); + const slugs = new Set(); for (const skill of skills) { if (skill.source?.registry !== "clawhub") continue; const slug = skill.source.slug?.trim(); - if (slug) installed.set(slug, skill); + if (!slug) continue; + slugs.add(slug); + installed.set( + buildClawHubSkillKey({ slug, ownerHandle: skill.source.ownerHandle ?? null }), + skill, + ); } - return installed; + return { installed, slugs }; }, [skills]); - const completedInstallSlugs = useMemo(() => { + const completedInstallState = useMemo(() => { + const keys = new Set(); const slugs = new Set(); - for (const [slug, jobId] of Object.entries(installingBySlug)) { + for (const [storeKey, jobId] of Object.entries(installingByStoreKey)) { const job = installJobs[jobId]; - if (job?.phase === "done" && slug.trim()) { - slugs.add(slug.trim()); + if (job?.phase === "done") { + keys.add(storeKey); + if (job.slug?.trim()) slugs.add(job.slug.trim()); } } for (const job of Object.values(installJobs)) { if (job.phase === "done" && job.slug?.trim()) { slugs.add(job.slug.trim()); + keys.add( + buildClawHubSkillKey({ + slug: job.slug.trim(), + ownerHandle: job.ownerHandle ?? null, + }), + ); } } - return slugs; - }, [installJobs, installingBySlug]); + return { keys, slugs }; + }, [installJobs, installingByStoreKey]); + const installedStoreKeys = useMemo(() => { + const keys = new Set(installedStoreState.installed.keys()); + for (const key of completedInstallState.keys) { + keys.add(key); + } + return keys; + }, [completedInstallState.keys, installedStoreState.installed]); const installedStoreSlugs = useMemo(() => { - const slugs = new Set(installedBySlug.keys()); - for (const slug of completedInstallSlugs) { + const slugs = new Set(installedStoreState.slugs); + for (const slug of completedInstallState.slugs) { slugs.add(slug); } return slugs; - }, [completedInstallSlugs, installedBySlug]); + }, [completedInstallState.slugs, installedStoreState.slugs]); useEffect(() => { if (view !== "store" || lockedByChatMode) return; @@ -732,30 +760,76 @@ export function SkillsHubPage(props: SkillsHubPageProps) { } async function installStoreSkill(skill: ClawHubSkillCard) { - const existingJobId = installingBySlug[skill.slug]; - const existingJob = existingJobId ? installJobs[existingJobId] : undefined; + const initialStoreKey = buildClawHubSkillKey(skill); + const initialJobId = installingByStoreKey[initialStoreKey]; + const initialJob = initialJobId ? installJobs[initialJobId] : undefined; if ( lockedByChatMode || - installedStoreSlugs.has(skill.slug) || - (existingJob && !TERMINAL_INSTALL_PHASES.has(existingJob.phase)) + pendingInstallTokensRef.current.has(initialStoreKey) || + installedStoreKeys.has(initialStoreKey) || + (!skill.ownerHandle && installedStoreSlugs.has(skill.slug)) || + (initialJob && !TERMINAL_INSTALL_PHASES.has(initialJob.phase)) ) { return; } + + const pendingToken = Symbol(initialStoreKey); + pendingInstallTokensRef.current.set(initialStoreKey, pendingToken); + setPendingInstallKeys(new Set(pendingInstallTokensRef.current.keys())); setStoreError(null); try { + const resolvedSkill = await resolveClawHubSkillOwner(skill); + const storeKey = buildClawHubSkillKey(resolvedSkill); + const activePendingToken = pendingInstallTokensRef.current.get(storeKey); + if (activePendingToken && activePendingToken !== pendingToken) return; + if (storeKey !== initialStoreKey) { + pendingInstallTokensRef.current.set(storeKey, pendingToken); + setPendingInstallKeys(new Set(pendingInstallTokensRef.current.keys())); + } + setStoreItems((prev) => + prev.map((item) => + item.slug === resolvedSkill.slug && + item.updatedAt === resolvedSkill.updatedAt && + (!item.ownerHandle || item.ownerHandle === resolvedSkill.ownerHandle) + ? resolvedSkill + : item, + ), + ); + const existingJobId = installingByStoreKey[storeKey]; + const existingJob = existingJobId ? installJobs[existingJobId] : undefined; + if ( + installedStoreKeys.has(storeKey) || + (existingJob && !TERMINAL_INSTALL_PHASES.has(existingJob.phase)) + ) { + return; + } const job = await startSkillInstallJob({ - source: buildClawHubDownloadUrl(skill.slug, skill.ownerHandle), - label: skill.displayName, - slug: skill.slug, - ownerHandle: skill.ownerHandle, - version: skill.latestVersion, + source: buildClawHubDownloadUrl(resolvedSkill.slug, resolvedSkill.ownerHandle), + label: resolvedSkill.displayName, + slug: resolvedSkill.slug, + ownerHandle: resolvedSkill.ownerHandle, + version: resolvedSkill.latestVersion, conflict: "backup", }); setInstallJobs((prev) => ({ ...prev, [job.jobId]: job })); - setInstallingBySlug((prev) => ({ ...prev, [skill.slug]: job.jobId })); + setInstallingByStoreKey((prev) => ({ + ...prev, + [initialStoreKey]: job.jobId, + [storeKey]: job.jobId, + })); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setStoreError(msg || t("settings.skillsHubInstallFailed")); + } finally { + let changed = false; + for (const [storeKey, token] of pendingInstallTokensRef.current) { + if (token !== pendingToken) continue; + pendingInstallTokensRef.current.delete(storeKey); + changed = true; + } + if (changed) { + setPendingInstallKeys(new Set(pendingInstallTokensRef.current.keys())); + } } } @@ -763,6 +837,8 @@ export function SkillsHubPage(props: SkillsHubPageProps) { if (lockedByChatMode || isAlwaysEnabledSkillName(skill.name) || deletingSkillName) return; const skillName = skill.name; const sourceSlug = skill.source?.registry === "clawhub" ? skill.source.slug?.trim() || "" : ""; + const sourceOwnerHandle = + skill.source?.registry === "clawhub" ? skill.source.ownerHandle?.trim() || null : null; setLoadError(null); setDeletingSkillName(skillName); try { @@ -775,17 +851,24 @@ export function SkillsHubPage(props: SkillsHubPageProps) { setSkills((prev) => prev.filter((item) => item.name !== skillName)); setPreviewInstalledSkill((current) => (current?.name === skillName ? null : current)); if (sourceSlug) { - setInstallingBySlug((prev) => { - if (!(sourceSlug in prev)) return prev; + const sourceKey = buildClawHubSkillKey({ + slug: sourceSlug, + ownerHandle: sourceOwnerHandle, + }); + setInstallingByStoreKey((prev) => { + if (!(sourceKey in prev)) return prev; const next = { ...prev }; - delete next[sourceSlug]; + delete next[sourceKey]; return next; }); setInstallJobs((prev) => { let changed = false; const next = { ...prev }; for (const [jobId, job] of Object.entries(prev)) { - if (job.slug?.trim() === sourceSlug) { + if ( + job.slug?.trim() === sourceSlug && + (!sourceOwnerHandle || job.ownerHandle?.trim() === sourceOwnerHandle) + ) { delete next[jobId]; changed = true; } @@ -1339,8 +1422,10 @@ export function SkillsHubPage(props: SkillsHubPageProps) { loadingMore={storeLoadingMore} error={storeError} cursor={storeCursor} + installedKeys={installedStoreKeys} installedSlugs={installedStoreSlugs} - installingBySlug={installingBySlug} + pendingInstallKeys={pendingInstallKeys} + installingByStoreKey={installingByStoreKey} installJobs={installJobs} onSortChange={setStoreSort} onLoadMore={() => void loadMoreStore()} @@ -1925,8 +2010,10 @@ function SkillsStoreView(props: { loadingMore: boolean; error: string | null; cursor: string | null; + installedKeys: Set; installedSlugs: Set; - installingBySlug: Record; + pendingInstallKeys: ReadonlySet; + installingByStoreKey: Record; installJobs: Record; onSortChange: (value: ClawHubSort) => void; onLoadMore: () => void; @@ -1940,8 +2027,10 @@ function SkillsStoreView(props: { loadingMore, error, cursor, + installedKeys, installedSlugs, - installingBySlug, + pendingInstallKeys, + installingByStoreKey, installJobs, onSortChange, onLoadMore, @@ -1968,7 +2057,16 @@ function SkillsStoreView(props: { setPreviewError(null); setPreviewLoading(true); - void getClawHubSkillDetail(previewSkill.slug, previewSkill.ownerHandle) + void resolveClawHubSkillOwner(previewSkill) + .then((resolvedSkill) => { + if ( + !cancelled && + buildClawHubSkillKey(resolvedSkill) !== buildClawHubSkillKey(previewSkill) + ) { + setPreviewSkill(resolvedSkill); + } + return getClawHubSkillDetail(resolvedSkill.slug, resolvedSkill.ownerHandle); + }) .then((detail) => { if (!cancelled) { setPreviewDetail(detail); @@ -1992,16 +2090,22 @@ function SkillsStoreView(props: { }, [previewSkill, t]); function getInstallState(skill: ClawHubSkillCard): StoreSkillInstallState { - const jobId = installingBySlug[skill.slug]; + const storeKey = buildClawHubSkillKey(skill); + const pending = pendingInstallKeys.has(storeKey); + const jobId = installingByStoreKey[storeKey]; const job = jobId ? installJobs[jobId] : undefined; const terminalJob = Boolean(job && TERMINAL_INSTALL_PHASES.has(job.phase)); - const done = installedSlugs.has(skill.slug) || job?.phase === "done"; + const done = + installedKeys.has(storeKey) || + (!skill.ownerHandle && installedSlugs.has(skill.slug)) || + job?.phase === "done"; return { done, - installing: Boolean(job && !terminalJob), + installing: pending || Boolean(job && !terminalJob), + pending, terminalJob, job, - progress: job ? getInstallProgressPercent(job) : null, + progress: pending ? null : job ? getInstallProgressPercent(job) : null, }; } @@ -2114,13 +2218,13 @@ function SkillsStoreView(props: { )} > {items.map((skill) => { - const { done, installing, terminalJob, job, progress } = getInstallState(skill); + const { done, installing, pending, job, progress } = getInstallState(skill); const link = buildClawHubSkillUrl(skill); return ( // biome-ignore lint/a11y/useSemanticElements: The card contains nested controls and cannot be a native button.
- {job && !done && !terminalJob ? ( + {installing && !done ? (
- {installPhaseLabel(job, t)} - - {formatInstallProgress(job)} - - + {installPhaseLabel(pending ? undefined : job, t)} + {job && !pending ? ( + + {formatInstallProgress(job)} + + + ) : null}
{progress === null ? ( @@ -2243,7 +2349,7 @@ function SkillsStoreView(props: {
) : null} - {job?.phase === "error" && job.error && !done ? ( + {job?.phase === "error" && job.error && !done && !pending ? (
{job.error}
@@ -2259,6 +2365,7 @@ function SkillsStoreView(props: { "border-border/55 bg-background/75 text-foreground/85 backdrop-blur-md", )} disabled={done || installing} + aria-busy={installing} onClick={(event) => { event.stopPropagation(); onInstall(skill); @@ -2273,7 +2380,7 @@ function SkillsStoreView(props: { )} {installing - ? installPhaseLabel(job, t) + ? installPhaseLabel(pending ? undefined : job, t) : done ? t("settings.skillsStoreInstalled") : t("settings.skillsStoreInstall")} @@ -2337,7 +2444,7 @@ function SkillsStorePreviewDrawer(props: { const supportedOs = detail?.supportedOs ?? []; const supportedSystems = detail?.supportedSystems ?? []; const actionLabel = installState.installing - ? installPhaseLabel(installState.job, t) + ? installPhaseLabel(installState.pending ? undefined : installState.job, t) : installState.done ? t("settings.skillsStoreInstalled") : t("settings.skillsStoreInstall"); @@ -2439,11 +2546,15 @@ function SkillsStorePreviewDrawer(props: { />
- {installState.job && installState.installing ? ( + {installState.installing && !installState.done ? (
- {installPhaseLabel(installState.job, t)} - {formatInstallProgress(installState.job)} + + {installPhaseLabel(installState.pending ? undefined : installState.job, t)} + + {installState.job && !installState.pending ? ( + {formatInstallProgress(installState.job)} + ) : null}
{installState.progress === null ? ( @@ -2458,7 +2569,10 @@ function SkillsStorePreviewDrawer(props: {
) : null} - {installState.job?.phase === "error" && installState.job.error && !installState.done ? ( + {installState.job?.phase === "error" && + installState.job.error && + !installState.done && + !installState.pending ? (
{installState.job.error}
@@ -2569,6 +2683,7 @@ function SkillsStorePreviewDrawer(props: { "border-border/55 bg-background/75 text-foreground/85 backdrop-blur-md", )} disabled={installState.done || installState.installing} + aria-busy={installState.installing} onClick={onInstall} > {installState.installing ? ( @@ -2644,8 +2759,9 @@ function StorePreviewField(props: { label: string; value?: string | null }) { function dedupeStoreItems(items: ClawHubSkillCard[]) { const seen = new Set(); return items.filter((item) => { - if (seen.has(item.slug)) return false; - seen.add(item.slug); + const storeKey = buildClawHubSkillKey(item); + if (seen.has(storeKey)) return false; + seen.add(storeKey); return true; }); } diff --git a/crates/agent-gui/src-tauri/src/services/skills/clawhub.rs b/crates/agent-gui/src-tauri/src/services/skills/clawhub.rs index 08d297709..f55bb99cb 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/clawhub.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/clawhub.rs @@ -40,25 +40,6 @@ pub(crate) fn json_string(item: &serde_json::Map, key: &str) -> O .map(ToOwned::to_owned) } -pub(crate) fn json_u64(item: &serde_json::Map, key: &str) -> u64 { - match item.get(key) { - Some(Value::Number(number)) => number - .as_u64() - .or_else(|| number.as_i64().and_then(|value| u64::try_from(value).ok())) - .or_else(|| { - number.as_f64().and_then(|value| { - if value.is_finite() && value >= 0.0 { - Some(value as u64) - } else { - None - } - }) - }) - .unwrap_or(0), - _ => 0, - } -} - pub(crate) fn json_optional_u64(item: &serde_json::Map, key: &str) -> Option { item.get(key).and_then(|value| match value { Value::Number(number) => number @@ -88,7 +69,10 @@ pub(crate) fn clawhub_download_url_for_slug( .append_pair("slug", slug) .append_pair("tag", tag); // ClawHub 对重名 slug 返回 409,必须带 ownerHandle 消歧。 - if let Some(owner) = owner_handle.map(str::trim).filter(|value| !value.is_empty()) { + if let Some(owner) = owner_handle + .map(str::trim) + .filter(|value| !value.is_empty()) + { url.query_pairs_mut().append_pair("ownerHandle", owner); } Ok(url.into()) @@ -123,10 +107,16 @@ pub(crate) fn normalize_clawhub_skill_card(raw: &Value) -> Option String { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos(); - format!("{nanos}-{}", UNIQUE_SUFFIX_SEQ.fetch_add(1, Ordering::Relaxed)) + format!( + "{nanos}-{}", + UNIQUE_SUFFIX_SEQ.fetch_add(1, Ordering::Relaxed) + ) } fn staging_root(dest_root: &Path) -> PathBuf { @@ -268,7 +271,10 @@ pub(crate) fn install_skill_dir( }) } -pub(crate) fn normalize_conflict(value: Option<&str>, default_value: &str) -> Result { +pub(crate) fn normalize_conflict( + value: Option<&str>, + default_value: &str, +) -> Result { let raw = value.unwrap_or(default_value).trim(); match raw { "backup" | "fail" | "overwrite" => Ok(raw.to_string()), @@ -284,8 +290,123 @@ pub(crate) fn normalize_method(value: Option<&str>) -> Result { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SkillNameCompatibilityTransform { + pub(crate) original_name: String, + pub(crate) normalized_name: String, +} + +fn is_clawhub_download_source(source: &str) -> bool { + reqwest::Url::parse(source) + .ok() + .map(|url| url.host_str() == Some("clawhub.ai") && url.path() == "/api/v1/download") + .unwrap_or(false) +} + +fn registry_skill_slug(value: &str) -> &str { + value + .trim() + .trim_start_matches('@') + .rsplit('/') + .next() + .unwrap_or("") +} + +fn rewrite_skill_metadata_name(metadata_file: &Path, normalized_name: &str) -> Result<(), String> { + if is_skill_json(metadata_file) { + let content = fs::read_to_string(metadata_file) + .map_err(|e| format!("Failed to read {}: {e}", metadata_file.display()))?; + let mut value = serde_json::from_str::(&content) + .map_err(|e| format!("Failed to parse {}: {e}", metadata_file.display()))?; + let object = value.as_object_mut().ok_or_else(|| { + format!( + "Skill metadata must be an object: {}", + metadata_file.display() + ) + })?; + object.insert( + "name".to_string(), + Value::String(normalized_name.to_string()), + ); + let next = serde_json::to_vec_pretty(&value) + .map_err(|e| format!("Failed to serialize {}: {e}", metadata_file.display()))?; + return fs::write(metadata_file, next) + .map_err(|e| format!("Failed to update {}: {e}", metadata_file.display())); + } + + let content = fs::read_to_string(metadata_file) + .map_err(|e| format!("Failed to read {}: {e}", metadata_file.display()))?; + let (yaml, body) = split_frontmatter(&content)?; + if yaml.lines().count() == 1 && frontmatter_keys(&yaml).len() > 1 { + return Err("Cannot safely normalize an inline Skill frontmatter name".to_string()); + } + + let mut replaced = false; + let mut lines = Vec::new(); + for line in yaml.lines() { + let is_top_level = !line + .chars() + .next() + .map(char::is_whitespace) + .unwrap_or(false); + let is_name = is_top_level + && line + .split_once(':') + .map(|(key, _)| key.trim() == "name") + .unwrap_or(false); + if is_name { + lines.push(format!("name: {normalized_name}")); + replaced = true; + } else { + lines.push(line.to_string()); + } + } + if !replaced { + return Err(format!( + "Missing top-level Skill name in {}", + metadata_file.display() + )); + } + + let next = format!("---\n{}\n---\n{}", lines.join("\n"), body); + fs::write(metadata_file, next) + .map_err(|e| format!("Failed to update {}: {e}", metadata_file.display())) +} + +pub(crate) fn normalize_clawhub_candidate_name( + candidate: &Path, + slug: &str, +) -> Result, String> { + let metadata_file = metadata_file_for(candidate).ok_or_else(|| { + format!( + "No SKILL.md, skill.md, skill.json, or README.md found in {}", + candidate.display() + ) + })?; + let metadata = read_skill_metadata_file(&metadata_file)?; + let Some(original_name) = metadata.name else { + return Ok(None); + }; + if sanitize_skill_name(&original_name).is_ok() { + return Ok(None); + } + + let normalized_name = normalize_skill_name(&original_name); + let expected_name = registry_skill_slug(slug); + if normalized_name != expected_name || sanitize_skill_name(expected_name).is_err() { + return Ok(None); + } + + rewrite_skill_metadata_name(&metadata_file, expected_name)?; + Ok(Some(SkillNameCompatibilityTransform { + original_name, + normalized_name, + })) +} + fn build_skill_source_metadata( payload: &serde_json::Map, + compatibility: Option<&SkillNameCompatibilityTransform>, ) -> Result>, String> { let Some(slug) = object_string(payload, "slug") else { return Ok(None); @@ -297,6 +418,9 @@ fn build_skill_source_metadata( .or_else(|| object_string(payload, "owner")), "version": object_string(payload, "version"), "publishedAt": payload.get("publishedAt").and_then(Value::as_u64), + "originalName": compatibility.map(|value| value.original_name.as_str()), + "normalizedName": compatibility.map(|value| value.normalized_name.as_str()), + "compatibilityTransform": compatibility.map(|_| "normalize-agent-skill-name"), }); serde_json::to_vec_pretty(&metadata) .map(Some) @@ -327,8 +451,6 @@ where let name_override = object_string(payload, "name") .map(sanitize_skill_name) .transpose()?; - let source_meta = build_skill_source_metadata(payload)?; - if should_cancel() { return Err(INSTALL_CANCELLED_ERROR.to_string()); } @@ -375,14 +497,37 @@ where if name_override.is_some() && candidates.len() != 1 { return Err("name can only be used when exactly one skill is installed".to_string()); } + let normalize_clawhub_name = candidates.len() == 1 && is_clawhub_download_source(source); + let registry_slug = object_string(payload, "slug").map(ToOwned::to_owned); let mut results = Vec::new(); for candidate in candidates { if should_cancel() { return Err(INSTALL_CANCELLED_ERROR.to_string()); } + let compatibility = if normalize_clawhub_name { + registry_slug + .as_deref() + .map(|slug| normalize_clawhub_candidate_name(&candidate, slug)) + .transpose()? + .flatten() + } else { + None + }; + if let Some(transform) = compatibility.as_ref() { + on_progress(SkillInstallProgressUpdate { + phase: "validating", + downloaded_bytes: None, + total_bytes: None, + message: Some(format!( + "Normalizing Skill name '{}' to '{}' for Agent Skills compatibility", + transform.original_name, transform.normalized_name + )), + }); + } let metadata = read_skill_metadata_from_dir(&candidate)?; let skill_name = name_override.as_deref().unwrap_or(&metadata.name); + let source_meta = build_skill_source_metadata(payload, compatibility.as_ref())?; ensure_not_builtin_skill_management_target(root, skill_name, "install")?; on_progress(SkillInstallProgressUpdate { phase: "installing", diff --git a/crates/agent-gui/src-tauri/src/services/skills/jobs.rs b/crates/agent-gui/src-tauri/src/services/skills/jobs.rs index 93b776868..b4deb90ca 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/jobs.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/jobs.rs @@ -17,6 +17,7 @@ pub(crate) struct SkillInstallJobState { pub(crate) source: String, pub(crate) label: Option, pub(crate) slug: Option, + pub(crate) owner_handle: Option, pub(crate) version: Option, pub(crate) downloaded_bytes: u64, pub(crate) total_bytes: Option, @@ -52,6 +53,7 @@ pub(crate) fn install_job_snapshot(job: &SkillInstallJobState) -> SystemSkillIns source: job.source.clone(), label: job.label.clone(), slug: job.slug.clone(), + owner_handle: job.owner_handle.clone(), version: job.version.clone(), downloaded_bytes: job.downloaded_bytes, total_bytes: job.total_bytes, @@ -141,6 +143,9 @@ pub(crate) fn start_install_job_from_payload( .to_string(); let label = object_string(payload, "label").map(ToOwned::to_owned); let slug = object_string(payload, "slug").map(ToOwned::to_owned); + let owner_handle = object_string(payload, "ownerHandle") + .or_else(|| object_string(payload, "owner")) + .map(ToOwned::to_owned); let version = object_string(payload, "version").map(ToOwned::to_owned); normalize_conflict(object_string(payload, "conflict"), "backup")?; normalize_method(object_string(payload, "method"))?; @@ -154,6 +159,7 @@ pub(crate) fn start_install_job_from_payload( source, label, slug, + owner_handle, version, downloaded_bytes: 0, total_bytes: None, diff --git a/crates/agent-gui/src-tauri/src/services/skills/library.rs b/crates/agent-gui/src-tauri/src/services/skills/library.rs index e23e6160e..71b70444e 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/library.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/library.rs @@ -254,13 +254,25 @@ pub(crate) fn read_skill_source_metadata(skill_dir: &Path) -> Option, pub version: Option, pub published_at: Option, + pub original_name: Option, + pub normalized_name: Option, + pub compatibility_transform: Option, } #[derive(Debug, Serialize, Clone)] @@ -138,6 +142,7 @@ pub struct SystemSkillInstallJobSnapshot { pub source: String, pub label: Option, pub slug: Option, + pub owner_handle: Option, pub version: Option, pub downloaded_bytes: u64, pub total_bytes: Option, diff --git a/crates/agent-gui/src/lib/skills/clawHub.ts b/crates/agent-gui/src/lib/skills/clawHub.ts index 4d11599b9..592e7deef 100644 --- a/crates/agent-gui/src/lib/skills/clawHub.ts +++ b/crates/agent-gui/src/lib/skills/clawHub.ts @@ -43,10 +43,6 @@ function asString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } -function asNumber(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - function asNullableNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -62,7 +58,13 @@ function buildClawHubWebUrl(ownerHandle: string | null, slug: string) { return `${CLAWHUB_API_BASE}/${encodeURIComponent(ownerHandle)}/${encodeURIComponent(slug)}`; } -function normalizeSkillCard(raw: unknown): ClawHubSkillCard | null { +export function buildClawHubSkillKey(skill: Pick) { + const slug = skill.slug.trim().toLowerCase(); + const ownerHandle = skill.ownerHandle?.trim().replace(/^@+/, "").toLowerCase(); + return `clawhub:${ownerHandle || "?"}/${slug}`; +} + +export function normalizeClawHubSkillCard(raw: unknown): ClawHubSkillCard | null { const item = asRecord(raw); const slug = asString(item.slug); if (!slug) return null; @@ -78,9 +80,14 @@ function normalizeSkillCard(raw: unknown): ClawHubSkillCard | null { summary: asString(item.summary) ?? "", latestVersion: asString(latestVersion.version) ?? asString(tags.latest) ?? asString(item.version), - downloads: asNumber(stats.downloads), - stars: asNumber(stats.stars), - installsCurrent: asNumber(stats.installsCurrent), + downloads: asNullableNumber(item.downloads) ?? asNullableNumber(stats.downloads) ?? 0, + stars: asNullableNumber(item.stars) ?? asNullableNumber(stats.stars) ?? 0, + installsCurrent: + asNullableNumber(item.installsCurrent) ?? + asNullableNumber(item.installs) ?? + asNullableNumber(stats.installsCurrent) ?? + asNullableNumber(stats.installs) ?? + 0, updatedAt: asNullableNumber(item.updatedAt), ownerHandle, webUrl: asString(item.webUrl) ?? buildClawHubWebUrl(ownerHandle, slug), @@ -96,7 +103,7 @@ function normalizeSkillDetail(raw: unknown): ClawHubSkillDetail | null { const owner = asRecord(payload.owner ?? item.owner); const metadata = asRecord(payload.metadata ?? item.metadata); const moderation = asRecord(payload.moderation ?? item.moderation); - const card = normalizeSkillCard({ + const card = normalizeClawHubSkillCard({ ...item, latestVersion, owner, @@ -118,14 +125,28 @@ function normalizeSkillDetail(raw: unknown): ClawHubSkillDetail | null { }; } +export class ClawHubHttpError extends Error { + readonly status: number; + readonly body: string; + + constructor(status: number, body: string) { + const detail = body.trim(); + super(`ClawHub request failed with HTTP ${status}${detail ? `: ${detail}` : ""}`); + this.name = "ClawHubHttpError"; + this.status = status; + this.body = body; + } +} + async function fetchClawHubJson(url: URL): Promise { const response = await fetch(url.toString(), { headers: { Accept: "application/json" }, }); + const body = await response.text(); if (!response.ok) { - throw new Error(`ClawHub request failed with HTTP ${response.status}`); + throw new ClawHubHttpError(response.status, body); } - return response.json(); + return JSON.parse(body) as unknown; } export async function listClawHubSkills(params: { @@ -143,7 +164,9 @@ export async function listClawHubSkills(params: { const json = asRecord(await fetchClawHubJson(url)); const items = Array.isArray(json.items) - ? json.items.map(normalizeSkillCard).filter((item): item is ClawHubSkillCard => Boolean(item)) + ? json.items + .map(normalizeClawHubSkillCard) + .filter((item): item is ClawHubSkillCard => Boolean(item)) : []; return { items, @@ -162,10 +185,78 @@ export async function searchClawHubSkills(params: { const json = asRecord(await fetchClawHubJson(url)); return Array.isArray(json.results) - ? json.results.map(normalizeSkillCard).filter((item): item is ClawHubSkillCard => Boolean(item)) + ? json.results + .map(normalizeClawHubSkillCard) + .filter((item): item is ClawHubSkillCard => Boolean(item)) : []; } +function narrowOwnerCandidates( + candidates: ClawHubSkillCard[], + predicate: (candidate: ClawHubSkillCard) => boolean, +) { + const narrowed = candidates.filter(predicate); + return narrowed.length > 0 ? narrowed : candidates; +} + +export function selectClawHubOwnerCandidate( + skill: ClawHubSkillCard, + candidates: ClawHubSkillCard[], +): ClawHubSkillCard | null { + let exact = candidates.filter( + (candidate) => + candidate.slug.toLowerCase() === skill.slug.toLowerCase() && Boolean(candidate.ownerHandle), + ); + if (exact.length === 1) return exact[0]; + if (exact.length === 0) return null; + + if (skill.updatedAt !== null) { + exact = narrowOwnerCandidates(exact, (candidate) => candidate.updatedAt === skill.updatedAt); + if (exact.length === 1) return exact[0]; + } + if (skill.latestVersion) { + exact = narrowOwnerCandidates( + exact, + (candidate) => candidate.latestVersion === skill.latestVersion, + ); + if (exact.length === 1) return exact[0]; + } + if (skill.downloads > 0) { + exact = narrowOwnerCandidates(exact, (candidate) => candidate.downloads === skill.downloads); + if (exact.length === 1) return exact[0]; + } + if (skill.summary) { + exact = narrowOwnerCandidates(exact, (candidate) => candidate.summary === skill.summary); + if (exact.length === 1) return exact[0]; + } + if (skill.displayName) { + exact = narrowOwnerCandidates( + exact, + (candidate) => candidate.displayName === skill.displayName, + ); + } + return exact.length === 1 ? exact[0] : null; +} + +export async function resolveClawHubSkillOwner(skill: ClawHubSkillCard): Promise { + if (skill.ownerHandle) return skill; + + const candidates = await searchClawHubSkills({ query: skill.slug, limit: 50 }); + const resolved = selectClawHubOwnerCandidate(skill, candidates); + if (!resolved?.ownerHandle) { + throw new Error( + `ClawHub skill "${skill.slug}" has multiple publishers, but the catalog item does not identify one`, + ); + } + + return { + ...skill, + ownerHandle: resolved.ownerHandle, + webUrl: resolved.webUrl ?? buildClawHubWebUrl(resolved.ownerHandle, skill.slug), + downloadUrl: buildClawHubDownloadUrl(skill.slug, resolved.ownerHandle), + }; +} + export async function getClawHubSkillDetail( slug: string, ownerHandle?: string | null, diff --git a/crates/agent-gui/src/lib/skills/index.ts b/crates/agent-gui/src/lib/skills/index.ts index a13757a75..637aafeee 100644 --- a/crates/agent-gui/src/lib/skills/index.ts +++ b/crates/agent-gui/src/lib/skills/index.ts @@ -33,8 +33,12 @@ export type SkillSummary = { export type SkillSourceMetadata = { registry: string; slug: string; + ownerHandle?: string | null; version?: string | null; publishedAt?: number | null; + originalName?: string | null; + normalizedName?: string | null; + compatibilityTransform?: string | null; }; export type SkillDiscovery = { @@ -93,6 +97,7 @@ export type SkillInstallJobSnapshot = { source: string; label?: string | null; slug?: string | null; + ownerHandle?: string | null; version?: string | null; downloadedBytes: number; totalBytes?: number | null; diff --git a/crates/agent-gui/src/pages/skills-hub/SkillsHubPage.tsx b/crates/agent-gui/src/pages/skills-hub/SkillsHubPage.tsx index 0025a2ce1..9674df5b6 100644 --- a/crates/agent-gui/src/pages/skills-hub/SkillsHubPage.tsx +++ b/crates/agent-gui/src/pages/skills-hub/SkillsHubPage.tsx @@ -46,11 +46,13 @@ import { } from "../../lib/skills"; import { buildClawHubDownloadUrl, + buildClawHubSkillKey, type ClawHubSkillCard, type ClawHubSkillDetail, type ClawHubSort, getClawHubSkillDetail, listClawHubSkills, + resolveClawHubSkillOwner, searchClawHubSkills, } from "../../lib/skills/clawHub"; @@ -76,6 +78,7 @@ const STORE_SORT_OPTIONS: Array<{ value: ClawHubSort; labelKey: string }> = [ type StoreSkillInstallState = { done: boolean; installing: boolean; + pending: boolean; terminalJob: boolean; job: SkillInstallJobSnapshot | undefined; progress: number | null; @@ -330,7 +333,11 @@ export function SkillsHubPage(props: SkillsHubPageProps) { const [storeLoadingMore, setStoreLoadingMore] = useState(false); const [storeError, setStoreError] = useState(null); const [installJobs, setInstallJobs] = useState>({}); - const [installingBySlug, setInstallingBySlug] = useState>({}); + const [installingByStoreKey, setInstallingByStoreKey] = useState>({}); + const [pendingInstallKeys, setPendingInstallKeys] = useState>( + () => new Set(), + ); + const pendingInstallTokensRef = useRef(new Map()); const [deletingSkillName, setDeletingSkillName] = useState(null); const [externalScans, setExternalScans] = useState(null); const [externalLoading, setExternalLoading] = useState(false); @@ -548,37 +555,58 @@ export function SkillsHubPage(props: SkillsHubPageProps) { }; }, [previewInstalledSkill, t]); - const installedBySlug = useMemo(() => { + const installedStoreState = useMemo(() => { const installed = new Map(); + const slugs = new Set(); for (const skill of skills) { if (skill.source?.registry !== "clawhub") continue; const slug = skill.source.slug?.trim(); - if (slug) installed.set(slug, skill); + if (!slug) continue; + slugs.add(slug); + installed.set( + buildClawHubSkillKey({ slug, ownerHandle: skill.source.ownerHandle ?? null }), + skill, + ); } - return installed; + return { installed, slugs }; }, [skills]); - const completedInstallSlugs = useMemo(() => { + const completedInstallState = useMemo(() => { + const keys = new Set(); const slugs = new Set(); - for (const [slug, jobId] of Object.entries(installingBySlug)) { + for (const [storeKey, jobId] of Object.entries(installingByStoreKey)) { const job = installJobs[jobId]; - if (job?.phase === "done" && slug.trim()) { - slugs.add(slug.trim()); + if (job?.phase === "done") { + keys.add(storeKey); + if (job.slug?.trim()) slugs.add(job.slug.trim()); } } for (const job of Object.values(installJobs)) { if (job.phase === "done" && job.slug?.trim()) { slugs.add(job.slug.trim()); + keys.add( + buildClawHubSkillKey({ + slug: job.slug.trim(), + ownerHandle: job.ownerHandle ?? null, + }), + ); } } - return slugs; - }, [installJobs, installingBySlug]); + return { keys, slugs }; + }, [installJobs, installingByStoreKey]); + const installedStoreKeys = useMemo(() => { + const keys = new Set(installedStoreState.installed.keys()); + for (const key of completedInstallState.keys) { + keys.add(key); + } + return keys; + }, [completedInstallState.keys, installedStoreState.installed]); const installedStoreSlugs = useMemo(() => { - const slugs = new Set(installedBySlug.keys()); - for (const slug of completedInstallSlugs) { + const slugs = new Set(installedStoreState.slugs); + for (const slug of completedInstallState.slugs) { slugs.add(slug); } return slugs; - }, [completedInstallSlugs, installedBySlug]); + }, [completedInstallState.slugs, installedStoreState.slugs]); useEffect(() => { if (view !== "store" || lockedByChatMode) return; @@ -732,30 +760,76 @@ export function SkillsHubPage(props: SkillsHubPageProps) { } async function installStoreSkill(skill: ClawHubSkillCard) { - const existingJobId = installingBySlug[skill.slug]; - const existingJob = existingJobId ? installJobs[existingJobId] : undefined; + const initialStoreKey = buildClawHubSkillKey(skill); + const initialJobId = installingByStoreKey[initialStoreKey]; + const initialJob = initialJobId ? installJobs[initialJobId] : undefined; if ( lockedByChatMode || - installedStoreSlugs.has(skill.slug) || - (existingJob && !TERMINAL_INSTALL_PHASES.has(existingJob.phase)) + pendingInstallTokensRef.current.has(initialStoreKey) || + installedStoreKeys.has(initialStoreKey) || + (!skill.ownerHandle && installedStoreSlugs.has(skill.slug)) || + (initialJob && !TERMINAL_INSTALL_PHASES.has(initialJob.phase)) ) { return; } + + const pendingToken = Symbol(initialStoreKey); + pendingInstallTokensRef.current.set(initialStoreKey, pendingToken); + setPendingInstallKeys(new Set(pendingInstallTokensRef.current.keys())); setStoreError(null); try { + const resolvedSkill = await resolveClawHubSkillOwner(skill); + const storeKey = buildClawHubSkillKey(resolvedSkill); + const activePendingToken = pendingInstallTokensRef.current.get(storeKey); + if (activePendingToken && activePendingToken !== pendingToken) return; + if (storeKey !== initialStoreKey) { + pendingInstallTokensRef.current.set(storeKey, pendingToken); + setPendingInstallKeys(new Set(pendingInstallTokensRef.current.keys())); + } + setStoreItems((prev) => + prev.map((item) => + item.slug === resolvedSkill.slug && + item.updatedAt === resolvedSkill.updatedAt && + (!item.ownerHandle || item.ownerHandle === resolvedSkill.ownerHandle) + ? resolvedSkill + : item, + ), + ); + const existingJobId = installingByStoreKey[storeKey]; + const existingJob = existingJobId ? installJobs[existingJobId] : undefined; + if ( + installedStoreKeys.has(storeKey) || + (existingJob && !TERMINAL_INSTALL_PHASES.has(existingJob.phase)) + ) { + return; + } const job = await startSkillInstallJob({ - source: buildClawHubDownloadUrl(skill.slug, skill.ownerHandle), - label: skill.displayName, - slug: skill.slug, - ownerHandle: skill.ownerHandle, - version: skill.latestVersion, + source: buildClawHubDownloadUrl(resolvedSkill.slug, resolvedSkill.ownerHandle), + label: resolvedSkill.displayName, + slug: resolvedSkill.slug, + ownerHandle: resolvedSkill.ownerHandle, + version: resolvedSkill.latestVersion, conflict: "backup", }); setInstallJobs((prev) => ({ ...prev, [job.jobId]: job })); - setInstallingBySlug((prev) => ({ ...prev, [skill.slug]: job.jobId })); + setInstallingByStoreKey((prev) => ({ + ...prev, + [initialStoreKey]: job.jobId, + [storeKey]: job.jobId, + })); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setStoreError(msg || t("settings.skillsHubInstallFailed")); + } finally { + let changed = false; + for (const [storeKey, token] of pendingInstallTokensRef.current) { + if (token !== pendingToken) continue; + pendingInstallTokensRef.current.delete(storeKey); + changed = true; + } + if (changed) { + setPendingInstallKeys(new Set(pendingInstallTokensRef.current.keys())); + } } } @@ -763,6 +837,8 @@ export function SkillsHubPage(props: SkillsHubPageProps) { if (lockedByChatMode || isAlwaysEnabledSkillName(skill.name) || deletingSkillName) return; const skillName = skill.name; const sourceSlug = skill.source?.registry === "clawhub" ? skill.source.slug?.trim() || "" : ""; + const sourceOwnerHandle = + skill.source?.registry === "clawhub" ? skill.source.ownerHandle?.trim() || null : null; setLoadError(null); setDeletingSkillName(skillName); try { @@ -775,17 +851,24 @@ export function SkillsHubPage(props: SkillsHubPageProps) { setSkills((prev) => prev.filter((item) => item.name !== skillName)); setPreviewInstalledSkill((current) => (current?.name === skillName ? null : current)); if (sourceSlug) { - setInstallingBySlug((prev) => { - if (!(sourceSlug in prev)) return prev; + const sourceKey = buildClawHubSkillKey({ + slug: sourceSlug, + ownerHandle: sourceOwnerHandle, + }); + setInstallingByStoreKey((prev) => { + if (!(sourceKey in prev)) return prev; const next = { ...prev }; - delete next[sourceSlug]; + delete next[sourceKey]; return next; }); setInstallJobs((prev) => { let changed = false; const next = { ...prev }; for (const [jobId, job] of Object.entries(prev)) { - if (job.slug?.trim() === sourceSlug) { + if ( + job.slug?.trim() === sourceSlug && + (!sourceOwnerHandle || job.ownerHandle?.trim() === sourceOwnerHandle) + ) { delete next[jobId]; changed = true; } @@ -1339,8 +1422,10 @@ export function SkillsHubPage(props: SkillsHubPageProps) { loadingMore={storeLoadingMore} error={storeError} cursor={storeCursor} + installedKeys={installedStoreKeys} installedSlugs={installedStoreSlugs} - installingBySlug={installingBySlug} + pendingInstallKeys={pendingInstallKeys} + installingByStoreKey={installingByStoreKey} installJobs={installJobs} onSortChange={setStoreSort} onLoadMore={() => void loadMoreStore()} @@ -1924,8 +2009,10 @@ function SkillsStoreView(props: { loadingMore: boolean; error: string | null; cursor: string | null; + installedKeys: Set; installedSlugs: Set; - installingBySlug: Record; + pendingInstallKeys: ReadonlySet; + installingByStoreKey: Record; installJobs: Record; onSortChange: (value: ClawHubSort) => void; onLoadMore: () => void; @@ -1939,8 +2026,10 @@ function SkillsStoreView(props: { loadingMore, error, cursor, + installedKeys, installedSlugs, - installingBySlug, + pendingInstallKeys, + installingByStoreKey, installJobs, onSortChange, onLoadMore, @@ -1967,7 +2056,16 @@ function SkillsStoreView(props: { setPreviewError(null); setPreviewLoading(true); - void getClawHubSkillDetail(previewSkill.slug, previewSkill.ownerHandle) + void resolveClawHubSkillOwner(previewSkill) + .then((resolvedSkill) => { + if ( + !cancelled && + buildClawHubSkillKey(resolvedSkill) !== buildClawHubSkillKey(previewSkill) + ) { + setPreviewSkill(resolvedSkill); + } + return getClawHubSkillDetail(resolvedSkill.slug, resolvedSkill.ownerHandle); + }) .then((detail) => { if (!cancelled) { setPreviewDetail(detail); @@ -1991,16 +2089,22 @@ function SkillsStoreView(props: { }, [previewSkill, t]); function getInstallState(skill: ClawHubSkillCard): StoreSkillInstallState { - const jobId = installingBySlug[skill.slug]; + const storeKey = buildClawHubSkillKey(skill); + const pending = pendingInstallKeys.has(storeKey); + const jobId = installingByStoreKey[storeKey]; const job = jobId ? installJobs[jobId] : undefined; const terminalJob = Boolean(job && TERMINAL_INSTALL_PHASES.has(job.phase)); - const done = installedSlugs.has(skill.slug) || job?.phase === "done"; + const done = + installedKeys.has(storeKey) || + (!skill.ownerHandle && installedSlugs.has(skill.slug)) || + job?.phase === "done"; return { done, - installing: Boolean(job && !terminalJob), + installing: pending || Boolean(job && !terminalJob), + pending, terminalJob, job, - progress: job ? getInstallProgressPercent(job) : null, + progress: pending ? null : job ? getInstallProgressPercent(job) : null, }; } @@ -2113,13 +2217,13 @@ function SkillsStoreView(props: { )} > {items.map((skill) => { - const { done, installing, terminalJob, job, progress } = getInstallState(skill); + const { done, installing, pending, job, progress } = getInstallState(skill); const link = buildClawHubSkillUrl(skill); return ( // biome-ignore lint/a11y/useSemanticElements: The card contains nested controls and cannot be a native button.
- {job && !done && !terminalJob ? ( + {installing && !done ? (
- {installPhaseLabel(job, t)} - - {formatInstallProgress(job)} - - + {installPhaseLabel(pending ? undefined : job, t)} + {job && !pending ? ( + + {formatInstallProgress(job)} + + + ) : null}
{progress === null ? ( @@ -2242,7 +2348,7 @@ function SkillsStoreView(props: {
) : null} - {job?.phase === "error" && job.error && !done ? ( + {job?.phase === "error" && job.error && !done && !pending ? (
{job.error}
@@ -2258,6 +2364,7 @@ function SkillsStoreView(props: { "border-border/55 bg-background/75 text-foreground/85 backdrop-blur-md", )} disabled={done || installing} + aria-busy={installing} onClick={(event) => { event.stopPropagation(); onInstall(skill); @@ -2272,7 +2379,7 @@ function SkillsStoreView(props: { )} {installing - ? installPhaseLabel(job, t) + ? installPhaseLabel(pending ? undefined : job, t) : done ? t("settings.skillsStoreInstalled") : t("settings.skillsStoreInstall")} @@ -2336,7 +2443,7 @@ function SkillsStorePreviewDrawer(props: { const supportedOs = detail?.supportedOs ?? []; const supportedSystems = detail?.supportedSystems ?? []; const actionLabel = installState.installing - ? installPhaseLabel(installState.job, t) + ? installPhaseLabel(installState.pending ? undefined : installState.job, t) : installState.done ? t("settings.skillsStoreInstalled") : t("settings.skillsStoreInstall"); @@ -2438,11 +2545,15 @@ function SkillsStorePreviewDrawer(props: { />
- {installState.job && installState.installing ? ( + {installState.installing && !installState.done ? (
- {installPhaseLabel(installState.job, t)} - {formatInstallProgress(installState.job)} + + {installPhaseLabel(installState.pending ? undefined : installState.job, t)} + + {installState.job && !installState.pending ? ( + {formatInstallProgress(installState.job)} + ) : null}
{installState.progress === null ? ( @@ -2457,7 +2568,10 @@ function SkillsStorePreviewDrawer(props: {
) : null} - {installState.job?.phase === "error" && installState.job.error && !installState.done ? ( + {installState.job?.phase === "error" && + installState.job.error && + !installState.done && + !installState.pending ? (
{installState.job.error}
@@ -2570,6 +2684,7 @@ function SkillsStorePreviewDrawer(props: { "border-border/55 bg-background/75 text-foreground/85 backdrop-blur-md", )} disabled={installState.done || installState.installing} + aria-busy={installState.installing} onClick={onInstall} > {installState.installing ? ( @@ -2645,8 +2760,9 @@ function StorePreviewField(props: { label: string; value?: string | null }) { function dedupeStoreItems(items: ClawHubSkillCard[]) { const seen = new Set(); return items.filter((item) => { - if (seen.has(item.slug)) return false; - seen.add(item.slug); + const storeKey = buildClawHubSkillKey(item); + if (seen.has(storeKey)) return false; + seen.add(storeKey); return true; }); } diff --git a/crates/agent-gui/test/skills/clawhub-contract.test.mjs b/crates/agent-gui/test/skills/clawhub-contract.test.mjs new file mode 100644 index 000000000..80025df76 --- /dev/null +++ b/crates/agent-gui/test/skills/clawhub-contract.test.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const clawHub = loader.loadModule("src/lib/skills/clawHub.ts"); + +test("normalizes current ClawHub list and search response shapes", () => { + const listCard = clawHub.normalizeClawHubSkillCard({ + slug: "github", + displayName: "Github", + summary: "GitHub CLI workflows", + latestVersion: { version: "1.0.0" }, + stats: { downloads: 193172, stars: 652, installs: 7621 }, + updatedAt: 1781268517834, + }); + const searchCard = clawHub.normalizeClawHubSkillCard({ + slug: "github", + displayName: "Github", + summary: "GitHub CLI workflows", + version: "1.0.0", + downloads: 193172, + updatedAt: 1781268517834, + ownerHandle: "steipete", + owner: { handle: "steipete", displayName: "Peter Steinberger" }, + }); + + assert.equal(listCard.ownerHandle, null); + assert.equal(listCard.downloads, 193172); + assert.equal(listCard.installsCurrent, 7621); + assert.equal(searchCard.ownerHandle, "steipete"); + assert.equal(searchCard.latestVersion, "1.0.0"); + assert.equal(searchCard.downloads, 193172); + assert.match(searchCard.downloadUrl, /ownerHandle=steipete/); +}); + +test("selects the publisher matching the unscoped catalog card", () => { + const catalogCard = clawHub.normalizeClawHubSkillCard({ + slug: "github", + displayName: "Github", + summary: "GitHub CLI workflows", + latestVersion: { version: "1.0.0" }, + stats: { downloads: 193172 }, + updatedAt: 1781268517834, + }); + const candidates = [ + { + ...catalogCard, + ownerHandle: "steipete", + updatedAt: 1781268517834, + }, + { + ...catalogCard, + ownerHandle: "eohmig", + downloads: 8, + updatedAt: 1782439133368, + }, + ]; + + const selected = clawHub.selectClawHubOwnerCandidate(catalogCard, candidates); + + assert.equal(selected.ownerHandle, "steipete"); +}); + +test("uses publisher and slug as the store identity", () => { + assert.notEqual( + clawHub.buildClawHubSkillKey({ slug: "github", ownerHandle: "steipete" }), + clawHub.buildClawHubSkillKey({ slug: "github", ownerHandle: "eohmig" }), + ); + assert.equal( + clawHub.buildClawHubSkillKey({ slug: "GitHub", ownerHandle: "@STEIPETE" }), + "clawhub:steipete/github", + ); +}); diff --git a/crates/agent-gui/test/skills/skills-store-install-feedback.test.mjs b/crates/agent-gui/test/skills/skills-store-install-feedback.test.mjs new file mode 100644 index 000000000..6361beaac --- /dev/null +++ b/crates/agent-gui/test/skills/skills-store-install-feedback.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const pageSources = [ + { + label: "GUI", + source: readFileSync( + new URL("../../src/pages/skills-hub/SkillsHubPage.tsx", import.meta.url), + "utf8", + ), + }, + { + label: "WebUI", + source: readFileSync( + new URL( + "../../../agent-gateway/web/src/pages/skills-hub/SkillsHubPage.tsx", + import.meta.url, + ), + "utf8", + ), + }, +]; + +function installFunctionSource(source) { + const start = source.indexOf(" async function installStoreSkill("); + const end = source.indexOf("\n async function deleteSkill(", start); + assert.notEqual(start, -1, "installStoreSkill must exist"); + assert.notEqual(end, -1, "installStoreSkill must end before deleteSkill"); + return source.slice(start, end); +} + +for (const { label, source } of pageSources) { + test(`${label} shows pending install feedback before owner resolution`, () => { + const installSource = installFunctionSource(source); + const pendingClaim = installSource.indexOf( + "pendingInstallTokensRef.current.set(initialStoreKey, pendingToken);", + ); + const ownerResolution = installSource.indexOf("await resolveClawHubSkillOwner(skill);"); + + assert.notEqual(pendingClaim, -1); + assert.notEqual(ownerResolution, -1); + assert.ok(pendingClaim < ownerResolution, "pending state must be claimed before the first await"); + assert.match( + installSource, + /pendingInstallTokensRef\.current\.has\(initialStoreKey\)/, + ); + assert.match(installSource, /finally \{[\s\S]*token !== pendingToken[\s\S]*delete\(storeKey\)/); + }); + + test(`${label} renders pending progress in cards and the preview drawer`, () => { + assert.match(source, /pendingInstallKeys=\{pendingInstallKeys\}/); + assert.match(source, /installing: pending \|\| Boolean\(job && !terminalJob\)/); + assert.match(source, /\{installing && !done \? \(/); + assert.match(source, /installState\.installing && !installState\.done/); + assert.match(source, /aria-busy=\{installing\}/); + assert.match(source, /aria-busy=\{installState\.installing\}/); + }); +} diff --git a/docs/features/skills-and-mcp.md b/docs/features/skills-and-mcp.md index 47ab1a026..f81975aae 100644 --- a/docs/features/skills-and-mcp.md +++ b/docs/features/skills-and-mcp.md @@ -35,6 +35,16 @@ 另有 UI 专用的后台安装 job 动作(不在 agent 工具 schema 内):`install_start` 启动带进度的后台安装线程、`install_status` 轮询快照、`install_cancel` 协作式取消(下载与逐 skill 安装循环检查取消标记,终态为 `phase: "cancelled"`)。 +## ClawHub 兼容边界 + +| 场景 | 处理规则 | +|---|---| +| Store identity | ClawHub Skill 以 `ownerHandle + slug` 作为唯一身份;React key、安装任务、已安装状态和 `_meta.json` 回读不得只按 slug 合并。 | +| list 缺 owner | `/api/v1/skills` 条目缺少发布者时,详情/安装前通过精确搜索按 `updatedAt`、version、downloads 等字段懒解析 owner;无法唯一匹配时明确失败,不盲选发布者。 | +| 下载/详情 | 所有已解析的详情和 `/api/v1/download` 请求都携带 `ownerHandle`,避免重名 slug 返回 HTTP 409。 | +| 非便携名称 | 仍严格执行 Agent Skills 小写名称规范;只有 ClawHub 单 Skill 包的非法名称归一化后与 registry slug 完全一致时,才改写临时副本并把原名、规范名和转换类型写入 `_meta.json`。 | +| 原始内容 | 名称兼容转换只发生在下载临时目录,不修改注册表下载包;其他名称不匹配继续按严格校验拒绝。 | + ## Skills 选择与 Prompt 注入 | 阶段 | 说明 | @@ -82,7 +92,7 @@ Registry card 会被归一化为统一的 `McpRegistryCard`,其中 `installDra | 区域 | 注意事项 | |---|---| -| Skills Hub | GUI/WebUI 都有 installed/store、preview drawer、install job 状态和 installedBySlug 推导。 | +| Skills Hub | GUI/WebUI 都有 installed/store、preview drawer、install job 状态,并以 `ownerHandle + slug` 推导 ClawHub 安装身份。 | | MCP Hub | GUI/WebUI 都有 server form、registry browser、preview drawer、install draft。 | | i18n | 双端有各自 `i18n/config.ts`,新增文案要同步。 | | settings sync | Skills/MCP settings 从 GUI 经 Gateway 同步到 WebUI,WebUI 修改再回写 GUI。 | From 023272d3aee2f7da40db9b3336169e3e15f92d91 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 16 Jul 2026 12:42:31 +0800 Subject: [PATCH 4/7] feat(file-tree): add hidden file visibility toggle --- .../internal/proto/v1/gateway.pb.go | 54 +++- .../internal/server/websocket_fs_handlers.go | 2 + .../internal/server/websocket_payload_test.go | 5 +- .../server/websocket_skills_handlers.go | 7 +- crates/agent-gateway/proto/v1/gateway.proto | 4 + .../project-tools/file-tree/ContextMenu.tsx | 22 ++ .../project-tools/file-tree/Row.tsx | 12 +- .../project-tools/file-tree/index.tsx | 10 +- .../project-tools/file-tree/model.ts | 15 +- .../file-tree/useFileTreeData.ts | 12 + crates/agent-gateway/web/src/i18n/config.ts | 4 + .../web/src/lib/gatewaySocket.ts | 10 +- .../web/src/lib/settings/index.ts | 5 + .../agent-gateway/web/src/shims/tauriCore.ts | 2 + .../src-tauri/src/commands/workspace/fs.rs | 285 ++++++++++++++++-- .../src-tauri/src/services/gateway_bridge.rs | 18 +- .../project-tools/file-tree/ContextMenu.tsx | 22 ++ .../project-tools/file-tree/Row.tsx | 12 +- .../project-tools/file-tree/index.tsx | 10 +- .../project-tools/file-tree/model.ts | 15 +- .../file-tree/useFileTreeData.ts | 12 + crates/agent-gui/src/i18n/config.ts | 4 + crates/agent-gui/src/lib/settings/index.ts | 5 + 23 files changed, 496 insertions(+), 51 deletions(-) diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index e348a30be..a2f4a1305 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -8974,6 +8974,7 @@ type FileMentionListRequest struct { Workdir string `protobuf:"bytes,1,opt,name=workdir,proto3" json:"workdir,omitempty"` MaxResults uint32 `protobuf:"varint,2,opt,name=max_results,json=maxResults,proto3" json:"max_results,omitempty"` Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"` + ShowHidden *bool `protobuf:"varint,4,opt,name=show_hidden,json=showHidden,proto3,oneof" json:"show_hidden,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9029,10 +9030,18 @@ func (x *FileMentionListRequest) GetQuery() string { return "" } +func (x *FileMentionListRequest) GetShowHidden() bool { + if x != nil && x.ShowHidden != nil { + return *x.ShowHidden + } + return false +} + type FileMentionEntry struct { state protoimpl.MessageState `protogen:"open.v1"` Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Hidden bool `protobuf:"varint,3,opt,name=hidden,proto3" json:"hidden,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9081,6 +9090,13 @@ func (x *FileMentionEntry) GetKind() string { return "" } +func (x *FileMentionEntry) GetHidden() bool { + if x != nil { + return x.Hidden + } + return false +} + type FileMentionListResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Entries []*FileMentionEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` @@ -9548,6 +9564,7 @@ type FsListRequest struct { Depth uint32 `protobuf:"varint,3,opt,name=depth,proto3" json:"depth,omitempty"` Offset uint32 `protobuf:"varint,4,opt,name=offset,proto3" json:"offset,omitempty"` MaxResults uint32 `protobuf:"varint,5,opt,name=max_results,json=maxResults,proto3" json:"max_results,omitempty"` + ShowHidden *bool `protobuf:"varint,6,opt,name=show_hidden,json=showHidden,proto3,oneof" json:"show_hidden,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9617,10 +9634,18 @@ func (x *FsListRequest) GetMaxResults() uint32 { return 0 } +func (x *FsListRequest) GetShowHidden() bool { + if x != nil && x.ShowHidden != nil { + return *x.ShowHidden + } + return false +} + type FsListEntry struct { state protoimpl.MessageState `protogen:"open.v1"` Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Hidden bool `protobuf:"varint,3,opt,name=hidden,proto3" json:"hidden,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -9669,6 +9694,13 @@ func (x *FsListEntry) GetKind() string { return "" } +func (x *FsListEntry) GetHidden() bool { + if x != nil { + return x.Hidden + } + return false +} + type FsListResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` @@ -11447,15 +11479,19 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\fpayload_json\x18\x01 \x01(\tR\vpayloadJson\"6\n" + "\x13SkillManageResponse\x12\x1f\n" + "\vresult_json\x18\x01 \x01(\tR\n" + - "resultJson\"i\n" + + "resultJson\"\x9f\x01\n" + "\x16FileMentionListRequest\x12\x18\n" + "\aworkdir\x18\x01 \x01(\tR\aworkdir\x12\x1f\n" + "\vmax_results\x18\x02 \x01(\rR\n" + "maxResults\x12\x14\n" + - "\x05query\x18\x03 \x01(\tR\x05query\":\n" + + "\x05query\x18\x03 \x01(\tR\x05query\x12$\n" + + "\vshow_hidden\x18\x04 \x01(\bH\x00R\n" + + "showHidden\x88\x01\x01B\x0e\n" + + "\f_show_hidden\"R\n" + "\x10FileMentionEntry\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + - "\x04kind\x18\x02 \x01(\tR\x04kind\"y\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x16\n" + + "\x06hidden\x18\x03 \x01(\bR\x06hidden\"y\n" + "\x17FileMentionListResponse\x12@\n" + "\aentries\x18\x01 \x03(\v2&.liveagent.gateway.v1.FileMentionEntryR\aentries\x12\x1c\n" + "\ttruncated\x18\x02 \x01(\bR\ttruncated\"V\n" + @@ -11483,17 +11519,21 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\x06parent\x18\x01 \x01(\tR\x06parent\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\"3\n" + "\x1dFsCreateProjectFolderResponse\x12\x12\n" + - "\x04path\x18\x01 \x01(\tR\x04path\"\x8c\x01\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"\xc2\x01\n" + "\rFsListRequest\x12\x18\n" + "\aworkdir\x18\x01 \x01(\tR\aworkdir\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x14\n" + "\x05depth\x18\x03 \x01(\rR\x05depth\x12\x16\n" + "\x06offset\x18\x04 \x01(\rR\x06offset\x12\x1f\n" + "\vmax_results\x18\x05 \x01(\rR\n" + - "maxResults\"5\n" + + "maxResults\x12$\n" + + "\vshow_hidden\x18\x06 \x01(\bH\x00R\n" + + "showHidden\x88\x01\x01B\x0e\n" + + "\f_show_hidden\"M\n" + "\vFsListEntry\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + - "\x04kind\x18\x02 \x01(\tR\x04kind\"\xfc\x01\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x16\n" + + "\x06hidden\x18\x03 \x01(\bR\x06hidden\"\xfc\x01\n" + "\x0eFsListResponse\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x19\n" + "\bhas_path\x18\x02 \x01(\bR\ahasPath\x12\x14\n" + @@ -12025,6 +12065,8 @@ func file_proto_v1_gateway_proto_init() { file_proto_v1_gateway_proto_msgTypes[17].OneofWrappers = []any{} file_proto_v1_gateway_proto_msgTypes[25].OneofWrappers = []any{} file_proto_v1_gateway_proto_msgTypes[76].OneofWrappers = []any{} + file_proto_v1_gateway_proto_msgTypes[103].OneofWrappers = []any{} + file_proto_v1_gateway_proto_msgTypes[114].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/crates/agent-gateway/internal/server/websocket_fs_handlers.go b/crates/agent-gateway/internal/server/websocket_fs_handlers.go index cb5ea909f..9c8c7b3f5 100644 --- a/crates/agent-gateway/internal/server/websocket_fs_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_fs_handlers.go @@ -176,6 +176,7 @@ func (c *websocketConnection) handleFsList(req websocketRequest) { Depth *int `json:"depth"` Offset *int `json:"offset"` MaxResults *int `json:"max_results"` + ShowHidden *bool `json:"show_hidden"` } var body payload @@ -216,6 +217,7 @@ func (c *websocketConnection) handleFsList(req websocketRequest) { Depth: depth, Offset: offset, MaxResults: maxResults, + ShowHidden: body.ShowHidden, }, }, }) diff --git a/crates/agent-gateway/internal/server/websocket_payload_test.go b/crates/agent-gateway/internal/server/websocket_payload_test.go index 8a7efdcef..fb7b96997 100644 --- a/crates/agent-gateway/internal/server/websocket_payload_test.go +++ b/crates/agent-gateway/internal/server/websocket_payload_test.go @@ -198,7 +198,7 @@ func TestWebsocketFsPayloadsUseFrontendFieldNames(t *testing.T) { Total: 3, HasMore: true, Entries: []*gatewayv1.FsListEntry{ - {Path: "src/components", Kind: "dir"}, + {Path: "src/components", Kind: "dir", Hidden: true}, {Path: "src/app.tsx", Kind: "file"}, }, }) @@ -218,6 +218,9 @@ func TestWebsocketFsPayloadsUseFrontendFieldNames(t *testing.T) { if entries[0]["path"] != "src/components" || entries[0]["kind"] != "dir" { t.Fatalf("fs.list first entry = %#v", entries[0]) } + if entries[0]["hidden"] != true || entries[1]["hidden"] != false { + t.Fatalf("fs.list hidden flags = %#v", entries) + } readEditable := websocketFsReadEditableTextResponsePayload(&gatewayv1.FsReadEditableTextResponse{ Path: "src/main.ts", diff --git a/crates/agent-gateway/internal/server/websocket_skills_handlers.go b/crates/agent-gateway/internal/server/websocket_skills_handlers.go index c140cfcf6..920729287 100644 --- a/crates/agent-gateway/internal/server/websocket_skills_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_skills_handlers.go @@ -43,6 +43,7 @@ func (c *websocketConnection) handleFileMentionList(req websocketRequest) { Workdir string `json:"workdir"` MaxResults *int `json:"max_results"` Query string `json:"query"` + ShowHidden *bool `json:"show_hidden"` } var body payload @@ -72,6 +73,7 @@ func (c *websocketConnection) handleFileMentionList(req websocketRequest) { Workdir: workdir, MaxResults: maxResults, Query: query, + ShowHidden: body.ShowHidden, }, }, }) @@ -93,8 +95,9 @@ func (c *websocketConnection) handleFileMentionList(req websocketRequest) { entries := make([]map[string]any, 0, len(resp.GetEntries())) for _, entry := range resp.GetEntries() { entries = append(entries, map[string]any{ - "path": entry.GetPath(), - "kind": entry.GetKind(), + "path": entry.GetPath(), + "kind": entry.GetKind(), + "hidden": entry.GetHidden(), }) } diff --git a/crates/agent-gateway/proto/v1/gateway.proto b/crates/agent-gateway/proto/v1/gateway.proto index d4b70b0a8..c35bd1821 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -915,11 +915,13 @@ message FileMentionListRequest { string workdir = 1; uint32 max_results = 2; string query = 3; + optional bool show_hidden = 4; } message FileMentionEntry { string path = 1; string kind = 2; + bool hidden = 3; } message FileMentionListResponse { @@ -969,11 +971,13 @@ message FsListRequest { uint32 depth = 3; uint32 offset = 4; uint32 max_results = 5; + optional bool show_hidden = 6; } message FsListEntry { string path = 1; string kind = 2; + bool hidden = 3; } message FsListResponse { diff --git a/crates/agent-gateway/web/src/components/project-tools/file-tree/ContextMenu.tsx b/crates/agent-gateway/web/src/components/project-tools/file-tree/ContextMenu.tsx index d8005b67a..2fc22c268 100644 --- a/crates/agent-gateway/web/src/components/project-tools/file-tree/ContextMenu.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/file-tree/ContextMenu.tsx @@ -21,6 +21,7 @@ import { Edit3, ExternalLink, Eye, + EyeOff, FilePenLine, Folder, FolderOpen, @@ -70,6 +71,7 @@ export type FileTreeContextMenuProps = { canMutate: boolean; canOpenFile: boolean; canInsertMention: boolean; + showHidden: boolean; onClose: () => void; onOpenFile: (path: string) => void; onOpenExternal: (path: string) => void; @@ -78,6 +80,7 @@ export type FileTreeContextMenuProps = { onDelete: (path: string) => void; onInsertMention: (path: string) => void; onRefresh: (path: string, kind: FileTreeKind) => void; + onToggleHidden: () => void; onActionError: (message: string) => void; }; @@ -90,6 +93,7 @@ export function FileTreeContextMenu(props: FileTreeContextMenuProps) { canMutate, canOpenFile, canInsertMention, + showHidden, onClose, onOpenFile, onOpenExternal, @@ -98,6 +102,7 @@ export function FileTreeContextMenu(props: FileTreeContextMenuProps) { onDelete, onInsertMention, onRefresh, + onToggleHidden, onActionError, } = props; const { t } = useLocale(); @@ -277,6 +282,23 @@ export function FileTreeContextMenu(props: FileTreeContextMenuProps) { {t("projectTools.fileTree.delete")}
+
+