diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 84c28659..777faccd 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -134,6 +134,27 @@ it('persists the story graph switch per conversation without schema migration or } finally { database.close() } }) +it('persists the composer role per conversation and the role snapshot on replies', async () => { + const database = await createDatabase() + const expertId = randomUUID() + const header = { id: randomUUID(), title: 'Role preference', updatedAt: 1000, selectedExpertId: expertId } + const team = { id: randomUUID(), title: 'Team preference', updatedAt: 1000, selectedExpertId: 'team' as const } + const plain = { id: randomUUID(), title: 'No preference', updatedAt: 1000 } + const reply = { id: randomUUID(), role: 'assistant' as const, content: 'Reviewed', state: 'complete' as const, + createdAt: 900, expert: { kind: 'expert' as const, id: expertId, name: '代码审查专家' } } + try { + database.saveLocalConversations([{ header, messages: [reply] }, { header: team, messages: [] }, { header: plain, messages: [] }]) + database.close() + database.initialize('C:\\Workspace') + expect(database.getConversation(header.id)).toMatchObject({ selectedExpertId: expertId, messages: [{ expert: reply.expert }] }) + expect(database.getConversation(team.id).selectedExpertId).toBe('team') + expect(database.getConversation(plain.id).selectedExpertId).toBeUndefined() + database.saveLocalConversations([{ header: { ...header, selectedExpertId: undefined, updatedAt: 1001 }, messages: [] }]) + expect(database.getConversation(header.id).selectedExpertId).toBeUndefined() + expect(localConversationHeaderSchema.safeParse({ ...plain, selectedExpertId: 'not-an-id' }).success).toBe(false) + } finally { database.close() } +}) + it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => { const database = await createDatabase() const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 } diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 1839d222..725a2296 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -364,6 +364,7 @@ type MessageMetadata = { contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions'] tools?: ConversationSnapshot['messages'][number]['tools'] subagents?: ConversationSnapshot['messages'][number]['subagents'] + expert?: ConversationMessage['expert'] sources?: string[] sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences'] knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval'] @@ -1238,6 +1239,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({ knowledgeLibraryIds: true, knowledgeRetrievalMode: true, storyGraphEnabled: true, + selectedExpertId: true, contextMetrics: true, contextCompressionState: true }) @@ -1246,7 +1248,7 @@ function parseConversationContextState( value: string | null ): Pick< ConversationSnapshot, - 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState' + 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState' > { if (!value) { return {} @@ -1264,18 +1266,20 @@ function parseConversationContextState( function serializeConversationContextState( conversation: Pick< ConversationSnapshot, - 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState' + 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState' > ): string | null { return conversation.knowledgeLibraryIds !== undefined || conversation.knowledgeRetrievalMode !== undefined || conversation.storyGraphEnabled !== undefined || + conversation.selectedExpertId !== undefined || conversation.contextMetrics || conversation.contextCompressionState ? JSON.stringify({ knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, storyGraphEnabled: conversation.storyGraphEnabled, + selectedExpertId: conversation.selectedExpertId, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState }) @@ -1346,6 +1350,7 @@ function toConversationSnapshot( contextCompressions: metadata.contextCompressions, tools: metadata.tools, subagents: metadata.subagents, + expert: metadata.expert, sources: metadata.sources, sourceReferences: metadata.sourceReferences, knowledgeRetrieval: metadata.knowledgeRetrieval, @@ -1377,6 +1382,7 @@ function serializeConversationMessageMetadata( contextCompressions: message.contextCompressions, tools: message.tools, subagents: message.subagents, + expert: message.expert, sources: message.sources, sourceReferences: message.sourceReferences, knowledgeRetrieval: message.knowledgeRetrieval, @@ -1784,6 +1790,7 @@ function storedRemoteAssistantMessage( contextCompressions: metadata.contextCompressions, tools: metadata.tools, subagents: metadata.subagents, + expert: metadata.expert, sources: metadata.sources, sourceReferences: metadata.sourceReferences, knowledgeRetrieval: metadata.knowledgeRetrieval, @@ -5638,6 +5645,7 @@ export class AssistantDatabase { contextCompressions: metadata.contextCompressions, tools: metadata.tools, subagents: metadata.subagents, + expert: metadata.expert, sources: metadata.sources, sourceReferences: metadata.sourceReferences, knowledgeRetrieval: metadata.knowledgeRetrieval, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 65f963af..6fb6552a 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -8751,13 +8751,16 @@ describe("App", () => { const conversationSettings = within(composer!).getByRole("group", { name: "对话设置", }); - // Order: context sources, expert, Runtime, then Runtime-specific settings. + // The role chip sits in the input's top-left corner, before the textarea. + const inputArea = composer!.querySelector(".composer__input")!; + const expertTrigger = within(inputArea).getByRole("button", { name: "专家角色:通用助手" }); + expect(expertTrigger.compareDocumentPosition(screen.getByLabelText("向 GoodBuddy 提问")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(conversationSettings).queryByRole("button", { name: "专家角色:通用助手" })).not.toBeInTheDocument(); + // Toolbar order: context sources, Runtime, then Runtime-specific settings. const contextTrigger = within(conversationSettings).getByRole("button", { name: "故事图谱与知识库" }); - const expertTrigger = within(conversationSettings).getByRole("button", { name: "专家角色:通用助手" }); const runtimeTrigger = within(conversationSettings).getByRole("button", { name: /默认模型/u }); expect(contextTrigger).toHaveAttribute("aria-expanded", "false"); - expect(contextTrigger.compareDocumentPosition(expertTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(expertTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(contextTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect( within(conversationSettings).queryByRole("button", { name: /工作模式/u, @@ -12583,7 +12586,8 @@ describe("App", () => { it("can dispatch a request to the parallel expert team", async () => { render(); - + // The role belongs to the loaded conversation. + await screen.findByLabelText("发送"); selectComposerOption("专家角色", "专家团队(并行)"); fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "制定发布计划" }, @@ -12671,6 +12675,43 @@ describe("App", () => { ); }); + it("keeps the role per conversation and snapshots it on the reply", async () => { + const expertId = "00000000-0000-4000-8000-000000000511"; + vi.mocked(api.experts.list).mockResolvedValueOnce([ + { + id: expertId, + name: "审查专家", + description: "代码审查", + systemInstructions: "Review code.", + routingKeywords: [], + enabled: true, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }, + ]); + const { container } = render(); + await waitFor(() => expect(api.experts.list).toHaveBeenCalled()); + const firstTitle = container.querySelector(".conversation-item--active .conversation-item__title")?.textContent; + const expertMenu = openComposerMenu("专家角色"); + fireEvent.click( + (await within(expertMenu).findByText("审查专家", { selector: "span" })).closest("button")!, + ); + expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:审查专家"); + + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "审查这个改动" } }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledWith(expect.objectContaining({ expertId }))); + expect(await screen.findByText("审查专家", { selector: ".message__role" })).toBeVisible(); + + const create = screen.getAllByRole("button", { name: "新建对话" })[0]!; + fireEvent.click(create); + await waitFor(() => expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:通用助手")); + + fireEvent.click(within(container.querySelector(".conversation-list")!) + .getByTitle(firstTitle && firstTitle !== "新对话" ? firstTitle : "审查这个改动")); + await waitFor(() => expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:审查专家")); + }); + it("shows Subagent states and records child expert activity", async () => { render(); fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f53e6e94..d2fa89a7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -842,6 +842,7 @@ function toConversationSnapshots( knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, storyGraphEnabled: conversation.storyGraphEnabled, + selectedExpertId: conversation.selectedExpertId, contextMetrics: conversation.contextMetrics, contextCompressionState: conversation.contextCompressionState, ...(conversation.branch ? { branch: conversation.branch } : {}), @@ -1153,7 +1154,6 @@ function App(): React.JSX.Element { const [assistantExperts, setAssistantExperts] = useState( [], ); - const [selectedExpertId, setSelectedExpertId] = useState(""); const [activeProjectId, setActiveProjectId] = useState(""); const activeProjectIdRef = useRef(activeProjectId); const workspaceChangesRequestRef = useRef(0); @@ -2280,6 +2280,34 @@ function App(): React.JSX.Element { // Header fields of the active conversation, value-compared: message updates // (streaming flushes, tool events) do not re-render App. const activeConversation = useActiveConversationView(conversationStore, activeId); + // The role follows the conversation; a deleted role reads as the general + // assistant. + const resolveExpertSelection = useCallback( + (value: string | undefined): string => + !value || value === "team" || + assistantExperts.some((expert) => expert.id === value) + ? value ?? "" + : "", + [assistantExperts], + ); + const selectedExpertId = resolveExpertSelection(activeConversation?.selectedExpertId); + const setSelectedExpertId = useCallback( + (value: string): void => { + setConversations((current) => + current.map((conversation) => + conversation.id === activeId && + (conversation.selectedExpertId ?? "") !== value + ? { + ...conversation, + selectedExpertId: value ? value : undefined, + updatedAt: Date.now(), + } + : conversation, + ), + ); + }, + [activeId, setConversations], + ); const enabledKnowledgeLibraryIds = activeConversation?.knowledgeLibraryIds ?? []; const setEnabledKnowledgeLibraryIds = useCallback( @@ -5113,7 +5141,7 @@ function App(): React.JSX.Element { : (queuedInput.expertId ?? "") : runtime?.capability === "image-generation" ? "" - : selectedExpertId; + : resolveExpertSelection(conversationSnapshot.selectedExpertId); const knowledgeLibraryIdsSnapshot = queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds; const smartRoutingSnapshot = @@ -5210,6 +5238,14 @@ function App(): React.JSX.Element { const executionPrompt = memoryContext ? `${prompt}\n\n${memoryContext}` : prompt; + const selectedExpert = selectedExpertSnapshot && selectedExpertSnapshot !== "team" + ? assistantExperts.find((expert) => expert.id === selectedExpertSnapshot) + : undefined; + const expertSnapshot: Message["expert"] = selectedExpertSnapshot === "team" + ? { kind: "team" } + : selectedExpert + ? { kind: "expert", id: selectedExpert.id, name: selectedExpert.name.trim().slice(0, 80) } + : undefined; const assistantMessage: Message = { id: crypto.randomUUID(), role: "assistant", @@ -5218,6 +5254,7 @@ function App(): React.JSX.Element { createdAt: Date.now(), state: "streaming", status: t("chat.status.preparingRequest"), + ...(expertSnapshot ? { expert: expertSnapshot } : {}), }; activeRuns.current.set(requestId, { @@ -6512,16 +6549,10 @@ function App(): React.JSX.Element { onClose: () => { commitView(viewRef.current); }, + // Each conversation keeps its role; one that no longer resolves reads as + // the general assistant (see resolveExpertSelection). onExpertsChanged: (experts: AssistantExpert[]) => { setAssistantExperts(experts); - if ( - (selectedExpertId === "team" && experts.length < 2) || - (selectedExpertId && - selectedExpertId !== "team" && - !experts.some((expert) => expert.id === selectedExpertId)) - ) { - setSelectedExpertId(""); - } }, onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange, onProjectsDeleted: removeProjectsFromUi, diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index de9bd5d9..e679992e 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -180,6 +180,23 @@ describe('ChatTimeline', () => { } }) + it('keeps the GoodBuddy name and shows the saved role as a caption below it', () => { + const props = { ...callbacks, artifactById: new Map(), conversationId: 'role', hiddenMessageCount: 0, + isUnusedConversation: false, locale: 'zh-CN' as const, messageStartIndex: 0, totalMessageCount: 3 } + const messages: Message[] = [ + { id: 'expert', role: 'assistant', content: 'a', createdAt: 1, state: 'complete', + expert: { kind: 'expert', id: '00000000-0000-4000-8000-000000000901', name: '代码审查专家' } }, + { id: 'team', role: 'assistant', content: 'b', createdAt: 2, state: 'complete', expert: { kind: 'team' } }, + { id: 'plain', role: 'assistant', content: 'c', createdAt: 3, state: 'complete' } + ] + const view = render() + const articles = view.container.querySelectorAll('article.message') + expect([...articles].map(article => article.querySelector('.message__meta strong')?.textContent)) + .toEqual(['GoodBuddy', 'GoodBuddy', 'GoodBuddy']) + expect([...articles].map(article => article.querySelector('.message__role')?.textContent ?? null)) + .toEqual(['代码审查专家', '专家团队(并行)', null]) + }) + it('captures only nonempty settled assistant replies, including incomplete replies', () => { const onAddToNote = vi.fn() const messages: Message[] = [ diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 2f95ab55..b78f8526 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -73,6 +73,7 @@ export type Message = { contextCompressions?: ConversationMessage['contextCompressions'] tools?: ToolActivity[] subagents?: SubagentActivity[] + expert?: ConversationMessage['expert'] approval?: { id: string taskId?: string @@ -754,6 +755,14 @@ function ChatMessageRowView({ : t('chat.status.waitingForProgress')) : message.status + const roleCaption = message.role !== 'assistant' + ? undefined + : message.expert?.kind === 'expert' + ? message.expert.name + : message.expert?.kind === 'team' + ? t('composer.experts.team') + : undefined + return ( <>
)} -
+
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')} @@ -786,6 +795,11 @@ function ChatMessageRowView({ )} {formatTime(message.createdAt, locale)}
+ {roleCaption && ( + + {roleCaption} + + )}
{message.attachments && message.attachments.length > 0 && ( diff --git a/src/renderer/src/Composer.tsx b/src/renderer/src/Composer.tsx index 16d040e7..e8b9071e 100644 --- a/src/renderer/src/Composer.tsx +++ b/src/renderer/src/Composer.tsx @@ -653,6 +653,22 @@ export const Composer = memo(function Composer({
)}
+
+
)} -