diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index e348a30be..467855934 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -6902,21 +6902,22 @@ func (x *HistoryListResponse) GetTotalCount() int32 { } type ConversationSummary struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` - CreatedAt int64 `protobuf:"varint,3,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - UpdatedAt int64 `protobuf:"varint,4,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - MessageCount int32 `protobuf:"varint,5,opt,name=message_count,json=messageCount,proto3" json:"message_count,omitempty"` - ProviderId string `protobuf:"bytes,6,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` - Model string `protobuf:"bytes,7,opt,name=model,proto3" json:"model,omitempty"` - SessionId string `protobuf:"bytes,8,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - Cwd string `protobuf:"bytes,9,opt,name=cwd,proto3" json:"cwd,omitempty"` - IsPinned bool `protobuf:"varint,10,opt,name=is_pinned,json=isPinned,proto3" json:"is_pinned,omitempty"` - PinnedAt int64 `protobuf:"varint,11,opt,name=pinned_at,json=pinnedAt,proto3" json:"pinned_at,omitempty"` - IsShared bool `protobuf:"varint,12,opt,name=is_shared,json=isShared,proto3" json:"is_shared,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` + CreatedAt int64 `protobuf:"varint,3,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt int64 `protobuf:"varint,4,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + MessageCount int32 `protobuf:"varint,5,opt,name=message_count,json=messageCount,proto3" json:"message_count,omitempty"` + ProviderId string `protobuf:"bytes,6,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + Model string `protobuf:"bytes,7,opt,name=model,proto3" json:"model,omitempty"` + SessionId string `protobuf:"bytes,8,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Cwd string `protobuf:"bytes,9,opt,name=cwd,proto3" json:"cwd,omitempty"` + IsPinned bool `protobuf:"varint,10,opt,name=is_pinned,json=isPinned,proto3" json:"is_pinned,omitempty"` + PinnedAt int64 `protobuf:"varint,11,opt,name=pinned_at,json=pinnedAt,proto3" json:"pinned_at,omitempty"` + IsShared bool `protobuf:"varint,12,opt,name=is_shared,json=isShared,proto3" json:"is_shared,omitempty"` + SelectedModelJson string `protobuf:"bytes,13,opt,name=selected_model_json,json=selectedModelJson,proto3" json:"selected_model_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ConversationSummary) Reset() { @@ -7033,6 +7034,13 @@ func (x *ConversationSummary) GetIsShared() bool { return false } +func (x *ConversationSummary) GetSelectedModelJson() string { + if x != nil { + return x.SelectedModelJson + } + return "" +} + type HistoryGetRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` @@ -8974,6 +8982,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 +9038,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 +9098,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 +9572,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 +9642,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 +9702,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"` @@ -11315,7 +11355,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\x13HistoryListResponse\x12O\n" + "\rconversations\x18\x01 \x03(\v2).liveagent.gateway.v1.ConversationSummaryR\rconversations\x12\x1f\n" + "\vtotal_count\x18\x02 \x01(\x05R\n" + - "totalCount\"\xdd\x02\n" + + "totalCount\"\x8d\x03\n" + "\x13ConversationSummary\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + "\x05title\x18\x02 \x01(\tR\x05title\x12\x1d\n" + @@ -11333,7 +11373,8 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\tis_pinned\x18\n" + " \x01(\bR\bisPinned\x12\x1b\n" + "\tpinned_at\x18\v \x01(\x03R\bpinnedAt\x12\x1b\n" + - "\tis_shared\x18\f \x01(\bR\bisShared\"_\n" + + "\tis_shared\x18\f \x01(\bR\bisShared\x12.\n" + + "\x13selected_model_json\x18\r \x01(\tR\x11selectedModelJson\"_\n" + "\x11HistoryGetRequest\x12'\n" + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12!\n" + "\fmax_messages\x18\x02 \x01(\x05R\vmaxMessages\"\xb2\x02\n" + @@ -11447,15 +11488,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 +11528,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 +12074,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..3d984de84 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -729,6 +729,7 @@ message ConversationSummary { bool is_pinned = 10; int64 pinned_at = 11; bool is_shared = 12; + string selected_model_json = 13; } message HistoryGetRequest { @@ -915,11 +916,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 +972,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/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index 0dac60d4e..1b66d742c 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -72,6 +72,7 @@ test("web settings normalization canonicalizes project keyed maps with Windows p query: "legacy", selectedPath: "src\\main.ts", expandedPaths: ["", "src", "src\\components", "src"], + showHidden: true, revision: 2, }, }, @@ -108,6 +109,7 @@ test("web settings normalization canonicalizes project keyed maps with Windows p query: "legacy", selectedPath: "src/main.ts", expandedPaths: ["", "src", "src/components"], + showHidden: true, revision: 2, }, }, @@ -205,6 +207,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( { @@ -1079,6 +1122,7 @@ test("gateway settings sync keeps right dock width local and syncs project state query: "desktop", selectedPath: "desktop.ts", expandedPaths: ["", "src"], + showHidden: true, revision: 1, stateVersion: 3, }, @@ -1151,6 +1195,7 @@ test("gateway settings sync keeps right dock width local and syncs project state query: "desktop", selectedPath: "desktop.ts", expandedPaths: ["", "src"], + showHidden: true, revision: 1, }, ); diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 078faee40..4c8a02a02 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -77,11 +77,14 @@ import { isThinkingAlwaysOnForModel, normalizeChatRuntimeControlsForProvider, openRightDockSingletonTab, + parseSelectedModelJson, type RightDockFileTreeStatePatch, type RightDockProjectState, removeRightDockProjectState, resolveEffectiveTheme, resolveWorkspaceProjects, + type SelectedModel, + setSelectedModel, updateChatRuntimeControlsForProvider, updateCustomSettings, updateRightDockFileTreeState, @@ -144,6 +147,7 @@ import { isChatEventTitleFinal, readChatEventTitle, readTunnelManagerToolChange, + resolveActiveModelSelection, } from "./chatEventUtils"; import { CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS, @@ -223,6 +227,11 @@ export default function GatewayApp() { // currently authenticated browser-socket epoch. const [sidebarAgentStatusFresh, setSidebarAgentStatusFresh] = useState(false); const [conversationId, setConversationId] = useState(""); + // 本地未持久化的会话模型切换(按会话 id 键);发消息随 selected_model + // 落库后由 history-sync 回声在清理 effect 中收敛删除。 + const [conversationModelOverrides, setConversationModelOverrides] = useState< + ReadonlyMap + >(new Map()); const [chatError, setChatError] = useState(null); // Sidebar errors raised outside the sidebar store (project removal flow). const [sidebarActionError, setSidebarActionError] = useState(null); @@ -812,9 +821,20 @@ export default function GatewayApp() { isPinned: existingNext?.isPinned ?? draftRow.isPinned, pinnedAt: existingNext ? existingNext.pinnedAt : draftRow.pinnedAt, isShared: existingNext?.isShared ?? draftRow.isShared, + selectedModelJson: existingNext?.selectedModelJson || draftRow.selectedModelJson, isPending: existingNext && existingNext.isPending !== true ? undefined : true, }); } + // Re-key the local model override so the pick made on the draft keeps + // applying to the bound conversation. + setConversationModelOverrides((prev) => { + const override = prev.get(previousId); + if (!override) return prev; + const next = new Map(prev); + next.delete(previousId); + if (!next.has(nextId)) next.set(nextId, override); + return next; + }); }, [moveConversationUploads, sidebarStore, transcriptStoreRegistry], ); @@ -1436,6 +1456,47 @@ export default function GatewayApp() { // regardless of running state, which is what makes GUI queue auto-sends // race-free: the next run's events simply flow in). const displayedConversationId = resolveVisibleConversationId(selectedHistoryId, conversationId); + + // 会话生效模型:本地 override > sidebar 行携带的持久化选择 > 全局默认。 + const selectionForConversation = useCallback( + (targetConversationId: string) => + resolveActiveModelSelection({ + settings, + override: conversationModelOverrides.get(targetConversationId), + persistedSelectedModelJson: sidebarStore.peek(targetConversationId)?.selectedModelJson, + }), + [conversationModelOverrides, settings, sidebarStore], + ); + const activeSelectedModel = useMemo( + () => + resolveActiveModelSelection({ + settings, + override: conversationModelOverrides.get(displayedConversationId), + persistedSelectedModelJson: + sidebarConversationsById.get(displayedConversationId)?.selectedModelJson, + }), + [conversationModelOverrides, displayedConversationId, settings, sidebarConversationsById], + ); + // override 的持久化回声(本会话 selected_model 落库后随 history-sync 回流) + // 到达即清理,会话从此走服务器权威值。 + useEffect(() => { + if (conversationModelOverrides.size === 0) return; + let changed = false; + const next = new Map(conversationModelOverrides); + for (const [id, override] of conversationModelOverrides) { + const persisted = parseSelectedModelJson(sidebarConversationsById.get(id)?.selectedModelJson); + if ( + persisted && + persisted.customProviderId === override.customProviderId && + persisted.model === override.model + ) { + next.delete(id); + changed = true; + } + } + if (changed) setConversationModelOverrides(next); + }, [conversationModelOverrides, sidebarConversationsById]); + const { transcript: displayedTranscript, busy: displayedConversationBusy } = useConversationChat({ api, conversationId: displayedConversationId || null, @@ -1765,6 +1826,7 @@ export default function GatewayApp() { if (isDisplayedConversation(activeConversationId)) { transcriptFollow.stickToBottom(); } + const turnSelectedModel = selectionForConversation(activeConversationId); if (startedAsDraftConversation) { draftClientRequestsRef.current.set(clientRequestId, activeConversationId); // Optimistic pending sidebar row: survives authoritative reconciles @@ -1772,8 +1834,8 @@ export default function GatewayApp() { sidebarStore.upsertLocal({ id: activeConversationId, title: buildOptimisticConversationTitle(message), - providerId: settings.selectedModel?.customProviderId ?? "", - model: settings.selectedModel?.model ?? "", + providerId: turnSelectedModel?.customProviderId ?? "", + model: turnSelectedModel?.model ?? "", cwd: effectiveWorkdir || undefined, messageCount: 1, createdAt: startedAt, @@ -1787,14 +1849,14 @@ export default function GatewayApp() { { providerId: currentChatProvider?.type, requestFormat: currentChatProvider?.requestFormat, - modelId: settings.selectedModel?.model, + modelId: turnSelectedModel?.model, }, ); const commandInput: GatewayChatCommandInput = { type: options?.editMessageRef ? "chat.edit_resend" : "chat.submit", message, conversationId: startedAsDraftConversation ? undefined : activeConversationId, - selectedModel: buildGatewaySelectedModel(settings.selectedModel, activeProviders), + selectedModel: buildGatewaySelectedModel(turnSelectedModel, activeProviders), systemSettings: buildGatewaySystemSettings(settings, effectiveWorkdir), uploadedFiles, clientRequestId, @@ -1955,7 +2017,10 @@ export default function GatewayApp() { conversationId: isLocalDraftConversationId(conversationIdValue) ? undefined : conversationIdValue, - selectedModel: buildGatewaySelectedModel(settings.selectedModel, activeProviders), + selectedModel: buildGatewaySelectedModel( + selectionForConversation(conversationIdValue), + activeProviders, + ), systemSettings: buildGatewaySystemSettings(settings, workdirForTurn), uploadedFiles: materialized.uploadedFiles, clientRequestId: crypto.randomUUID(), @@ -3055,65 +3120,63 @@ export default function GatewayApp() { ); const currentModelLabel = useMemo(() => { - if (!settings.selectedModel) { + if (!activeSelectedModel) { return "选择模型"; } const provider = activeProviders.find( - (item) => item.id === settings.selectedModel?.customProviderId, + (item) => item.id === activeSelectedModel.customProviderId, ); - return provider - ? `${provider.name} / ${settings.selectedModel.model}` - : settings.selectedModel.model; - }, [activeProviders, settings.selectedModel]); + return provider ? `${provider.name} / ${activeSelectedModel.model}` : activeSelectedModel.model; + }, [activeProviders, activeSelectedModel]); const currentModelContextWindow = useMemo(() => { - if (!settings.selectedModel) { + if (!activeSelectedModel) { return undefined; } const provider = settings.customProviders.find( - (item) => item.id === settings.selectedModel?.customProviderId, + (item) => item.id === activeSelectedModel.customProviderId, ); if (!provider) { return undefined; } - return findProviderModelConfig(provider, settings.selectedModel.model).contextWindow; - }, [settings.customProviders, settings.selectedModel]); + return findProviderModelConfig(provider, activeSelectedModel.model).contextWindow; + }, [settings.customProviders, activeSelectedModel]); const currentChatProvider = useMemo(() => { - if (!settings.selectedModel) { + if (!activeSelectedModel) { return undefined; } return settings.customProviders.find( - (item) => item.id === settings.selectedModel?.customProviderId, + (item) => item.id === activeSelectedModel.customProviderId, ); - }, [settings.customProviders, settings.selectedModel]); + }, [settings.customProviders, activeSelectedModel]); const chatRuntimeReasoningOptions = useMemo( () => getChatRuntimeReasoningLevelsForProvider({ providerId: currentChatProvider?.type, requestFormat: currentChatProvider?.requestFormat, - modelId: settings.selectedModel?.model, + modelId: activeSelectedModel?.model, }), - [currentChatProvider?.requestFormat, currentChatProvider?.type, settings.selectedModel?.model], + [currentChatProvider?.requestFormat, currentChatProvider?.type, activeSelectedModel?.model], ); const chatRuntimeThinkingAlwaysOn = useMemo( () => isThinkingAlwaysOnForModel( currentChatProvider?.type ?? "claude_code", - settings.selectedModel?.model, + activeSelectedModel?.model, ), - [currentChatProvider?.type, settings.selectedModel?.model], + [currentChatProvider?.type, activeSelectedModel?.model], ); const chatRuntimeControlsForCurrentProvider = useMemo( () => normalizeChatRuntimeControlsForProvider(settings.chatRuntimeControls, { providerId: currentChatProvider?.type, requestFormat: currentChatProvider?.requestFormat, - modelId: settings.selectedModel?.model, + modelId: activeSelectedModel?.model, }), [ currentChatProvider?.requestFormat, currentChatProvider?.type, settings.chatRuntimeControls, - settings.selectedModel?.model, + activeSelectedModel?.model, ], ); const handleChatRuntimeControlsChange = useCallback( @@ -3123,7 +3186,7 @@ export default function GatewayApp() { chatRuntimeControls: updateChatRuntimeControlsForProvider(prev.chatRuntimeControls, patch, { providerId: currentChatProvider?.type, requestFormat: currentChatProvider?.requestFormat, - modelId: settings.selectedModel?.model, + modelId: activeSelectedModel?.model, }), })); }, @@ -3131,7 +3194,7 @@ export default function GatewayApp() { currentChatProvider?.requestFormat, currentChatProvider?.type, setSettings, - settings.selectedModel?.model, + activeSelectedModel?.model, ], ); const isAgentDevExecutionMode = isAgentDevMode(settings.system.executionMode); @@ -3140,10 +3203,27 @@ export default function GatewayApp() { () => buildModelOptions(settings, { floatSelectedFirst: false }), [settings], ); - const selectedValue = settings.selectedModel - ? toModelValue(settings.selectedModel.customProviderId, settings.selectedModel.model) + const selectedValue = activeSelectedModel + ? toModelValue(activeSelectedModel.customProviderId, activeSelectedModel.model) : undefined; + // 选择器切换:写当前会话的本地 override(下次发消息随 selected_model 落库), + // 并同步更新「新会话默认模型」(最近使用语义,走既有 settings 同步通道)。 + const handleSelectModel = useCallback( + (selection: SelectedModel) => { + const targetConversationId = displayedConversationId.trim(); + if (targetConversationId) { + setConversationModelOverrides((prev) => { + const next = new Map(prev); + next.set(targetConversationId, selection); + return next; + }); + } + setSettings((prev) => setSelectedModel(prev, selection)); + }, + [displayedConversationId, setSettings], + ); + const skillsEnabled = settings.skills.enabled && isAgentMode; const selectedSkillNames = useMemo( () => (skillsEnabled ? mergeAlwaysEnabledSkillNames(settings.skills.selected) : []), @@ -3812,7 +3892,7 @@ export default function GatewayApp() { modelOptions={modelOptions} selectedValue={selectedValue} sidebarOpen={sidebarOpen} - setSettings={setSettings} + onSelectModel={handleSelectModel} onOpenSettings={openSettings} onToggleTheme={() => setSettings((prev) => ({ diff --git a/crates/agent-gateway/web/src/app/chatEventUtils.ts b/crates/agent-gateway/web/src/app/chatEventUtils.ts index 9526050fb..ff084d438 100644 --- a/crates/agent-gateway/web/src/app/chatEventUtils.ts +++ b/crates/agent-gateway/web/src/app/chatEventUtils.ts @@ -1,5 +1,10 @@ import type { ChatEvent, GatewaySelectedModel } from "@/lib/gatewayTypes"; -import type { AppSettings, SelectedModel } from "@/lib/settings"; +import { + type AppSettings, + normalizeSelectedModelForProviders, + parseSelectedModelJson, + type SelectedModel, +} from "@/lib/settings"; import type { ModelProviderSource, TunnelManagerToolChange } from "./types"; @@ -65,6 +70,25 @@ export function readTunnelManagerToolChange(event: ChatEvent): TunnelManagerTool return { action, projectPathKey }; } +// 会话生效模型的唯一派生点:本地未持久化的切换(override)> +// history-sync 带回的会话持久化选择 > 全局默认(新会话语义)。 +// 前两级都按当前 providers 校验,失效则逐级回退。 +export function resolveActiveModelSelection(params: { + settings: AppSettings; + override?: SelectedModel; + persistedSelectedModelJson?: string; +}): SelectedModel | undefined { + const { settings, override, persistedSelectedModelJson } = params; + return ( + normalizeSelectedModelForProviders(override, settings.customProviders) ?? + normalizeSelectedModelForProviders( + parseSelectedModelJson(persistedSelectedModelJson), + settings.customProviders, + ) ?? + settings.selectedModel + ); +} + export function buildGatewaySelectedModel( selectedModel: SelectedModel | undefined, providers: ModelProviderSource[], 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")}
+ - + {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-gateway/web/src/shims/tauriCore.ts b/crates/agent-gateway/web/src/shims/tauriCore.ts index 00ef1f895..18af5032d 100644 --- a/crates/agent-gateway/web/src/shims/tauriCore.ts +++ b/crates/agent-gateway/web/src/shims/tauriCore.ts @@ -229,6 +229,7 @@ export async function invoke(command: string, args?: Record) typeof args?.depth === "number" ? args.depth : undefined, typeof args?.offset === "number" ? args.offset : undefined, typeof args?.max_results === "number" ? args.max_results : undefined, + typeof args?.show_hidden === "boolean" ? args.show_hidden : undefined, )) as T; case "fs_write_text": return (await getGatewayWebSocketClient(loadToken().trim()).writeTextFile({ @@ -272,6 +273,7 @@ export async function invoke(command: string, args?: Record) String(args?.workdir ?? ""), typeof args?.max_results === "number" ? args.max_results : undefined, typeof args?.query === "string" ? args.query : undefined, + typeof args?.show_hidden === "boolean" ? args.show_hidden : undefined, )) as T; case "system_list_skill_files": return (await getGatewayWebSocketClient(loadToken().trim()).listSkillFiles()) as T; diff --git a/crates/agent-gateway/web/test/web-sidebar-backend.test.mjs b/crates/agent-gateway/web/test/web-sidebar-backend.test.mjs index 44d4868f6..c55345efb 100644 --- a/crates/agent-gateway/web/test/web-sidebar-backend.test.mjs +++ b/crates/agent-gateway/web/test/web-sidebar-backend.test.mjs @@ -32,6 +32,7 @@ function summary(id, overrides = {}) { is_pinned: overrides.is_pinned, pinned_at: overrides.pinned_at, is_shared: overrides.is_shared, + selected_model_json: overrides.selected_model_json, }; } @@ -118,6 +119,7 @@ test("normalize maps snake_case summaries with second→ms timestamps and empty assert.equal(normalized.model, ""); assert.equal(normalized.sessionId, undefined); assert.equal(normalized.cwd, undefined); + assert.equal(normalized.selectedModelJson, undefined); assert.equal(normalized.createdAt, MILLIS); assert.equal(normalized.updatedAt, (SECONDS + 5) * 1000); assert.equal(normalized.isPinned, true); @@ -134,6 +136,16 @@ test("normalize maps snake_case summaries with second→ms timestamps and empty // Blank titles fall back through formatConversationTitle. const untitled = normalizeGatewayConversationSummary(summary("c3", { title: " " })); assert.notEqual(untitled.title.trim(), ""); + + // Non-empty selected_model_json passes through; blank normalizes to undefined. + const withSelection = normalizeGatewayConversationSummary( + summary("c4", { selected_model_json: '{"customProviderId":"p1","model":"m1"}' }), + ); + assert.equal(withSelection.selectedModelJson, '{"customProviderId":"p1","model":"m1"}'); + const blankSelection = normalizeGatewayConversationSummary( + summary("c5", { selected_model_json: " " }), + ); + assert.equal(blankSelection.selectedModelJson, undefined); }); test("listConversations normalizes items/workdirs and hydrates the activity store", async () => { diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs index c70af219c..bd11cde1a 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/commands.rs @@ -132,6 +132,7 @@ pub async fn chat_history_get_active_segment( model: record.model, session_id: record.session_id, cwd: record.cwd, + selected_model_json: record.selected_model_json, context_meta_json, active_segment_index, total_segment_count, @@ -160,6 +161,7 @@ pub(crate) async fn chat_history_upsert_inner( model: input.model.clone(), session_id: input.session_id.clone(), cwd: input.cwd.clone(), + selected_model_json: input.selected_model_json.clone(), context_meta_json: input.context_meta_json.clone(), active_segment_index: input.active_segment_index, total_segment_count: input.total_segment_count, @@ -324,6 +326,31 @@ pub async fn chat_history_set_pinned( Ok(summary) } +pub(crate) async fn chat_history_set_model_inner( + id: String, + selected_model_json: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let conn = open_db()?; + set_chat_history_model_sync(&conn, &id, &selected_model_json) + }) + .await + .map_err(|e| format!("chat_history_set_model join 失败:{e}"))? +} + +#[tauri::command] +pub async fn chat_history_set_model( + id: String, + selected_model_json: String, + gateway_controller: tauri::State<'_, Arc>, +) -> Result { + let summary = chat_history_set_model_inner(id, selected_model_json).await?; + gateway_controller + .publish_history_sync(build_history_sync_upsert(&summary)) + .await; + Ok(summary) +} + pub(crate) async fn chat_history_share_get_inner( id: String, ) -> Result { diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/repository.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/repository.rs index 81db44fb3..62f2be515 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/repository.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/repository.rs @@ -6,6 +6,7 @@ fn row_to_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result model: row.get("model")?, session_id: row.get("session_id")?, cwd: row.get("cwd")?, + selected_model_json: row.get("selected_model_json")?, context_meta_json: row.get("context_meta_json")?, active_segment_index: row.get("active_segment_index")?, total_segment_count: row.get("total_segment_count")?, @@ -61,6 +63,7 @@ fn get_summary_by_id(conn: &Connection, id: &str) -> Result Result Result<(), String> { model: input.model.clone(), session_id: input.session_id.clone(), cwd: input.cwd.clone(), + selected_model_json: input.selected_model_json.clone(), context_meta_json: input.context_meta_json.clone(), active_segment_index: input.active_segment_index, total_segment_count: input.total_segment_count, @@ -315,6 +316,12 @@ fn upsert_chat_history_header( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); + let selected_model_json = input + .selected_model_json + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); conn.execute( " @@ -325,19 +332,21 @@ fn upsert_chat_history_header( model, session_id, cwd, + selected_model_json, context_meta_json, active_segment_index, total_segment_count, total_message_count, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) ON CONFLICT(id) DO UPDATE SET title = excluded.title, provider_id = excluded.provider_id, model = excluded.model, session_id = excluded.session_id, cwd = excluded.cwd, + selected_model_json = COALESCE(excluded.selected_model_json, chatHistory.selected_model_json), context_meta_json = excluded.context_meta_json, active_segment_index = excluded.active_segment_index, total_segment_count = excluded.total_segment_count, @@ -351,6 +360,7 @@ fn upsert_chat_history_header( input.model.trim(), session_id, cwd, + selected_model_json, input.context_meta_json.trim(), input.active_segment_index, input.total_segment_count, @@ -364,6 +374,47 @@ fn upsert_chat_history_header( Ok(()) } +fn set_chat_history_model_sync( + conn: &Connection, + id: &str, + selected_model_json: &str, +) -> Result { + let chat_id = id.trim(); + if chat_id.is_empty() { + return Err("历史对话 id 不能为空".to_string()); + } + + let payload = selected_model_json.trim(); + let parsed: serde_json::Value = + serde_json::from_str(payload).map_err(|_| "会话模型选择格式无效".to_string())?; + let has_non_empty = |key: &str| { + parsed + .get(key) + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + }; + if !has_non_empty("customProviderId") || !has_non_empty("model") { + return Err("会话模型选择格式无效".to_string()); + } + + let affected = conn + .execute( + " + UPDATE chatHistory + SET selected_model_json = ?1 + WHERE id = ?2 + ", + params![payload, chat_id], + ) + .map_err(|e| format!("更新历史对话模型选择失败:{e}"))?; + + if affected == 0 { + return Err("未找到对应的历史对话".to_string()); + } + + get_summary_by_id(conn, chat_id) +} + fn set_chat_history_pinned_sync( conn: &Connection, id: &str, diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs index a33d63057..89ab6b67b 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/tests.rs @@ -19,6 +19,7 @@ mod tests { model: "gpt-5".to_string(), session_id: Some("session-1".to_string()), cwd: Some("/tmp".to_string()), + selected_model_json: None, context_meta_json: "{}".to_string(), active_segment_index: 0, total_segment_count: 1, @@ -524,6 +525,105 @@ mod tests { assert_eq!(summary.title, "Updated Conversation"); } + #[test] + fn v1_database_gains_selected_model_column_via_v2_migration() { + // 复现存量库场景:完整的 v1 schema(无 selected_model_json)且 + // user_version 已到 1——版本门禁必须由 v2 迁移补齐新列。 + let v1 = Connection::open_in_memory().expect("open v1 in-memory chat history database"); + v1.execute_batch( + " + CREATE TABLE chatHistory ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + session_id TEXT, + cwd TEXT, + context_meta_json TEXT, + active_segment_index INTEGER, + total_segment_count INTEGER, + total_message_count INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + is_pinned INTEGER NOT NULL DEFAULT 0, + pinned_at INTEGER + ); + PRAGMA user_version = 1; + ", + ) + .expect("create v1 chatHistory schema"); + + history_db::initialize_connection(&v1).expect("migrate v1 schema to v2"); + + assert!( + table_column_names(&v1, "chatHistory").contains(&"selected_model_json".to_string()), + "v2 migration should add selected_model_json to a v1 database" + ); + } + + #[test] + fn set_model_persists_without_bumping_updated_at() { + let conn = open_test_db().expect("open test db"); + let conversation = sample_conversation(); + upsert_chat_history_header(&conn, &conversation).expect("upsert header"); + let before = get_summary_by_id(&conn, "conv-1").expect("load summary"); + + let payload = r#"{"customProviderId":"provider-a","model":"claude-fable-5"}"#; + let summary = + set_chat_history_model_sync(&conn, "conv-1", payload).expect("set conversation model"); + + assert_eq!(summary.selected_model_json.as_deref(), Some(payload)); + assert_eq!(summary.updated_at, before.updated_at); + assert_eq!(summary.title, before.title); + } + + #[test] + fn set_model_rejects_invalid_payloads() { + let conn = open_test_db().expect("open test db"); + let conversation = sample_conversation(); + upsert_chat_history_header(&conn, &conversation).expect("upsert header"); + + assert!(set_chat_history_model_sync(&conn, "conv-1", "not-json").is_err()); + assert!(set_chat_history_model_sync(&conn, "conv-1", r#"{"model":"m"}"#).is_err()); + assert!(set_chat_history_model_sync( + &conn, + "conv-1", + r#"{"customProviderId":" ","model":"m"}"# + ) + .is_err()); + assert!(set_chat_history_model_sync( + &conn, + "conv-missing", + r#"{"customProviderId":"provider-a","model":"m"}"# + ) + .is_err()); + + let summary = get_summary_by_id(&conn, "conv-1").expect("load summary"); + assert_eq!(summary.selected_model_json, None); + } + + #[test] + fn upsert_header_preserves_selected_model_when_input_none() { + let conn = open_test_db().expect("open test db"); + let mut conversation = sample_conversation(); + upsert_chat_history_header(&conn, &conversation).expect("upsert header"); + + let payload = r#"{"customProviderId":"provider-a","model":"claude-fable-5"}"#; + set_chat_history_model_sync(&conn, "conv-1", payload).expect("set conversation model"); + + conversation.updated_at += 1_000; + upsert_chat_history_header(&conn, &conversation).expect("upsert without selection"); + let preserved = get_summary_by_id(&conn, "conv-1").expect("load summary"); + assert_eq!(preserved.selected_model_json.as_deref(), Some(payload)); + + let replacement = r#"{"customProviderId":"provider-b","model":"gpt-5"}"#; + conversation.selected_model_json = Some(replacement.to_string()); + conversation.updated_at += 1_000; + upsert_chat_history_header(&conn, &conversation).expect("upsert with new selection"); + let replaced = get_summary_by_id(&conn, "conv-1").expect("load summary"); + assert_eq!(replaced.selected_model_json.as_deref(), Some(replacement)); + } + #[test] fn rename_preserves_existing_pin_state() { let conn = open_test_db().expect("open test db"); diff --git a/crates/agent-gui/src-tauri/src/commands/history/chat_history/types.rs b/crates/agent-gui/src-tauri/src/commands/history/chat_history/types.rs index eee41bafc..9c3ec857a 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/chat_history/types.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/chat_history/types.rs @@ -7,6 +7,7 @@ pub struct ChatHistorySummary { pub model: String, pub session_id: Option, pub cwd: Option, + pub selected_model_json: Option, pub message_count: i64, pub created_at: i64, pub updated_at: i64, @@ -65,6 +66,7 @@ pub struct ChatHistoryRecord { pub model: String, pub session_id: Option, pub cwd: Option, + pub selected_model_json: Option, pub context_meta_json: String, pub active_segment_index: i64, pub total_segment_count: i64, @@ -98,6 +100,7 @@ pub struct ChatHistoryActiveSegmentRecord { pub model: String, pub session_id: Option, pub cwd: Option, + pub selected_model_json: Option, pub context_meta_json: String, pub active_segment_index: i64, pub total_segment_count: i64, @@ -133,6 +136,7 @@ pub struct ChatHistoryUpsertInput { pub model: String, pub session_id: Option, pub cwd: Option, + pub selected_model_json: Option, pub context_meta_json: String, pub active_segment_index: i64, pub total_segment_count: i64, @@ -151,6 +155,7 @@ pub struct ChatHistoryConversationInput { pub model: String, pub session_id: Option, pub cwd: Option, + pub selected_model_json: Option, pub context_meta_json: String, pub active_segment_index: i64, pub total_segment_count: i64, diff --git a/crates/agent-gui/src-tauri/src/commands/history/history_db.rs b/crates/agent-gui/src-tauri/src/commands/history/history_db.rs index 1771e4f7f..852ce9132 100644 --- a/crates/agent-gui/src-tauri/src/commands/history/history_db.rs +++ b/crates/agent-gui/src-tauri/src/commands/history/history_db.rs @@ -2,7 +2,7 @@ use rusqlite::{Connection, OptionalExtension}; use std::{collections::HashSet, fs, path::PathBuf, sync::Mutex, time::Duration}; const DB_FILENAME: &str = "chat-history.sqlite3"; -const HISTORY_DB_SCHEMA_VERSION: i64 = 1; +const HISTORY_DB_SCHEMA_VERSION: i64 = 2; static HISTORY_DB_MIGRATION_LOCK: Mutex<()> = Mutex::new(()); @@ -83,6 +83,11 @@ fn migrate_history_db_inner(conn: &Connection) -> Result<(), String> { set_user_version(conn, 1)?; } + if current_version < 2 { + migrate_to_v2(conn)?; + set_user_version(conn, 2)?; + } + // The subagent schema is versioned independently via subagentMeta and is // safe to (re)ensure on every startup. ensure_subagent_schema(conn)?; @@ -95,6 +100,13 @@ fn migrate_to_v1(conn: &Connection) -> Result<(), String> { Ok(()) } +// v2: chatHistory 新增 selected_model_json(每会话模型选择)。schema ensure +// 本身幂等,重跑即补齐缺失列。 +fn migrate_to_v2(conn: &Connection) -> Result<(), String> { + ensure_chat_history_schema(conn)?; + Ok(()) +} + fn read_table_columns( conn: &Connection, table_name: &str, @@ -163,7 +175,8 @@ fn ensure_chat_history_schema(conn: &Connection) -> Result<(), String> { created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, is_pinned INTEGER NOT NULL DEFAULT 0, - pinned_at INTEGER + pinned_at INTEGER, + selected_model_json TEXT ); CREATE TABLE IF NOT EXISTS chatHistorySegment ( @@ -271,6 +284,10 @@ fn ensure_chat_history_columns(conn: &Connection) -> Result<(), String> { "pinned_at", "ALTER TABLE chatHistory ADD COLUMN pinned_at INTEGER;", ), + ( + "selected_model_json", + "ALTER TABLE chatHistory ADD COLUMN selected_model_json TEXT;", + ), ], )?; @@ -715,7 +732,10 @@ mod tests { #[test] fn initialize_connection_sets_schema_version() { let conn = open_test_db(); - assert_eq!(read_user_version(&conn).expect("read version"), 1); + assert_eq!( + read_user_version(&conn).expect("read version"), + HISTORY_DB_SCHEMA_VERSION + ); } #[test] @@ -782,10 +802,14 @@ mod tests { initialize_connection(&conn).expect("migrate legacy history schema"); - assert_eq!(read_user_version(&conn).expect("read version"), 1); + assert_eq!( + read_user_version(&conn).expect("read version"), + HISTORY_DB_SCHEMA_VERSION + ); for (table_name, column_name) in [ ("chatHistory", "context_meta_json"), ("chatHistory", "is_pinned"), + ("chatHistory", "selected_model_json"), ("subagentRun", "prompt"), ("subagentRun", "context_schema_version"), ] { diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index 2615f537d..8209663d8 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -6,7 +6,7 @@ use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE}; use reqwest::Url; use serde::Serialize; use serde_json::Value; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::fs; use std::io::{self, Cursor, Read, Seek}; use std::path::{Component, Path, PathBuf}; @@ -3452,6 +3452,7 @@ pub async fn fs_rename( pub struct ListEntry { pub path: String, pub kind: String, + pub hidden: bool, } #[derive(Debug, Serialize)] @@ -3629,31 +3630,100 @@ pub async fn fs_list_dirs( run_blocking("fs_list_dirs", move || fs_list_dirs_sync(path, max_results)).await } -fn build_ignore_walker(base: &Path, max_depth: Option) -> ignore::Walk { +#[derive(Clone, Copy)] +struct WalkerVisibility { + include_system_hidden: bool, + include_ignored: bool, +} + +fn requested_visibility( + show_hidden: Option, + default_include_system_hidden: bool, +) -> WalkerVisibility { + WalkerVisibility { + include_system_hidden: show_hidden.unwrap_or(default_include_system_hidden), + include_ignored: show_hidden == Some(true), + } +} + +#[cfg(target_os = "macos")] +fn has_macos_hidden_flag(entry: &ignore::DirEntry) -> bool { + use std::os::macos::fs::MetadataExt; + + const UF_HIDDEN: u32 = 0x0000_8000; + entry + .metadata() + .is_ok_and(|metadata| metadata.st_flags() & UF_HIDDEN != 0) +} + +#[cfg(not(target_os = "macos"))] +fn has_macos_hidden_flag(_entry: &ignore::DirEntry) -> bool { + false +} + +fn build_workspace_walker( + base: &Path, + max_depth: Option, + visibility: WalkerVisibility, + skip_common_dirs: bool, +) -> ignore::Walk { let mut builder = WalkBuilder::new(base); builder - .hidden(false) - .ignore(true) - .git_ignore(true) - .git_global(true) - .git_exclude(true) + .hidden(!visibility.include_system_hidden) + .ignore(!visibility.include_ignored) + .git_ignore(!visibility.include_ignored) + .git_global(!visibility.include_ignored) + .git_exclude(!visibility.include_ignored) .require_git(false) .follow_links(false); + let filter_macos_hidden = cfg!(target_os = "macos") && !visibility.include_system_hidden; + if filter_macos_hidden || skip_common_dirs { + builder.filter_entry(move |entry| { + (!filter_macos_hidden || !has_macos_hidden_flag(entry)) + && (!skip_common_dirs || should_visit_mention_entry(entry)) + }); + } if let Some(depth) = max_depth { builder.max_depth(Some(depth)); } builder.build() } +fn build_ignore_walker(base: &Path, max_depth: Option) -> ignore::Walk { + build_workspace_walker( + base, + max_depth, + requested_visibility(None, true), + false, + ) +} + +fn visible_paths_for_hidden_marking( + base: &Path, + max_depth: Option, + skip_common_dirs: bool, +) -> HashSet { + build_workspace_walker( + base, + max_depth, + requested_visibility(Some(false), false), + skip_common_dirs, + ) + .filter_map(Result::ok) + .map(|entry| entry.into_path()) + .collect() +} + pub(crate) fn fs_list_sync( workdir: String, path: Option, depth: Option, offset: Option, max_results: Option, + show_hidden: Option, ) -> Result { let wd = canonicalize_workdir(&workdir)?; - fs_list_impl(&wd, path, depth, offset, max_results) + fs_list_impl(&wd, path, depth, offset, max_results, show_hidden) .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) } @@ -3663,6 +3733,7 @@ fn fs_list_impl( depth: Option, offset: Option, max_results: Option, + show_hidden: Option, ) -> Result { let rel_opt = sanitize_optional_rel_path(path)?; let path_display = rel_opt.as_ref().map(|rel| logical_rel_path(rel)); @@ -3692,9 +3763,14 @@ fn fs_list_impl( entries.push(ListEntry { path: rel_to_workdir_str(wd, &base), kind: "file".to_string(), + hidden: false, }); } else { - for result in build_ignore_walker(&base, Some(depth)) { + let visibility = requested_visibility(show_hidden, true); + let normally_visible = visibility + .include_ignored + .then(|| visible_paths_for_hidden_marking(&base, Some(depth), false)); + for result in build_workspace_walker(&base, Some(depth), visibility, false) { let entry = match result { Ok(v) => v, Err(_) => continue, @@ -3713,6 +3789,9 @@ fn fs_list_impl( entries.push(ListEntry { path: rel_to_workdir_str(wd, entry.path()), kind: kind.to_string(), + hidden: normally_visible + .as_ref() + .is_some_and(|paths| !paths.contains(entry.path())), }); } } @@ -3745,9 +3824,10 @@ pub async fn fs_list( depth: Option, offset: Option, max_results: Option, + show_hidden: Option, ) -> Result { run_blocking_fs("fs_list", move || { - fs_list_sync(workdir, path, depth, offset, max_results) + fs_list_sync(workdir, path, depth, offset, max_results, show_hidden) }) .await } @@ -4273,6 +4353,7 @@ const MENTION_IGNORED_DIR_NAMES: &[&str] = &[ pub struct MentionFileEntry { pub path: String, pub kind: String, + pub hidden: bool, } #[derive(Debug, Serialize)] @@ -4348,6 +4429,7 @@ pub fn fs_mention_list_sync( workdir: String, max_results: Option, query: Option, + show_hidden: Option, ) -> Result { let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; let max_results = max_results.unwrap_or(DEFAULT_MENTION_MAX_RESULTS).max(1); @@ -4358,15 +4440,11 @@ pub fn fs_mention_list_sync( QUERY_MENTION_CANDIDATE_LIMIT.max(max_results) }; - let walker = WalkBuilder::new(&wd) - .hidden(true) - .git_ignore(true) - .git_global(true) - .git_exclude(true) - .require_git(false) - .follow_links(false) - .filter_entry(should_visit_mention_entry) - .build(); + let visibility = requested_visibility(show_hidden, false); + let normally_visible = visibility + .include_ignored + .then(|| visible_paths_for_hidden_marking(&wd, None, true)); + let walker = build_workspace_walker(&wd, None, visibility, !visibility.include_ignored); let mut entries: Vec = Vec::new(); let mut truncated = false; @@ -4402,6 +4480,9 @@ pub fn fs_mention_list_sync( entries.push(MentionFileEntry { path, kind: kind.to_string(), + hidden: normally_visible + .as_ref() + .is_some_and(|paths| !paths.contains(entry.path())), }); } @@ -4421,9 +4502,10 @@ pub async fn fs_mention_list( workdir: String, max_results: Option, query: Option, + show_hidden: Option, ) -> Result { run_blocking("fs_mention_list", move || { - fs_mention_list_sync(workdir, max_results, query) + fs_mention_list_sync(workdir, max_results, query, show_hidden) }) .await } @@ -4443,6 +4525,30 @@ mod tests { std::env::temp_dir().join(format!("liveagent-{name}-{suffix}")) } + fn list_test_entries(workdir: &Path, show_hidden: Option) -> Vec { + fs_list_sync( + workdir.display().to_string(), + None, + Some(3), + None, + Some(100), + show_hidden, + ) + .expect("list should succeed") + .entries + } + + fn mention_test_entries(workdir: &Path, show_hidden: Option) -> Vec { + fs_mention_list_sync( + workdir.display().to_string(), + Some(100), + None, + show_hidden, + ) + .expect("mention list should succeed") + .entries + } + fn png_like_bytes() -> Vec { b"\x89PNG\r\n\x1a\nliveagent-test".to_vec() } @@ -5225,6 +5331,7 @@ mod tests { Some(3), None, Some(100), + None, ) .expect("list should succeed"); let paths = response @@ -5249,6 +5356,7 @@ mod tests { Some(1), None, Some(10), + None, ) .expect_err("outside path should fail"); assert!( @@ -5260,6 +5368,103 @@ mod tests { let _ = fs::remove_dir_all(workdir); } + #[test] + fn list_hidden_toggle_includes_and_marks_ignored_and_dot_entries() { + let workdir = unique_test_workdir("list-hidden-toggle"); + fs::create_dir_all(workdir.join(".hidden_dir")).expect("create hidden dir"); + fs::create_dir_all(workdir.join("ignored_dir")).expect("create ignored dir"); + fs::write(workdir.join(".gitignore"), "ignored_dir/\nignored.txt\n") + .expect("write gitignore"); + fs::write(workdir.join("visible.txt"), "visible").expect("write visible file"); + fs::write(workdir.join(".hidden.txt"), "hidden").expect("write hidden file"); + fs::write(workdir.join(".hidden_dir/child.txt"), "hidden").expect("write hidden child"); + fs::write(workdir.join("ignored.txt"), "ignored").expect("write ignored file"); + fs::write(workdir.join("ignored_dir/child.txt"), "ignored").expect("write ignored child"); + + let legacy_entries = list_test_entries(&workdir, None); + assert!(legacy_entries.iter().any(|entry| entry.path == ".hidden.txt")); + assert!(!legacy_entries.iter().any(|entry| entry.path == "ignored.txt")); + + let hidden_entries = list_test_entries(&workdir, Some(false)); + let hidden_paths = hidden_entries + .iter() + .map(|entry| entry.path.as_str()) + .collect::>(); + assert!(hidden_paths.contains(&"visible.txt")); + assert!(!hidden_paths.iter().any(|path| path.starts_with('.'))); + assert!(!hidden_paths.iter().any(|path| path.starts_with("ignored"))); + + let shown_entries = list_test_entries(&workdir, Some(true)); + let entry = |path: &str| { + shown_entries + .iter() + .find(|entry| entry.path == path) + .unwrap_or_else(|| panic!("missing entry: {path}")) + }; + assert!(!entry("visible.txt").hidden); + assert!(entry(".hidden.txt").hidden); + assert!(entry(".hidden_dir/child.txt").hidden); + assert!(entry("ignored.txt").hidden); + assert!(entry("ignored_dir/child.txt").hidden); + + let _ = fs::remove_dir_all(workdir); + } + + #[cfg(target_os = "macos")] + #[test] + fn list_hidden_toggle_respects_macos_finder_hidden_flag() { + let workdir = unique_test_workdir("list-macos-hidden"); + fs::create_dir_all(&workdir).expect("create workdir"); + let hidden_path = workdir.join("finder-hidden.txt"); + fs::write(&hidden_path, "hidden").expect("write hidden file"); + let status = Command::new("chflags") + .arg("hidden") + .arg(&hidden_path) + .status() + .expect("run chflags"); + assert!(status.success(), "chflags hidden failed"); + + let hidden_entries = list_test_entries(&workdir, Some(false)); + assert!(!hidden_entries.iter().any(|entry| entry.path == "finder-hidden.txt")); + + let shown_entries = list_test_entries(&workdir, Some(true)); + assert!( + shown_entries + .iter() + .any(|entry| entry.path == "finder-hidden.txt" && entry.hidden) + ); + + let _ = fs::remove_dir_all(workdir); + } + + #[cfg(windows)] + #[test] + fn list_hidden_toggle_respects_windows_hidden_attribute() { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; + let workdir = unique_test_workdir("list-windows-hidden"); + fs::create_dir_all(&workdir).expect("create workdir"); + fs::OpenOptions::new() + .write(true) + .create(true) + .attributes(FILE_ATTRIBUTE_HIDDEN) + .open(workdir.join("windows-hidden.txt")) + .expect("create hidden file"); + + let hidden_entries = list_test_entries(&workdir, Some(false)); + assert!(!hidden_entries.iter().any(|entry| entry.path == "windows-hidden.txt")); + + let shown_entries = list_test_entries(&workdir, Some(true)); + assert!( + shown_entries + .iter() + .any(|entry| entry.path == "windows-hidden.txt" && entry.hidden) + ); + + let _ = fs::remove_dir_all(workdir); + } + #[cfg(unix)] #[test] fn create_dir_and_rename_reject_out_of_bounds_symlink_parents() { @@ -5305,10 +5510,8 @@ mod tests { fs::write(workdir.join("src/app.ts"), "export {}").expect("write app"); fs::write(workdir.join("node_modules/pkg/ignored.ts"), "export {}").expect("write ignored"); - let response = fs_mention_list_sync(workdir.display().to_string(), Some(100), None) - .expect("mention list should succeed"); - let paths = response - .entries + let entries = mention_test_entries(&workdir, None); + let paths = entries .iter() .map(|entry| entry.path.as_str()) .collect::>(); @@ -5337,10 +5540,8 @@ mod tests { fs::write(workdir.join("ignored_dir/hidden.ts"), "export {}").expect("write hidden"); fs::write(workdir.join("ignored_file.txt"), "hidden").expect("write ignored file"); - let response = fs_mention_list_sync(workdir.display().to_string(), Some(100), None) - .expect("mention list should succeed"); - let paths = response - .entries + let entries = mention_test_entries(&workdir, None); + let paths = entries .iter() .map(|entry| entry.path.as_str()) .collect::>(); @@ -5358,6 +5559,32 @@ mod tests { let _ = fs::remove_dir_all(workdir); } + #[test] + fn mention_list_hidden_toggle_includes_and_marks_filtered_entries() { + let workdir = unique_test_workdir("mention-hidden-toggle"); + fs::create_dir_all(workdir.join("node_modules/pkg")).expect("create node_modules"); + fs::create_dir_all(workdir.join("ignored_dir")).expect("create ignored dir"); + fs::write(workdir.join(".gitignore"), "ignored_dir/\n").expect("write gitignore"); + fs::write(workdir.join("visible.txt"), "visible").expect("write visible file"); + fs::write(workdir.join(".hidden.txt"), "hidden").expect("write hidden file"); + fs::write(workdir.join("ignored_dir/child.txt"), "ignored") + .expect("write ignored file"); + + let shown_entries = mention_test_entries(&workdir, Some(true)); + let entry = |path: &str| { + shown_entries + .iter() + .find(|entry| entry.path == path) + .unwrap_or_else(|| panic!("missing entry: {path}")) + }; + assert!(!entry("visible.txt").hidden); + assert!(entry(".hidden.txt").hidden); + assert!(entry("node_modules").hidden); + assert!(entry("ignored_dir/child.txt").hidden); + + let _ = fs::remove_dir_all(workdir); + } + #[test] fn mention_list_query_filters_and_ranks_filename_matches() { let workdir = unique_test_workdir("mention-query"); @@ -5370,6 +5597,7 @@ mod tests { workdir.display().to_string(), Some(10), Some("needle".to_string()), + None, ) .expect("mention list should succeed"); let paths = response @@ -5454,6 +5682,7 @@ mod tests { None, None, None, + None, ) .expect("list should accept a file target"); assert_eq!(list.target_kind.as_deref(), Some("file")); diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 77d31aef9..928acd48a 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -44,6 +44,7 @@ macro_rules! app_invoke_handler { commands::chat_history::chat_history_append_segment, commands::chat_history::chat_history_rename, commands::chat_history::chat_history_set_pinned, + commands::chat_history::chat_history_set_model, commands::chat_history::chat_history_share_get, commands::chat_history::chat_history_share_set, commands::chat_history::chat_history_delete, diff --git a/crates/agent-gui/src-tauri/src/services/gateway/history_sync.rs b/crates/agent-gui/src-tauri/src/services/gateway/history_sync.rs index 4ffb7b387..ea69f1446 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/history_sync.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/history_sync.rs @@ -15,6 +15,7 @@ pub fn build_history_sync_upsert(summary: &ChatHistorySummary) -> GatewayHistory model: Some(summary.model.clone()), session_id: summary.session_id.clone(), cwd: summary.cwd.clone(), + selected_model_json: summary.selected_model_json.clone(), created_at: summary.created_at, updated_at: summary.updated_at, message_count: summary.message_count, @@ -48,6 +49,8 @@ pub(crate) fn build_history_sync_upsert_from_proto( model: (!summary.model.trim().is_empty()).then(|| summary.model.clone()), session_id: (!summary.session_id.trim().is_empty()).then(|| summary.session_id.clone()), cwd: (!summary.cwd.trim().is_empty()).then(|| summary.cwd.clone()), + selected_model_json: (!summary.selected_model_json.trim().is_empty()) + .then(|| summary.selected_model_json.clone()), created_at: summary.created_at, updated_at: summary.updated_at, message_count: i64::from(summary.message_count), @@ -88,6 +91,7 @@ pub(crate) fn build_history_sync_envelope( model: conversation.model.unwrap_or_default(), session_id: conversation.session_id.unwrap_or_default(), cwd: conversation.cwd.unwrap_or_default(), + selected_model_json: conversation.selected_model_json.unwrap_or_default(), is_pinned: conversation.is_pinned, pinned_at: conversation.pinned_at.unwrap_or_default(), is_shared: conversation.is_shared, diff --git a/crates/agent-gui/src-tauri/src/services/gateway/types.rs b/crates/agent-gui/src-tauri/src/services/gateway/types.rs index 7ea64c957..59e03400c 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/types.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/types.rs @@ -176,6 +176,7 @@ pub struct GatewayHistorySyncConversation { pub model: Option, pub session_id: Option, pub cwd: Option, + pub selected_model_json: Option, pub created_at: i64, pub updated_at: i64, pub message_count: i64, diff --git a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs index 67c9e7b55..45c636257 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -154,6 +154,7 @@ fn build_proto_history_list_response( model: item.model, session_id: item.session_id.unwrap_or_default(), cwd: item.cwd.unwrap_or_default(), + selected_model_json: item.selected_model_json.unwrap_or_default(), is_pinned: item.is_pinned, pinned_at: item.pinned_at.unwrap_or_default(), is_shared: item.is_shared, @@ -341,7 +342,12 @@ pub async fn handle_file_mention_list( .filter(|value| *value > 0); tauri::async_runtime::spawn_blocking(move || { - fs_mention_list_sync(request.workdir, max_results, Some(request.query)) + fs_mention_list_sync( + request.workdir, + max_results, + Some(request.query), + request.show_hidden, + ) }) .await .map_err(|e| format!("gateway file mention list join failed: {e}"))? @@ -352,6 +358,7 @@ pub async fn handle_file_mention_list( .map(|entry| proto::FileMentionEntry { path: entry.path, kind: entry.kind, + hidden: entry.hidden, }) .collect(), truncated: response.truncated, @@ -431,7 +438,14 @@ pub async fn handle_fs_list( .filter(|value| *value > 0); tauri::async_runtime::spawn_blocking(move || { - fs_list_sync(request.workdir, path, depth, offset, max_results) + fs_list_sync( + request.workdir, + path, + depth, + offset, + max_results, + request.show_hidden, + ) }) .await .map_err(|e| format!("gateway fs list join failed: {e}"))? @@ -452,6 +466,7 @@ pub async fn handle_fs_list( .map(|entry| proto::FsListEntry { path: entry.path, kind: entry.kind, + hidden: entry.hidden, }) .collect(), } @@ -1495,6 +1510,7 @@ fn build_proto_conversation_summary_from_record( model: record.model.clone(), session_id: record.session_id.clone().unwrap_or_default(), cwd: record.cwd.clone().unwrap_or_default(), + selected_model_json: record.selected_model_json.clone().unwrap_or_default(), is_pinned: record.is_pinned, pinned_at: record.pinned_at.unwrap_or_default(), is_shared: record.is_shared, @@ -1514,6 +1530,7 @@ fn build_proto_conversation_summary( model: summary.model, session_id: summary.session_id.unwrap_or_default(), cwd: summary.cwd.unwrap_or_default(), + selected_model_json: summary.selected_model_json.unwrap_or_default(), is_pinned: summary.is_pinned, pinned_at: summary.pinned_at.unwrap_or_default(), is_shared: summary.is_shared, @@ -1546,6 +1563,7 @@ fn history_list_json(page: chat_history::ChatHistoryListResponse) -> Value { "model": item.model, "session_id": item.session_id.unwrap_or_default(), "cwd": item.cwd.unwrap_or_default(), + "selected_model_json": item.selected_model_json.unwrap_or_default(), "is_pinned": item.is_pinned, "pinned_at": item.pinned_at.unwrap_or_default(), "is_shared": item.is_shared, 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/components/project-tools/file-tree/ContextMenu.tsx b/crates/agent-gui/src/components/project-tools/file-tree/ContextMenu.tsx index d8005b67a..2fc22c268 100644 --- a/crates/agent-gui/src/components/project-tools/file-tree/ContextMenu.tsx +++ b/crates/agent-gui/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")}
+ - + {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/chat/model-selection.test.mjs b/crates/agent-gui/test/chat/model-selection.test.mjs index e5f28be6f..5f90db61f 100644 --- a/crates/agent-gui/test/chat/model-selection.test.mjs +++ b/crates/agent-gui/test/chat/model-selection.test.mjs @@ -36,7 +36,7 @@ test("local chat model selection resolves only an enabled selected model", () => { customProviderId: "openai-main", model: "gpt-5" }, ); - const resolved = modelSelection.resolveEffectiveChatModelSelection(app); + const resolved = modelSelection.resolveEffectiveChatModelSelection({ settings: app }); assert.equal(resolved.provider.id, "openai-main"); assert.equal(resolved.providerId, "codex"); @@ -58,10 +58,13 @@ test("remote chat model selection does not fall back to another provider with th assert.throws( () => - modelSelection.resolveEffectiveChatModelSelection(app, { - customProviderId: "missing-openai", - model: "gpt-5-mini", - providerType: "codex", + modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + gatewaySelectedModel: { + customProviderId: "missing-openai", + model: "gpt-5-mini", + providerType: "codex", + }, }), /供应商不存在/, ); @@ -75,10 +78,13 @@ test("remote chat model selection rejects provider type drift", () => { assert.throws( () => - modelSelection.resolveEffectiveChatModelSelection(app, { - customProviderId: "anthropic-main", - model: "claude-sonnet", - providerType: "codex", + modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + gatewaySelectedModel: { + customProviderId: "anthropic-main", + model: "claude-sonnet", + providerType: "codex", + }, }), /供应商类型.*不一致/, ); @@ -98,10 +104,13 @@ test("remote chat model selection rejects models that are no longer enabled", () assert.throws( () => - modelSelection.resolveEffectiveChatModelSelection(app, { - customProviderId: "openai-main", - model: "gpt-5-mini", - providerType: "codex", + modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + gatewaySelectedModel: { + customProviderId: "openai-main", + model: "gpt-5-mini", + providerType: "codex", + }, }), /未在桌面端启用/, ); @@ -113,10 +122,13 @@ test("remote chat model selection accepts an exact enabled provider model", () = { customProviderId: "gemini-main", model: "gemini-3.5-flash" }, ); - const resolved = modelSelection.resolveEffectiveChatModelSelection(app, { - customProviderId: "gemini-main", - model: "gemini-3.5-flash", - providerType: "gemini", + const resolved = modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + gatewaySelectedModel: { + customProviderId: "gemini-main", + model: "gemini-3.5-flash", + providerType: "gemini", + }, }); assert.equal(resolved.provider.id, "gemini-main"); @@ -126,3 +138,115 @@ test("remote chat model selection accepts an exact enabled provider model", () = model: "gemini-3.5-flash", }); }); + +test("conversation selection wins over the global default", () => { + const app = appSettings( + [ + provider({ id: "openai-main", models: ["gpt-5"] }), + provider({ id: "anthropic-main", type: "claude_code", models: ["claude-fable-5"] }), + ], + { customProviderId: "openai-main", model: "gpt-5" }, + ); + + const resolved = modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + conversationSelectedModel: { customProviderId: "anthropic-main", model: "claude-fable-5" }, + }); + + assert.equal(resolved.provider.id, "anthropic-main"); + assert.equal(resolved.providerId, "claude_code"); + assert.deepEqual(resolved.selectedModel, { + customProviderId: "anthropic-main", + model: "claude-fable-5", + }); +}); + +test("gateway override wins over the conversation selection", () => { + const app = appSettings( + [ + provider({ id: "openai-main", models: ["gpt-5"] }), + provider({ id: "gemini-main", type: "gemini", models: ["gemini-3.5-flash"] }), + ], + { customProviderId: "openai-main", model: "gpt-5" }, + ); + + const resolved = modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + conversationSelectedModel: { customProviderId: "openai-main", model: "gpt-5" }, + gatewaySelectedModel: { + customProviderId: "gemini-main", + model: "gemini-3.5-flash", + providerType: "gemini", + }, + }); + + assert.equal(resolved.provider.id, "gemini-main"); +}); + +test("invalid conversation selection throws like an invalid default", () => { + const app = appSettings([provider({ id: "openai-main", models: ["gpt-5"] })], { + customProviderId: "openai-main", + model: "gpt-5", + }); + + assert.throws( + () => + modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + conversationSelectedModel: { customProviderId: "missing", model: "gpt-5" }, + }), + /供应商不存在/, + ); +}); + +test("resolveActiveModelSelection prefers the conversation selection", () => { + const app = appSettings([provider({ id: "openai-main", models: ["gpt-5"] })], { + customProviderId: "openai-main", + model: "gpt-5", + }); + const conversationSelection = { customProviderId: "other", model: "m" }; + + assert.equal( + modelSelection.resolveActiveModelSelection(app, conversationSelection), + conversationSelection, + ); + assert.deepEqual(modelSelection.resolveActiveModelSelection(app, undefined), { + customProviderId: "openai-main", + model: "gpt-5", + }); +}); + +test("history persistence prefers the latest runtime selection over the turn-start model", () => { + const turnSelectedModel = { customProviderId: "openai-main", model: "gpt-5" }; + const runtimeSelectedModel = { + customProviderId: "anthropic-main", + model: "claude-fable-5", + }; + + assert.equal( + modelSelection.resolvePersistedConversationModelSelection({ + runtimeSelectedModel, + turnSelectedModel, + }), + runtimeSelectedModel, + ); + assert.equal( + modelSelection.resolvePersistedConversationModelSelection({ turnSelectedModel }), + turnSelectedModel, + ); +}); + +test("selected model json round-trips and rejects malformed payloads", () => { + assert.equal( + settings.serializeSelectedModelJson({ customProviderId: "p1", model: "m1" }), + '{"customProviderId":"p1","model":"m1"}', + ); + assert.deepEqual(settings.parseSelectedModelJson('{"customProviderId":"p1","model":"m1"}'), { + customProviderId: "p1", + model: "m1", + }); + assert.equal(settings.parseSelectedModelJson(undefined), undefined); + assert.equal(settings.parseSelectedModelJson("not-json"), undefined); + assert.equal(settings.parseSelectedModelJson('{"model":"m1"}'), undefined); + assert.equal(settings.serializeSelectedModelJson(undefined), undefined); +}); diff --git a/crates/agent-gui/test/chat/sidebar-reconcile.test.mjs b/crates/agent-gui/test/chat/sidebar-reconcile.test.mjs index afb0e4b76..7e7d51731 100644 --- a/crates/agent-gui/test/chat/sidebar-reconcile.test.mjs +++ b/crates/agent-gui/test/chat/sidebar-reconcile.test.mjs @@ -19,6 +19,7 @@ function conversation(id, overrides = {}) { isPinned: overrides.isPinned, pinnedAt: overrides.pinnedAt, isShared: overrides.isShared, + selectedModelJson: overrides.selectedModelJson, isPending: overrides.isPending, }; } @@ -162,3 +163,36 @@ test("scope filter preserves identity when everything matches", () => { const items = [conversation("a", { cwd: "/p" }), conversation("b", { cwd: "/p" })]; assert.equal(scope.filterConversationsForScope(items, { kind: "workdir", cwd: "/p" }), items); }); + +test("merge keeps the existing selectedModelJson when the incoming row omits it", () => { + const existing = conversation("one", { + selectedModelJson: '{"customProviderId":"p1","model":"m1"}', + }); + const merged = reconcile.mergeSidebarConversation(existing, conversation("one", { updatedAt: 2 })); + assert.equal(merged.selectedModelJson, '{"customProviderId":"p1","model":"m1"}'); +}); + +test("merge replaces selectedModelJson with a non-empty incoming value", () => { + const existing = conversation("one", { + selectedModelJson: '{"customProviderId":"p1","model":"m1"}', + }); + const merged = reconcile.mergeSidebarConversation( + existing, + conversation("one", { + updatedAt: 2, + selectedModelJson: '{"customProviderId":"p2","model":"m2"}', + }), + ); + assert.equal(merged.selectedModelJson, '{"customProviderId":"p2","model":"m2"}'); +}); + +test("merge keeps identity when selectedModelJson is unchanged", () => { + const existing = conversation("one", { + selectedModelJson: '{"customProviderId":"p1","model":"m1"}', + }); + const merged = reconcile.mergeSidebarConversation( + existing, + conversation("one", { selectedModelJson: '{"customProviderId":"p1","model":"m1"}' }), + ); + assert.equal(merged, existing); +}); 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); +}); 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"); +}); diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index ea51cd9d2..a4422622d 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -154,6 +154,7 @@ test("settings normalization canonicalizes project keyed maps with Windows path query: "legacy", selectedPath: "src\\main.ts", expandedPaths: ["", "src", "src\\components", "src"], + showHidden: true, revision: 2, }, }, @@ -190,6 +191,7 @@ test("settings normalization canonicalizes project keyed maps with Windows path query: "legacy", selectedPath: "src/main.ts", expandedPaths: ["", "src", "src/components"], + showHidden: true, revision: 2, }, }, @@ -707,6 +709,7 @@ test("gateway settings sync payload redacts provider api keys", () => { query: "src", selectedPath: "src/main.ts", expandedPaths: ["", "src", "src/bad"], + showHidden: false, revision: 3, }, }, @@ -1060,6 +1063,7 @@ test("normalizes right dock from current settings", () => { query: "src", selectedPath: "src/main.ts", expandedPaths: ["", "src", "src/components"], + showHidden: false, revision: 4, }, }, @@ -1093,6 +1097,7 @@ test("opens right dock singleton tabs and updates file tree state per project", query: "x".repeat(250), selectedPath: "src/../main.ts", expandedPaths: ["", "src", "src/../bad", "src\\components", "src"], + showHidden: true, bumpRevision: true, }); const updatedState = settings.getRightDockProjectState( @@ -1109,6 +1114,7 @@ test("opens right dock singleton tabs and updates file tree state per project", query: "x".repeat(200), selectedPath: "src/main.ts", expandedPaths: ["", "src", "src/bad", "src/components"], + showHidden: true, revision: 1, }); assert.equal(updatedState.openVersion, 1); @@ -1269,6 +1275,7 @@ test("gateway settings sync keeps right dock width local and syncs project state query: "desktop", selectedPath: "desktop.ts", expandedPaths: ["", "src"], + showHidden: true, revision: 1, stateVersion: 3, }, @@ -1341,6 +1348,7 @@ test("gateway settings sync keeps right dock width local and syncs project state query: "desktop", selectedPath: "desktop.ts", expandedPaths: ["", "src"], + showHidden: true, revision: 1, }, ); diff --git a/crates/agent-gui/test/settings/right-dock-model.test.mjs b/crates/agent-gui/test/settings/right-dock-model.test.mjs index e7473dd0c..4880658a5 100644 --- a/crates/agent-gui/test/settings/right-dock-model.test.mjs +++ b/crates/agent-gui/test/settings/right-dock-model.test.mjs @@ -69,6 +69,7 @@ test("normalizeRightDockProjectState migrates the full legacy tabs shape", () => query: "abc", selectedPath: "src/x.ts", expandedPaths: ["", "src"], + showHidden: true, revision: 2, stateVersion: 3, }, @@ -93,6 +94,7 @@ test("normalizeRightDockProjectState migrates the full legacy tabs shape", () => query: "abc", selectedPath: "src/x.ts", expandedPaths: ["", "src"], + showHidden: true, revision: 2, }, }, @@ -486,6 +488,7 @@ test("right dock merge accepts legacy-shaped incoming project state", () => { query: "web", selectedPath: "web.ts", expandedPaths: ["", "src"], + showHidden: true, revision: 1, stateVersion: 2, }, @@ -508,6 +511,7 @@ test("right dock merge accepts legacy-shaped incoming project state", () => { query: "web", selectedPath: "web.ts", expandedPaths: ["", "src"], + showHidden: true, revision: 1, }); }); 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。 |