feat: prepare GoodBuddy 0.8.0

This commit is contained in:
lofyer
2026-08-05 19:40:40 +08:00
parent 38ac2206f2
commit 39a457ded8
96 changed files with 14221 additions and 1027 deletions
+31 -1
View File
@@ -140,7 +140,7 @@ describe('ActivityPanel', () => {
)
expect(
screen.getByText(
'任务请求、工具调用和审批决定会显示在这里。'
'任务请求、子专家、工具调用和审批决定会显示在这里。'
)
).toBeInTheDocument()
expect(
@@ -148,6 +148,36 @@ describe('ActivityPanel', () => {
).toBeDisabled()
})
it('labels Subagent activity as child expert work', () => {
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={[
{
...makeRecord(1),
kind: 'subagent',
title: '研究专家',
detail: '智能路由 · 分析证据',
status: 'running'
}
]}
tokenUsage={makeTokenUsage()}
/>
)
expect(screen.getByText('子专家')).toBeInTheDocument()
const item = screen.getByText('研究专家').closest('article')
expect(item).not.toBeNull()
if (!item) {
return
}
expect(
within(item).getByText('智能路由 · 分析证据')
).toBeInTheDocument()
expect(within(item).getByText('进行中')).toBeInTheDocument()
})
it('uses the shared page hierarchy and explicit global scope', () => {
render(
<ActivityPanel
+3 -2
View File
@@ -40,6 +40,7 @@ const kindLabels: Record<ActivityRecord['kind'], string> = {
request: '任务',
tool: '工具',
approval: '审批',
subagent: '子专家',
result: '结果'
}
@@ -128,7 +129,7 @@ function emptyMessage(filter: ActivityFilter): string {
if (filter === 'failed') {
return '当前没有失败、取消或中断的活动。'
}
return '任务请求、工具调用和审批决定会显示在这里。'
return '任务请求、子专家、工具调用和审批决定会显示在这里。'
}
export function ActivityPanel({
@@ -187,7 +188,7 @@ export function ActivityPanel({
triggerLabel="清空记录"
/>
}
description="查看全部项目中的任务请求、工具调用、审批结果和 Token 用量。"
description="查看全部项目中的任务请求、子专家、工具调用、审批结果和 Token 用量。"
eyebrow="ACTIVITY AUDIT"
headingId="activity-panel-title"
icon={<Activity size={20} />}
+403 -8
View File
@@ -96,6 +96,7 @@ const api: DesktopApi = {
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
imageGenerationQuality: 'auto',
opencodeBaseUrl: '',
opencodeEmbedded: false,
opencodeBinaryPath: '',
@@ -104,9 +105,13 @@ const api: DesktopApi = {
continueConfigPath: '',
continueMode: 'chat',
runtimeSandboxMode: 'auto',
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434',
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
knowledgeEmbeddingModel: 'nomic-embed-text',
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none',
workspacePath: 'C:\\Users\\test',
apiKeyConfigured: false,
credentialSource: 'none',
@@ -118,6 +123,7 @@ const api: DesktopApi = {
modelName: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
@@ -135,6 +141,7 @@ const api: DesktopApi = {
modelName: input.modelName,
modelProtocol: input.modelProtocol,
modelAuthentication: input.modelAuthentication,
imageGenerationQuality: input.imageGenerationQuality,
opencodeBaseUrl: input.opencodeBaseUrl,
opencodeEmbedded: input.opencodeEmbedded,
opencodeBinaryPath: input.opencodeBinaryPath,
@@ -143,9 +150,17 @@ const api: DesktopApi = {
continueConfigPath: input.continueConfigPath,
continueMode: input.continueMode,
runtimeSandboxMode: input.runtimeSandboxMode,
subagentSmartRoutingEnabled:
input.subagentSmartRoutingEnabled ?? false,
knowledgeEmbeddingEnabled: input.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl: input.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: input.knowledgeEmbeddingModel,
knowledgeEmbeddingApiKeyConfigured:
input.knowledgeEmbeddingApiKey?.action === 'replace',
knowledgeEmbeddingCredentialSource:
input.knowledgeEmbeddingApiKey?.action === 'replace'
? 'encrypted'
: 'none',
workspacePath: input.workspacePath,
apiKeyConfigured: input.apiKey.action === 'replace',
credentialSource:
@@ -159,6 +174,8 @@ const api: DesktopApi = {
modelName: input.modelName,
protocol: input.modelProtocol,
authentication: input.modelAuthentication,
imageGenerationQuality:
input.imageGenerationQuality,
apiKey: input.apiKey
}
]
@@ -382,6 +399,7 @@ const api: DesktopApi = {
captureScreen: vi.fn(async () => {
throw new Error('not used')
}),
listWindows: vi.fn(async () => []),
captureWindow: vi.fn(async () => {
throw new Error('not used')
}),
@@ -452,6 +470,7 @@ describe('App', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('provides custom minimize, maximize, and close controls', async () => {
@@ -607,6 +626,185 @@ describe('App', () => {
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('keeps sent documents and images in conversation history', async () => {
const documentAttachment = {
id: '00000000-0000-4000-8000-000000000301',
name: '需求说明.md',
size: 2_048,
preview: '需要保留在用户消息中的文档',
kind: 'text' as const
}
const imageAttachment = {
id: '00000000-0000-4000-8000-000000000302',
name: '页面截图.png',
size: 4_096,
preview: '1280 × 720',
kind: 'image' as const,
thumbnailUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB',
contentUrl:
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q=='
}
vi.mocked(api.context.selectFiles).mockResolvedValueOnce([
documentAttachment,
imageAttachment
])
render(<App />)
fireEvent.click(await screen.findByLabelText('添加附件'))
expect(await screen.findByText('需求说明.md')).toBeInTheDocument()
expect(screen.getByText('页面截图.png')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '分析这些附件' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(run.mock.calls[0]?.[0].contextIds).toEqual([
documentAttachment.id,
imageAttachment.id
])
const userArticle = screen
.getAllByText('分析这些附件')
.map((element) => element.closest('article'))
.find((element) => element?.classList.contains('message--user'))
expect(userArticle).not.toBeNull()
if (!userArticle) {
return
}
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
expect(within(userArticle).getByText('需求说明.md')).toBeInTheDocument()
expect(within(userArticle).getByText('2 KB')).toBeInTheDocument()
expect(
within(userArticle).getByRole('img', { name: '页面截图.png' })
).toHaveAttribute('src', imageAttachment.contentUrl)
fireEvent.click(
within(userArticle).getByRole('button', {
name: '查看图片 页面截图.png'
})
)
const imageDialog = await screen.findByRole('dialog', {
name: '页面截图.png'
})
expect(
within(imageDialog).getByRole('img', { name: '页面截图.png' })
).toHaveAttribute('src', imageAttachment.contentUrl)
fireEvent.click(
within(imageDialog).getByRole('button', {
name: '关闭图片查看器'
})
)
expect(
screen.queryByRole('dialog', { name: '页面截图.png' })
).not.toBeInTheDocument()
fireEvent.click(
within(userArticle).getByRole('button', {
name: '下载图片 页面截图.png'
})
)
expect(anchorClick).toHaveBeenCalledOnce()
await waitFor(
() =>
expect(api.conversations.replace).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: 'user',
attachments: [
documentAttachment,
imageAttachment
]
})
])
})
])
),
{ timeout: 2_000 }
)
})
it('sends and renders five selected images together', async () => {
const imageAttachments = Array.from({ length: 5 }, (_, index) => ({
id: `00000000-0000-4000-8000-00000000031${index}`,
name: `参考图-${index + 1}.png`,
size: 4_096,
preview: '640 × 480',
kind: 'image' as const,
thumbnailUrl:
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q==',
contentUrl:
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2Q=='
}))
vi.mocked(api.context.selectFiles).mockResolvedValueOnce(imageAttachments)
render(<App />)
fireEvent.click(await screen.findByLabelText('添加附件'))
await waitFor(() =>
expect(screen.getAllByText(/^-\d\.png$/u)).toHaveLength(5)
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '比较这五张图片' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(run.mock.calls[0]?.[0].contextIds).toEqual(
imageAttachments.map((attachment) => attachment.id)
)
const userArticle = screen
.getAllByText('比较这五张图片')
.map((element) => element.closest('article'))
.find((element) => element?.classList.contains('message--user'))
expect(userArticle).not.toBeNull()
if (!userArticle) {
return
}
expect(within(userArticle).getAllByRole('img')).toHaveLength(5)
expect(within(userArticle).getByLabelText('消息附件')).toHaveClass(
'message-attachments'
)
})
it('lists capturable application windows vertically before capture', async () => {
vi.mocked(api.context.listWindows).mockResolvedValueOnce([
{ id: 'window-1', name: 'Visual Studio Code' },
{ id: 'window-2', name: 'Browser' },
{ id: 'window-3', name: 'Terminal' }
])
vi.mocked(api.context.captureWindow).mockResolvedValueOnce({
id: '00000000-0000-4000-8000-000000000303',
name: '窗口-Browser.jpg',
size: 120_000,
preview: '1280 × 800',
kind: 'image',
thumbnailUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'
})
render(<App />)
fireEvent.click(await screen.findByLabelText('捕获应用窗口'))
const dialog = await screen.findByRole('dialog', {
name: '选择应用窗口'
})
const list = within(dialog).getByLabelText('可捕获的应用窗口')
expect(list).toHaveClass('window-capture-dialog__list')
expect(within(list).getAllByRole('button')).toHaveLength(3)
fireEvent.click(
within(list).getByRole('button', { name: 'Browser' })
)
await waitFor(() =>
expect(api.context.captureWindow).toHaveBeenCalledWith('window-2')
)
expect(
await screen.findByText('窗口-Browser.jpg')
).toBeInTheDocument()
})
it('keeps a draft in chat when Enter is pressed while the runtime loads', async () => {
vi.mocked(api.agent.getStatus).mockReturnValue(
new Promise(() => {})
@@ -741,7 +939,7 @@ describe('App', () => {
render(<App />)
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
fireEvent.click(await screen.findByRole('tab', { name: '更改' }))
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
fireEvent.click(
await screen.findByRole('button', { name: /README\.md/u })
)
@@ -776,7 +974,7 @@ describe('App', () => {
render(<App />)
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
fireEvent.click(await screen.findByRole('tab', { name: '更改' }))
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledOnce()
)
@@ -828,7 +1026,7 @@ describe('App', () => {
render(<App />)
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
fireEvent.click(await screen.findByRole('tab', { name: '更改' }))
fireEvent.click(await screen.findByRole('tab', { name: '工作区' }))
await waitFor(() =>
expect(api.workspace.getChanges).toHaveBeenCalledWith(projectId)
)
@@ -1258,6 +1456,9 @@ describe('App', () => {
})
it('marks an image model and renders its generated artifact', async () => {
const anchorClick = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {})
vi.mocked(api.agent.getStatus).mockResolvedValueOnce({
id: 'model',
label: 'gpt-image-2',
@@ -1316,6 +1517,35 @@ describe('App', () => {
expect(
await screen.findByRole('img', { name: '生成一只蓝色的猫' })
).toHaveAttribute('src', expect.stringMatching(/^data:image\/png/u))
fireEvent.click(
screen.getByRole('button', {
name: '下载图片 生成一只蓝色的猫'
})
)
expect(anchorClick).toHaveBeenCalledOnce()
fireEvent.click(
screen.getByRole('button', {
name: '查看图片 生成一只蓝色的猫'
})
)
const imageDialog = await screen.findByRole('dialog', {
name: '生成一只蓝色的猫'
})
expect(
within(imageDialog).getByRole('img', {
name: '生成一只蓝色的猫'
})
).toHaveAttribute('src', expect.stringMatching(/^data:image\/png/u))
fireEvent.click(
within(imageDialog).getByRole('button', { name: '下载图片' })
)
expect(anchorClick).toHaveBeenCalledTimes(2)
fireEvent.keyDown(imageDialog, { key: 'Escape' })
expect(
screen.queryByRole('dialog', { name: '生成一只蓝色的猫' })
).not.toBeInTheDocument()
anchorClick.mockRestore()
})
it('can dispatch a request to the parallel expert team', async () => {
@@ -1340,6 +1570,164 @@ describe('App', () => {
)
})
it('requests smart routing only when enabled without an explicit expert', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
subagentSmartRoutingEnabled: true
})
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '分析发布风险' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
smartRouting: true,
expertId: undefined,
teamMode: false,
workMode: 'ask'
})
)
)
})
it('gives an explicitly selected expert priority over smart routing', async () => {
const expertId = '00000000-0000-4000-8000-000000000501'
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
subagentSmartRoutingEnabled: true
})
vi.mocked(api.experts.list).mockResolvedValueOnce([
{
id: expertId,
name: '发布专家',
description: '检查发布风险',
systemInstructions: 'Review release risks.',
routingKeywords: ['发布', '风险'],
enabled: true,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
])
render(<App />)
await screen.findByRole('option', { name: '发布专家' })
fireEvent.change(screen.getByLabelText('专家角色'), {
target: { value: expertId }
})
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '检查发布方案' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
expertId,
smartRouting: undefined,
teamMode: false
})
)
)
})
it('shows bounded Subagent states and records child expert activity', async () => {
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '分析复杂问题' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
const events = [
{
childTaskId: '00000000-0000-4000-8000-000000000601',
expertId: '00000000-0000-4000-8000-000000000701',
expertName: '研究专家',
routingMode: 'smart' as const,
state: 'queued' as const
},
{
childTaskId: '00000000-0000-4000-8000-000000000602',
expertId: '00000000-0000-4000-8000-000000000702',
expertName: '代码专家',
routingMode: 'manual' as const,
state: 'running' as const
},
{
childTaskId: '00000000-0000-4000-8000-000000000603',
expertId: '00000000-0000-4000-8000-000000000703',
expertName: '安全专家',
routingMode: 'smart' as const,
state: 'failed' as const,
error: '无法读取必要上下文'
},
{
childTaskId: '00000000-0000-4000-8000-000000000604',
expertId: '00000000-0000-4000-8000-000000000704',
expertName: '第四位专家',
routingMode: 'smart' as const,
state: 'completed' as const
}
]
act(() => {
for (const event of events) {
agentListener?.({
requestId: request.requestId,
type: 'subagent',
...event
})
}
})
const statusRegion = await screen.findByLabelText('子专家状态')
expect(within(statusRegion).getByText('研究专家')).toBeInTheDocument()
expect(within(statusRegion).getByText('等待中')).toBeInTheDocument()
expect(within(statusRegion).getByText('代码专家')).toBeInTheDocument()
expect(within(statusRegion).getByText('进行中')).toBeInTheDocument()
expect(within(statusRegion).getByText('安全专家')).toBeInTheDocument()
expect(within(statusRegion).getByText('失败')).toBeInTheDocument()
expect(
within(statusRegion).getByText('无法读取必要上下文')
).toBeInTheDocument()
expect(
within(statusRegion).queryByText('第四位专家')
).not.toBeInTheDocument()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'subagent',
...events[0]!,
state: 'completed'
})
agentListener?.({
requestId: request.requestId,
type: 'subagent',
...events[1]!,
state: 'cancelled',
reason: '父任务已停止'
})
})
expect(within(statusRegion).getByText('已完成')).toBeInTheDocument()
expect(within(statusRegion).getByText('已取消')).toBeInTheDocument()
expect(within(statusRegion).getByText('父任务已停止')).toBeInTheDocument()
fireEvent.click(screen.getByText('任务与活动'))
expect(await screen.findAllByText('子专家')).toHaveLength(4)
expect(screen.getAllByText(//u).length).toBeGreaterThan(0)
expect(screen.getAllByText(//u).length).toBeGreaterThan(0)
})
it('offers once, session, permanent, and deny for a tool call', async () => {
render(<App />)
@@ -1431,8 +1819,15 @@ describe('App', () => {
expect(
screen.getByText('尚未添加文件、截图或剪贴板内容。')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '成果' }))
expect(screen.getByText('对话成果')).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '任务中心' }))
expect(
screen.getByText(/查看当前和最近请求的运行状态/)
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '成果库' }))
expect(screen.getByText('对话与导入成果')).toBeInTheDocument()
expect(
screen.getByText(/保存并预览由对话生成或手动导入/)
).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
})
@@ -1453,7 +1848,7 @@ describe('App', () => {
conversationId: conversationId ?? '',
status: 'ready',
url: 'https://example.com/',
frameDataUrl: 'data:image/png;base64,iVBORw0KGgo=',
frameDataUrl: 'data:image/jpeg;base64,/9j/2Q==',
updatedAt: Date.now()
})
})
@@ -1468,7 +1863,7 @@ describe('App', () => {
screen.getByAltText('Agent 实时浏览器画面')
).toHaveAttribute(
'src',
'data:image/png;base64,iVBORw0KGgo='
'data:image/jpeg;base64,/9j/2Q=='
)
fireEvent.click(
screen.getByRole('button', { name: '停止浏览器' })
+603 -46
View File
@@ -45,7 +45,8 @@ import type {
KnowledgeSearchReference,
KnowledgeSnapshot,
RuntimeSettings,
RuntimeSettingsInput
RuntimeSettingsInput,
WindowCaptureOption
} from '../../shared/contracts'
import type {
AssistantProject,
@@ -60,11 +61,13 @@ import type {
AssistantTask,
TokenUsageSummary,
ConversationSnapshot,
ConversationAttachment,
ProjectCreateInput,
InteractiveWorkMode,
WorkspaceChanges
} from '../../shared/assistant-contracts'
import {
conversationAttachmentSchema,
interactiveWorkModes,
normalizeInteractiveWorkMode
} from '../../shared/assistant-contracts'
@@ -107,6 +110,12 @@ function isAgentRuntime(
return runtime?.id === 'opencode' || runtime?.id === 'continue'
}
function supportsSubagentSmartRouting(
workMode: string
): boolean {
return workMode === 'ask' || ['plan'].includes(workMode)
}
type ToolActivity = {
callId?: string
name: string
@@ -119,6 +128,17 @@ type ToolActivity = {
| 'cancelled'
| 'interrupted'
summary: string
error?: string
}
type SubagentActivity = {
childTaskId: string
expertId: string
expertName: string
routingMode: 'manual' | 'smart'
state: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
reason?: string
error?: string
}
type Message = {
@@ -129,6 +149,7 @@ type Message = {
state: 'streaming' | 'complete' | 'error'
status?: string
tools?: ToolActivity[]
subagents?: SubagentActivity[]
approval?: {
id: string
title: string
@@ -140,6 +161,7 @@ type Message = {
sources?: string[]
sourceReferences?: KnowledgeSearchReference[]
artifactIds?: string[]
attachments?: ConversationAttachment[]
}
type Conversation = {
@@ -150,6 +172,11 @@ type Conversation = {
messages: Message[]
}
type ImageViewerItem = {
src: string
title: string
}
type ActiveRun = {
conversationId: string
messageId: string
@@ -205,6 +232,14 @@ const toolStateLabels: Record<ToolActivity['state'], string> = {
interrupted: '已中断'
}
const subagentStateLabels: Record<SubagentActivity['state'], string> = {
queued: '等待中',
running: '进行中',
completed: '已完成',
failed: '失败',
cancelled: '已取消'
}
function createConversation(projectId?: string): Conversation {
const now = Date.now()
return {
@@ -233,6 +268,12 @@ function isUnusedConversation(conversation: Conversation): boolean {
)
}
function isConversationAttachment(
value: unknown
): value is ConversationAttachment {
return conversationAttachmentSchema.safeParse(value).success
}
function loadConversations(): Conversation[] {
try {
const value = localStorage.getItem(storageKey)
@@ -297,7 +338,11 @@ function isConversation(value: unknown): value is Conversation {
entry.artifactIds.length <= 8 &&
entry.artifactIds.every(
(artifactId) => typeof artifactId === 'string'
)))
))) &&
(entry.attachments === undefined ||
(Array.isArray(entry.attachments) &&
entry.attachments.length <= 8 &&
entry.attachments.every(isConversationAttachment)))
)
})
)
@@ -321,7 +366,8 @@ function toConversationSnapshots(
tools: message.tools,
sources: message.sources,
sourceReferences: message.sourceReferences,
artifactIds: message.artifactIds
artifactIds: message.artifactIds,
attachments: message.attachments
}))
}))
}
@@ -361,6 +407,8 @@ function createRuntimeSwitchInput(
modelName: selectedProfile.modelName,
modelProtocol: selectedProfile.protocol,
modelAuthentication: selectedProfile.authentication,
imageGenerationQuality:
selectedProfile.imageGenerationQuality,
opencodeBaseUrl: settings.opencodeBaseUrl,
opencodeEmbedded: settings.opencodeEmbedded,
opencodeBinaryPath: settings.opencodeBinaryPath,
@@ -369,6 +417,8 @@ function createRuntimeSwitchInput(
continueConfigPath: settings.continueConfigPath,
continueMode: settings.continueMode,
runtimeSandboxMode: settings.runtimeSandboxMode,
subagentSmartRoutingEnabled:
settings.subagentSmartRoutingEnabled,
knowledgeEmbeddingEnabled: settings.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl: settings.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: settings.knowledgeEmbeddingModel,
@@ -381,6 +431,7 @@ function createRuntimeSwitchInput(
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: { action: 'keep' }
})),
defaultModelProfileId: selectedProfile.id,
@@ -397,6 +448,37 @@ function formatTime(timestamp: number): string {
}).format(timestamp)
}
function formatAttachmentSize(size: number): string {
return `${Math.max(1, Math.ceil(size / 1024))} KB`
}
const imageDataUrlPattern =
/^data:image\/(png|jpeg|webp);base64,/u
function getImageDownloadName(title: string, src: string): string {
const extension = imageDataUrlPattern.exec(src)?.[1] ?? 'png'
const normalizedExtension = extension === 'jpeg' ? 'jpg' : extension
const safeTitle =
title
.replace(/\.(?:jpe?g|png|webp)$/iu, '')
.replace(/[\\/:*?"<>|]/gu, '_')
.trim() || 'GoodBuddy 图片'
return `${safeTitle}.${normalizedExtension}`
}
function formatAttachmentList(
attachments: ConversationAttachment[] | undefined
): string {
return attachments?.length
? `\n\n附件:\n${attachments
.map(
(attachment) =>
`- ${attachment.name}${formatAttachmentSize(attachment.size)}`
)
.join('\n')}`
: ''
}
function buildKnowledgeContext(
references: KnowledgeSearchReference[]
): string {
@@ -602,7 +684,32 @@ function App(): React.JSX.Element {
const [renamingConversationId, setRenamingConversationId] = useState('')
const [notice, setNotice] = useState<string>()
const [attachments, setAttachments] = useState<ContextAttachment[]>([])
const attachmentsRef = useRef<ContextAttachment[]>([])
const updateAttachments = useCallback(
(
update:
| ContextAttachment[]
| ((current: ContextAttachment[]) => ContextAttachment[])
): void => {
const next =
typeof update === 'function'
? update(attachmentsRef.current)
: update
attachmentsRef.current = next
setAttachments(next)
},
[]
)
const [contextError, setContextError] = useState<string>()
const [imageViewerItem, setImageViewerItem] =
useState<ImageViewerItem>()
const imageViewerTriggerRef = useRef<HTMLElement | undefined>(
undefined
)
const [windowCaptureOptions, setWindowCaptureOptions] = useState<
WindowCaptureOption[]
>()
const [windowCaptureLoading, setWindowCaptureLoading] = useState(false)
const [knowledgeSnapshot, setKnowledgeSnapshot] = useState<KnowledgeSnapshot>({
libraries: [],
sources: [],
@@ -781,7 +888,7 @@ function App(): React.JSX.Element {
setActiveId(conversation.id)
setView('chat')
setInput('')
setAttachments((current) => {
updateAttachments((current) => {
for (const attachment of current) {
void window.goodbuddy.context.remove(attachment.id)
}
@@ -789,7 +896,7 @@ function App(): React.JSX.Element {
})
requestAnimationFrame(() => inputRef.current?.focus())
},
[]
[updateAttachments]
)
const activeProject = useMemo(
() => projects.find((project) => project.id === activeProjectId),
@@ -1123,7 +1230,10 @@ function App(): React.JSX.Element {
callId: event.callId.slice(0, 256),
kind: 'tool',
title: event.name,
detail: event.summary.slice(0, 4_000),
detail: [event.summary, event.error]
.filter(Boolean)
.join('\n')
.slice(0, 4_000),
status:
event.state === 'pending'
? 'pending'
@@ -1142,7 +1252,8 @@ function App(): React.JSX.Element {
callId: event.callId.slice(0, 256),
name: event.name,
state: event.state,
summary: event.summary
summary: event.summary,
error: event.error
}
if (index >= 0) {
tools[index] = tool
@@ -1151,6 +1262,86 @@ function App(): React.JSX.Element {
}
return { ...message, tools }
})
} else if (event.type === 'subagent') {
const childStatus = event.state
const completedAt =
event.state === 'completed' ||
event.state === 'failed' ||
event.state === 'cancelled'
? new Date().toISOString()
: undefined
setAssistantTasks((current) => {
const existing = current.find(
(task) => task.id === event.childTaskId
)
const childTask: AssistantTask = {
id: event.childTaskId,
projectId: run.projectId,
conversationId: run.conversationId,
parentTaskId: event.requestId,
expertId: event.expertId,
routingMode: event.routingMode,
title: event.expertName,
instructions:
event.reason ?? `${event.expertName} 子专家任务`,
origin: 'subagent',
status: childStatus,
createdAt:
existing?.createdAt ?? new Date().toISOString(),
startedAt:
event.state === 'running'
? existing?.startedAt ?? new Date().toISOString()
: existing?.startedAt,
completedAt: completedAt ?? existing?.completedAt,
error: event.error
}
return existing
? current.map((task) =>
task.id === event.childTaskId ? childTask : task
)
: [...current, childTask].slice(0, 100)
})
recordActivity({
conversationId: run.conversationId,
requestId: event.requestId,
callId: event.childTaskId,
kind: 'subagent',
title: event.expertName,
detail: [
event.routingMode === 'smart' ? '智能路由' : '手动指定',
event.reason,
event.error
]
.filter(Boolean)
.join(' · ')
.slice(0, 4_000),
status:
event.state === 'queued'
? 'pending'
: event.state
})
updateMessage(run.conversationId, run.messageId, (message) => {
const subagents = [...(message.subagents ?? [])]
const index = subagents.findIndex(
(subagent) =>
subagent.childTaskId === event.childTaskId
)
const subagent: SubagentActivity = {
childTaskId: event.childTaskId,
expertId: event.expertId,
expertName: event.expertName,
routingMode: event.routingMode,
state: event.state,
reason: event.reason,
error: event.error
}
if (index >= 0) {
subagents[index] = subagent
} else if (subagents.length < 3) {
subagents.push(subagent)
}
return { ...message, subagents }
})
} else if (event.type === 'approval') {
recordActivity({
conversationId: run.conversationId,
@@ -1898,7 +2089,7 @@ function App(): React.JSX.Element {
const transcript = conversation.messages
.map(
(message) =>
`${message.role === 'user' ? '你' : 'GoodBuddy'}\n${message.content}`
`${message.role === 'user' ? '你' : 'GoodBuddy'}\n${message.content}${formatAttachmentList(message.attachments)}`
)
.join('\n\n')
try {
@@ -1918,7 +2109,7 @@ function App(): React.JSX.Element {
...conversation.messages.flatMap((message) => [
`## ${message.role === 'user' ? '你' : 'GoodBuddy'}`,
'',
message.content,
`${message.content}${formatAttachmentList(message.attachments)}`,
''
])
].join('\n')
@@ -1934,6 +2125,39 @@ function App(): React.JSX.Element {
setNotice('对话已导出')
}
const openImageViewer = (
item: ImageViewerItem,
trigger: HTMLElement
): void => {
if (!imageDataUrlPattern.test(item.src)) {
setNotice('图片内容不可用')
return
}
imageViewerTriggerRef.current = trigger
setImageViewerItem(item)
}
const closeImageViewer = (): void => {
setImageViewerItem(undefined)
requestAnimationFrame(() => {
imageViewerTriggerRef.current?.focus()
imageViewerTriggerRef.current = undefined
})
}
const downloadImage = (item: ImageViewerItem): void => {
if (!imageDataUrlPattern.test(item.src)) {
setNotice('图片内容不可用')
return
}
const anchor = document.createElement('a')
anchor.href = item.src
anchor.download = getImageDownloadName(item.title, item.src)
anchor.rel = 'noopener'
anchor.click()
setNotice('图片下载已开始')
}
const submit = async (): Promise<void> => {
const prompt = input.trim()
if (!prompt || !activeConversation) {
@@ -1959,7 +2183,7 @@ function App(): React.JSX.Element {
const requestId = crypto.randomUUID()
const conversationId = activeConversation.id
const attachmentSnapshot = attachments
const attachmentSnapshot = attachments.slice(0, 8)
const historySnapshot = activeConversation.messages
const projectIdSnapshot = activeProjectId || undefined
const selectedExpertSnapshot =
@@ -1967,7 +2191,34 @@ function App(): React.JSX.Element {
const workModeSnapshot = effectiveWorkMode
preparingConversations.current.add(conversationId)
setInput('')
setAttachments([])
updateAttachments([])
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: prompt,
createdAt: Date.now(),
state: 'complete',
attachments:
attachmentSnapshot.length > 0 ? attachmentSnapshot : undefined
}
setConversations((current) =>
current.map((conversation) =>
conversation.id === conversationId
? {
...conversation,
title:
conversation.title === '新对话'
? prompt.slice(0, 24)
: conversation.title,
updatedAt: Date.now(),
messages: [
...conversation.messages.slice(-499),
userMessage
]
}
: conversation
)
)
let knowledgeResults: KnowledgeSearchReference[] = []
if (
runtime.capability !== 'image-generation' &&
@@ -1995,13 +2246,6 @@ function App(): React.JSX.Element {
const executionPrompt = supplementalContext
? `${prompt}\n\n${supplementalContext}`
: prompt
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content: prompt,
createdAt: Date.now(),
state: 'complete'
}
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
@@ -2058,14 +2302,9 @@ function App(): React.JSX.Element {
conversation.id === conversationId
? {
...conversation,
title:
conversation.title === '新对话'
? prompt.slice(0, 24)
: conversation.title,
updatedAt: Date.now(),
messages: [
...conversation.messages.slice(-498),
userMessage,
...conversation.messages.slice(-499),
assistantMessage
]
}
@@ -2082,6 +2321,13 @@ function App(): React.JSX.Element {
? selectedExpertSnapshot
: undefined,
teamMode: selectedExpertSnapshot === 'team',
smartRouting:
runtime.capability !== 'image-generation' &&
runtimeSettings?.subagentSmartRoutingEnabled === true &&
!selectedExpertSnapshot &&
supportsSubagentSmartRouting(workModeSnapshot)
? true
: undefined,
workMode: workModeSnapshot,
prompt: executionPrompt,
contextIds: attachmentSnapshot.map(
@@ -2180,13 +2426,22 @@ function App(): React.JSX.Element {
try {
const result = await action()
const selected = Array.isArray(result) ? result : [result]
setAttachments((current) => [
...current,
...selected.filter(
(item) =>
!current.some((existing) => existing.id === item.id)
)
])
const current = attachmentsRef.current
const unique = selected.filter(
(item) =>
!current.some((existing) => existing.id === item.id)
)
const accepted = unique.slice(
0,
Math.max(0, 8 - current.length)
)
for (const attachment of unique.slice(accepted.length)) {
void window.goodbuddy.context.remove(attachment.id)
}
updateAttachments([...current, ...accepted])
if (accepted.length < unique.length) {
setContextError('单次消息最多添加 8 个附件')
}
} catch (reason) {
setContextError(
reason instanceof Error ? reason.message : '添加上下文失败'
@@ -2194,9 +2449,34 @@ function App(): React.JSX.Element {
}
}
const openWindowCapture = async (): Promise<void> => {
setContextError(undefined)
setWindowCaptureLoading(true)
try {
setWindowCaptureOptions(
await window.goodbuddy.context.listWindows()
)
} catch (reason) {
setContextError(
reason instanceof Error ? reason.message : '读取应用窗口失败'
)
} finally {
setWindowCaptureLoading(false)
}
}
const captureSelectedWindow = async (
sourceId: string
): Promise<void> => {
setWindowCaptureOptions(undefined)
await addContext(() =>
window.goodbuddy.context.captureWindow(sourceId)
)
}
const removeAttachment = (attachmentId: string): void => {
void window.goodbuddy.context.remove(attachmentId)
setAttachments((current) =>
updateAttachments((current) =>
current.filter((attachment) => attachment.id !== attachmentId)
)
}
@@ -2333,7 +2613,7 @@ function App(): React.JSX.Element {
evidence: []
})
setEnabledKnowledgeLibraryIds([])
setAttachments([])
updateAttachments([])
setInput('')
setView('chat')
setNotice('本地对话、任务、记忆、心跳、自动化和知识库索引已清除')
@@ -2803,6 +3083,92 @@ function App(): React.JSX.Element {
</strong>
<span>{formatTime(message.createdAt)}</span>
</div>
{message.attachments &&
message.attachments.length > 0 && (
<div
aria-label="消息附件"
className="message-attachments"
>
{message.attachments.map((attachment) => {
const imageSource =
attachment.kind === 'image'
? attachment.contentUrl ??
attachment.thumbnailUrl
: undefined
const imageItem = imageSource
? {
src: imageSource,
title: attachment.name
}
: undefined
return (
<div
className={`message-attachment message-attachment--${attachment.kind}`}
key={attachment.id}
title={attachment.preview}
>
{imageItem ? (
<button
aria-label={`查看图片 ${attachment.name}`}
className="message-image-button"
onClick={(event) =>
openImageViewer(
imageItem,
event.currentTarget
)
}
type="button"
>
<img
alt={attachment.name}
loading="lazy"
src={imageSource}
/>
</button>
) : (
<span
aria-hidden="true"
className="message-attachment__icon"
>
<FileText size={16} />
</span>
)}
<span className="message-attachment__details">
<strong>{attachment.name}</strong>
<small>
{formatAttachmentSize(attachment.size)}
</small>
{imageItem && (
<span className="message-image-actions">
<button
onClick={(event) =>
openImageViewer(
imageItem,
event.currentTarget
)
}
type="button"
>
</button>
<button
aria-label={`下载图片 ${attachment.name}`}
onClick={() =>
downloadImage(imageItem)
}
type="button"
>
<Download size={12} />
</button>
</span>
)}
</span>
</div>
)
})}
</div>
)}
{message.content && (
<div className="markdown-content message__content">
<MarkdownRenderer>
@@ -2826,12 +3192,56 @@ function App(): React.JSX.Element {
className="message-generated-image"
key={artifact.id}
>
<img
alt={artifact.title}
loading="lazy"
src={artifact.content}
/>
<button
aria-label={`查看图片 ${artifact.title}`}
className="message-image-button"
onClick={(event) =>
openImageViewer(
{
src: artifact.content!,
title: artifact.title
},
event.currentTarget
)
}
type="button"
>
<img
alt={artifact.title}
loading="lazy"
src={artifact.content}
/>
</button>
<figcaption>{artifact.title}</figcaption>
<div className="message-image-actions">
<button
onClick={(event) =>
openImageViewer(
{
src: artifact.content!,
title: artifact.title
},
event.currentTarget
)
}
type="button"
>
</button>
<button
aria-label={`下载图片 ${artifact.title}`}
onClick={() =>
downloadImage({
src: artifact.content!,
title: artifact.title
})
}
type="button"
>
<Download size={12} />
</button>
</div>
</figure>
) : null
})}
@@ -2889,10 +3299,46 @@ function App(): React.JSX.Element {
key={tool.callId ?? tool.name}
>
<TerminalSquare size={15} />
<span>{tool.summary}</span>
<div className="tool-activity__content">
<span>{tool.summary}</span>
{tool.error && <code>{tool.error}</code>}
</div>
<small>{toolStateLabels[tool.state]}</small>
</div>
))}
{message.subagents && message.subagents.length > 0 && (
<section
aria-label="子专家状态"
className="subagent-status-list"
>
{message.subagents.slice(0, 3).map((subagent) => (
<article
className={`subagent-status-card subagent-status-card--${subagent.state}`}
key={subagent.childTaskId}
>
<Bot aria-hidden="true" size={15} />
<div>
<strong>{subagent.expertName}</strong>
<small>
{subagent.routingMode === 'smart'
? '智能路由'
: '手动指定'}
</small>
{(subagent.error || subagent.reason) &&
(subagent.state === 'failed' ||
subagent.state === 'cancelled') && (
<p>
{subagent.error ?? subagent.reason}
</p>
)}
</div>
<span>
{subagentStateLabels[subagent.state]}
</span>
</article>
))}
</section>
)}
{message.approval && (
<div className="approval-card">
<ShieldCheck size={18} />
@@ -3020,14 +3466,14 @@ function App(): React.JSX.Element {
<span>
<strong>{attachment.name}</strong>
<small>
{Math.max(1, Math.ceil(attachment.size / 1024))} KB
{formatAttachmentSize(attachment.size)}
</small>
</span>
<button
aria-label={`移除 ${attachment.name}`}
onClick={() => {
void window.goodbuddy.context.remove(attachment.id)
setAttachments((current) =>
updateAttachments((current) =>
current.filter(
(item) => item.id !== attachment.id
)
@@ -3111,11 +3557,8 @@ function App(): React.JSX.Element {
</button>
<button
aria-label="捕获应用窗口"
onClick={() =>
void addContext(() =>
window.goodbuddy.context.captureWindow()
)
}
disabled={windowCaptureLoading}
onClick={() => void openWindowCapture()}
title="选择一个应用或浏览器窗口,仅捕获当前画面"
type="button"
>
@@ -3578,6 +4021,119 @@ function App(): React.JSX.Element {
</button>
</div>
)}
{imageViewerItem && (
<div
className="image-viewer-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeImageViewer()
}
}}
>
<section
aria-labelledby="image-viewer-title"
aria-modal="true"
className="image-viewer-dialog"
onKeyDown={(event) => {
if (event.key === 'Escape') {
closeImageViewer()
}
}}
role="dialog"
>
<header className="image-viewer-dialog__header">
<strong id="image-viewer-title">
{imageViewerItem.title}
</strong>
<div>
<button
className="secondary-button"
onClick={() => downloadImage(imageViewerItem)}
type="button"
>
<Download size={14} />
</button>
<button
aria-label="关闭图片查看器"
autoFocus
className="icon-button"
onClick={closeImageViewer}
type="button"
>
<X size={16} />
</button>
</div>
</header>
<div className="image-viewer-dialog__content">
<img
alt={imageViewerItem.title}
src={imageViewerItem.src}
/>
</div>
</section>
</div>
)}
{windowCaptureOptions && (
<div
className="window-capture-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setWindowCaptureOptions(undefined)
}
}}
>
<section
aria-labelledby="window-capture-title"
aria-modal="true"
className="window-capture-dialog"
onKeyDown={(event) => {
if (event.key === 'Escape') {
setWindowCaptureOptions(undefined)
}
}}
role="dialog"
>
<div className="window-capture-dialog__header">
<div>
<strong id="window-capture-title"></strong>
<small></small>
</div>
<button
aria-label="关闭应用窗口选择"
className="icon-button"
onClick={() => setWindowCaptureOptions(undefined)}
type="button"
>
×
</button>
</div>
<div
aria-label="可捕获的应用窗口"
className="window-capture-dialog__list"
>
{windowCaptureOptions.map((source, index) => (
<button
autoFocus={index === 0}
key={source.id}
onClick={() => void captureSelectedWindow(source.id)}
type="button"
>
<PanelsTopLeft size={16} />
<span>{source.name}</span>
</button>
))}
</div>
<button
className="secondary-button"
onClick={() => setWindowCaptureOptions(undefined)}
type="button"
>
</button>
</section>
</div>
)}
<RightAssistantSidebar
activities={activityRecords}
approvals={pendingSidebarApprovals}
@@ -3585,6 +4141,7 @@ function App(): React.JSX.Element {
attachments={attachments}
browserState={browserStates[activeId]}
enabledLibraries={enabledSidebarLibraries}
experts={assistantExperts}
heartbeatEntries={heartbeatEntries}
heartbeats={assistantHeartbeats}
memories={assistantMemories}
@@ -1,5 +1,9 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AssistantExpert,
AssistantTask
} from '../../shared/assistant-contracts'
import { RightAssistantSidebar } from './RightAssistantSidebar'
afterEach(cleanup)
@@ -11,7 +15,15 @@ beforeEach(() => {
})
})
function renderSidebar(): HTMLElement {
function renderSidebar({
tasks = [],
experts = [],
tab = 'context'
}: {
tasks?: AssistantTask[]
experts?: AssistantExpert[]
tab?: 'tasks' | 'context'
} = {}): HTMLElement {
render(
<RightAssistantSidebar
activities={[]}
@@ -19,6 +31,7 @@ function renderSidebar(): HTMLElement {
artifacts={[]}
attachments={[]}
enabledLibraries={[]}
experts={experts}
heartbeatEntries={[]}
heartbeats={[]}
memories={[]}
@@ -50,8 +63,8 @@ function renderSidebar(): HTMLElement {
onTabChange={vi.fn()}
open
schedules={[]}
tab="context"
tasks={[]}
tab={tab}
tasks={tasks}
/>
)
@@ -148,4 +161,51 @@ describe('RightAssistantSidebar resizing', () => {
sidebar.style.getPropertyValue('--assistant-sidebar-width')
).toBe('424px')
})
it('indents child tasks and names their expert and routing mode', () => {
const parentTask: AssistantTask = {
id: 'parent-task',
conversationId: 'conversation-1',
title: '分析发布计划',
instructions: '分析发布计划',
origin: 'user',
status: 'running',
createdAt: '2026-08-01T00:00:00.000Z'
}
const childTask: AssistantTask = {
id: 'child-task',
conversationId: 'conversation-1',
parentTaskId: parentTask.id,
expertId: 'expert-1',
routingMode: 'smart',
title: '研究子任务',
instructions: '收集资料',
origin: 'subagent',
status: 'completed',
createdAt: '2026-08-01T00:01:00.000Z'
}
renderSidebar({
tab: 'tasks',
tasks: [childTask, parentTask],
experts: [
{
id: 'expert-1',
name: '研究专家',
description: '分析证据',
systemInstructions: 'Analyze evidence.',
routingKeywords: ['研究'],
enabled: true,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
]
})
const taskButtons = screen.getAllByRole('button', {
name: /|/u
})
expect(taskButtons[0]).toHaveTextContent('分析发布计划')
expect(taskButtons[1]).toHaveClass('assistant-sidebar__row--subtask')
expect(taskButtons[1]).toHaveTextContent('子专家:研究专家 · 智能路由')
})
})
+111 -9
View File
@@ -20,6 +20,7 @@ import type {
AssistantSchedule,
AssistantHeartbeatConfig,
AssistantHeartbeatEntry,
AssistantExpert,
HeartbeatCreateInput,
ScheduleCreateInput,
AssistantTask,
@@ -68,6 +69,7 @@ type RightAssistantSidebarProps = {
tab: AssistantSidebarTab
activities: ActivityRecord[]
tasks: AssistantTask[]
experts?: AssistantExpert[]
artifacts: SidebarArtifact[]
attachments: ContextAttachment[]
enabledLibraries: KnowledgeLibrary[]
@@ -117,13 +119,38 @@ type RightAssistantSidebarProps = {
const tabs: Array<{
id: AssistantSidebarTab
label: string
description: string
}> = [
{ id: 'tasks', label: '任务' },
{ id: 'context', label: '上下文' },
{ id: 'artifacts', label: '成果' },
{ id: 'changes', label: '更改' },
{ id: 'browser', label: '浏览器' },
{ id: 'preview', label: '预览' }
{
id: 'tasks',
label: '任务中心',
description: '查看运行状态、处理审批并安排自动化'
},
{
id: 'context',
label: '上下文',
description: '管理本次对话的附件、知识库与长期记忆'
},
{
id: 'artifacts',
label: '成果库',
description: '集中保存和打开对话生成或手动导入的内容'
},
{
id: 'changes',
label: '工作区',
description: '浏览项目文件、Git 变更与工具活动'
},
{
id: 'browser',
label: '浏览器',
description: '查看 Agent 操作网页时的实时画面'
},
{
id: 'preview',
label: '预览',
description: '预览选中的成果或工作区文件'
}
]
const emptyChangedFiles: WorkspaceChanges['files'] = []
const defaultSidebarWidth = 350
@@ -162,11 +189,50 @@ function formatTime(timestamp: number | string): string {
return sidebarTimeFormatter.format(new Date(timestamp))
}
export function orderTasksWithChildren(
tasks: readonly AssistantTask[]
): AssistantTask[] {
const childIds = new Set(
tasks.flatMap((task) => (task.parentTaskId ? [task.id] : []))
)
const childrenByParent = new Map<string, AssistantTask[]>()
for (const task of tasks) {
if (!task.parentTaskId) {
continue
}
const children = childrenByParent.get(task.parentTaskId) ?? []
children.push(task)
childrenByParent.set(task.parentTaskId, children)
}
const ordered: AssistantTask[] = []
const included = new Set<string>()
const append = (task: AssistantTask): void => {
if (included.has(task.id)) {
return
}
included.add(task.id)
ordered.push(task)
for (const child of childrenByParent.get(task.id) ?? []) {
append(child)
}
}
for (const task of tasks) {
if (!childIds.has(task.id)) {
append(task)
}
}
for (const task of tasks) {
append(task)
}
return ordered
}
export function RightAssistantSidebar({
open,
tab,
activities,
tasks,
experts = [],
artifacts,
attachments,
enabledLibraries,
@@ -247,6 +313,14 @@ export function RightAssistantSidebar({
.slice(0, 20),
[activities]
)
const orderedTasks = useMemo(
() => orderTasksWithChildren(tasks),
[tasks]
)
const expertNames = useMemo(
() => new Map(experts.map((expert) => [expert.id, expert.name])),
[experts]
)
const changes = useMemo(
() =>
activities
@@ -497,6 +571,7 @@ export function RightAssistantSidebar({
onKeyDown={(event) => moveTabFocus(event, item.id)}
role="tab"
tabIndex={tab === item.id ? 0 : -1}
title={item.description}
type="button"
>
{item.label}
@@ -517,6 +592,9 @@ export function RightAssistantSidebar({
>
{tab === 'tasks' && (
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
</p>
{approvals.length > 0 && (
<>
<h3>
@@ -565,9 +643,13 @@ export function RightAssistantSidebar({
</p>
) : (
(tasks.length > 0 ? tasks : recentTasks).map((task) => (
(orderedTasks.length > 0 ? orderedTasks : recentTasks).map((task) => (
<button
className="assistant-sidebar__row"
className={
'parentTaskId' in task && task.parentTaskId
? 'assistant-sidebar__row assistant-sidebar__row--subtask'
: 'assistant-sidebar__row'
}
key={task.id}
onClick={() => {
if (task.conversationId) {
@@ -590,6 +672,19 @@ export function RightAssistantSidebar({
<small>
{formatTime(task.createdAt)} · {task.status}
</small>
{'parentTaskId' in task && task.parentTaskId && (
<small className="assistant-sidebar__subtask-meta">
{task.expertId
? expertNames.get(task.expertId) ??
task.title
: task.title}
{' · '}
{task.routingMode === 'smart'
? '智能路由'
: '手动指定'}
</small>
)}
</span>
<ChevronRight size={14} />
</button>
@@ -867,9 +962,12 @@ export function RightAssistantSidebar({
{tab === 'artifacts' && (
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
PDF
</p>
<h3>
<FileText size={15} />
</h3>
<button
className="secondary-button assistant-sidebar__import"
@@ -912,6 +1010,10 @@ export function RightAssistantSidebar({
{tab === 'changes' && (
<>
<section className="assistant-sidebar__section">
<p className="assistant-sidebar__section-description">
Git Agent
</p>
<h3>
<FolderTree size={15} />
+86 -4
View File
@@ -6,8 +6,9 @@ import type {
} from '../../shared/assistant-contracts'
import { DestructiveConfirmActions } from './WorkspacePrimitives'
type ExpertDraft = ExpertCreateInput & {
type ExpertDraft = Omit<ExpertCreateInput, 'routingKeywords'> & {
id?: string
routingKeywordsText: string
}
type RolePromptSettingsSectionProps = {
@@ -17,7 +18,8 @@ type RolePromptSettingsSectionProps = {
const emptyDraft: ExpertDraft = {
name: '',
description: '',
systemInstructions: ''
systemInstructions: '',
routingKeywordsText: ''
}
function draftFromExpert(expert: AssistantExpert): ExpertDraft {
@@ -25,10 +27,40 @@ function draftFromExpert(expert: AssistantExpert): ExpertDraft {
id: expert.id,
name: expert.name,
description: expert.description,
systemInstructions: expert.systemInstructions
systemInstructions: expert.systemInstructions,
routingKeywordsText: (expert.routingKeywords ?? []).join('、')
}
}
export function normalizeRoutingKeywords(value: string): string[] {
const normalized: string[] = []
const seen = new Set<string>()
for (const keyword of value.split(/[,\r\n]+/u)) {
const normalizedKeyword = keyword
.normalize('NFKC')
.trim()
.replace(/\s+/gu, ' ')
.toLocaleLowerCase('zh-CN')
if (normalizedKeyword && !seen.has(normalizedKeyword)) {
seen.add(normalizedKeyword)
normalized.push(normalizedKeyword)
}
}
return normalized
}
function validateRoutingKeywords(keywords: readonly string[]): string | undefined {
if (keywords.length > 32) {
return '路由关键词最多 32 个。'
}
const invalid = keywords.find(
(keyword) => keyword.length < 2 || keyword.length > 48
)
return invalid
? `关键词“${invalid.slice(0, 48)}”需为 2 至 48 个字符。`
: undefined
}
function sortExperts(experts: AssistantExpert[]): AssistantExpert[] {
return [...experts].sort((left, right) =>
left.name.localeCompare(right.name, 'zh-CN')
@@ -43,6 +75,8 @@ export function RolePromptSettingsSection({
const [draft, setDraft] = useState<ExpertDraft>()
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string>()
const [routingKeywordsError, setRoutingKeywordsError] =
useState<string>()
const [confirmingRemove, setConfirmingRemove] = useState(false)
useEffect(() => {
@@ -68,6 +102,7 @@ export function RolePromptSettingsSection({
setDraft(draftFromExpert(expert))
setConfirmingRemove(false)
setError(undefined)
setRoutingKeywordsError(undefined)
}
const createDraft = (): void => {
@@ -75,6 +110,7 @@ export function RolePromptSettingsSection({
setDraft({ ...emptyDraft })
setConfirmingRemove(false)
setError(undefined)
setRoutingKeywordsError(undefined)
}
const save = async (): Promise<void> => {
@@ -83,11 +119,22 @@ export function RolePromptSettingsSection({
}
setBusy(true)
setError(undefined)
const routingKeywords = normalizeRoutingKeywords(
draft.routingKeywordsText
)
const keywordError = validateRoutingKeywords(routingKeywords)
if (keywordError) {
setRoutingKeywordsError(keywordError)
setBusy(false)
return
}
setRoutingKeywordsError(undefined)
try {
const input: ExpertCreateInput = {
name: draft.name,
description: draft.description,
systemInstructions: draft.systemInstructions
systemInstructions: draft.systemInstructions,
routingKeywords
}
const saved = draft.id
? await window.goodbuddy.experts.update(draft.id, input)
@@ -249,6 +296,41 @@ export function RolePromptSettingsSection({
20,000
</small>
</label>
<label className="field">
<span></span>
<textarea
aria-describedby={
routingKeywordsError
? 'role-routing-keywords-error role-routing-keywords-help'
: 'role-routing-keywords-help'
}
aria-invalid={routingKeywordsError ? 'true' : undefined}
aria-label="路由关键词"
onChange={(event) => {
setDraft({
...draft,
routingKeywordsText: event.target.value
})
setRoutingKeywordsError(undefined)
}}
placeholder="例如:代码审查、TypeScript、性能分析"
rows={3}
value={draft.routingKeywordsText}
/>
<small id="role-routing-keywords-help">
使 32
2 48
</small>
{routingKeywordsError && (
<small
className="field-error"
id="role-routing-keywords-error"
role="alert"
>
{routingKeywordsError}
</small>
)}
</label>
<div className="role-prompt-detail__actions">
{draft.id ? (
<DestructiveConfirmActions
+153 -4
View File
@@ -23,6 +23,7 @@ const runtimeSettings: RuntimeSettings = {
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
imageGenerationQuality: 'auto',
opencodeBaseUrl: '',
opencodeEmbedded: false,
opencodeBinaryPath: '',
@@ -31,9 +32,13 @@ const runtimeSettings: RuntimeSettings = {
continueConfigPath: '',
continueMode: 'chat',
runtimeSandboxMode: 'auto',
subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434',
knowledgeEmbeddingBaseUrl:
'http://127.0.0.1:11434/v1/embeddings',
knowledgeEmbeddingModel: 'nomic-embed-text',
knowledgeEmbeddingApiKeyConfigured: false,
knowledgeEmbeddingCredentialSource: 'none',
workspacePath: 'C:\\Workspace',
apiKeyConfigured: false,
credentialSource: 'none',
@@ -45,6 +50,7 @@ const runtimeSettings: RuntimeSettings = {
modelName: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
imageGenerationQuality: 'auto',
apiKeyConfigured: false,
credentialSource: 'none'
}
@@ -199,6 +205,7 @@ const assistantExpert: AssistantExpert = {
name: '研究分析专家',
description: '负责资料分析',
systemInstructions: 'Separate evidence from assumptions.',
routingKeywords: ['研究', '分析'],
enabled: true,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
@@ -209,6 +216,7 @@ const listExperts = vi.fn<DesktopApi['experts']['list']>(
const createExpert = vi.fn<DesktopApi['experts']['create']>(
async (input) => ({
...input,
routingKeywords: input.routingKeywords ?? [],
id: '00000000-0000-4000-8000-000000000102',
enabled: true,
createdAt: '2026-08-04T00:00:00.000Z',
@@ -218,6 +226,7 @@ const createExpert = vi.fn<DesktopApi['experts']['create']>(
const updateExpert = vi.fn<DesktopApi['experts']['update']>(
async (expertId, input) => ({
...input,
routingKeywords: input.routingKeywords ?? [],
id: expertId,
enabled: true,
createdAt: assistantExpert.createdAt,
@@ -347,6 +356,40 @@ describe('SettingsPanel runtime files', () => {
)
})
it('saves the accessible Subagent smart routing switch', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
const smartRouting = await screen.findByRole('checkbox', {
name: '启用 Subagent 智能路由'
})
expect(smartRouting).not.toBeChecked()
expect(screen.getByText(/仅在 Ask 或 Plan 模式/)).toHaveTextContent(
'自动选择 1 位专家'
)
expect(screen.getByText(/仅在 Ask 或 Plan 模式/)).toHaveTextContent(
'只读运行且不使用工具'
)
fireEvent.click(smartRouting)
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
subagentSmartRoutingEnabled: true
})
)
)
})
it('automatically detects runtimes and displays path, version, and detail', async () => {
render(
<SettingsPanel
@@ -555,11 +598,92 @@ describe('SettingsPanel runtime files', () => {
screen.queryByRole('button', { name: '从预设添加' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('checkbox', {
name: '支持图片输出 默认模型'
})
).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('接口协议 默认模型'), {
target: { value: 'openai-images-generations' }
})
expect(screen.getByText('图像生成', { selector: 'span' }))
.toBeInTheDocument()
const qualitySelect = screen.getByLabelText('图片质量 默认模型')
expect(qualitySelect).toHaveValue('auto')
fireEvent.change(qualitySelect, {
target: { value: 'high' }
})
expect(
screen.getByText('图像生成', {
selector: '.model-capability-badge'
})
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: [
expect.objectContaining({
protocol: 'openai-images-generations',
imageGenerationQuality: 'high'
})
],
imageGenerationQuality: 'high'
})
)
)
})
it('configures vector models under model connections instead of security', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '安全与数据' }))
expect(
screen.queryByRole('checkbox', { name: '启用向量模型' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '向量模型' }))
expect(
screen.getByText('向量模型连接', { selector: 'strong' })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('checkbox', { name: '启用向量模型' })
)
fireEvent.change(screen.getByLabelText('向量接口 URL'), {
target: { value: 'https://vectors.example/v1/embeddings' }
})
fireEvent.change(screen.getByLabelText('模型名称'), {
target: { value: 'bge-m3' }
})
fireEvent.change(screen.getByLabelText('API Key(可选)'), {
target: { value: 'vector-secret' }
})
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
knowledgeEmbeddingEnabled: true,
knowledgeEmbeddingBaseUrl:
'https://vectors.example/v1/embeddings',
knowledgeEmbeddingModel: 'bge-m3',
knowledgeEmbeddingApiKey: {
action: 'replace',
value: 'vector-secret'
}
})
)
)
})
it('manages heartbeat automation from Settings', async () => {
@@ -815,6 +939,30 @@ describe('SettingsPanel runtime files', () => {
)
)
fireEvent.change(screen.getByLabelText('路由关键词'), {
target: { value: 'x' }
})
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
expect(
await screen.findByText('关键词“x”需为 2 至 48 个字符。')
).toBeInTheDocument()
expect(updateExpert).toHaveBeenCalledTimes(1)
fireEvent.change(screen.getByLabelText('路由关键词'), {
target: {
value: ' TypeScript,代码 审查\nTYPESCRIPT '
}
})
fireEvent.click(screen.getByRole('button', { name: '保存角色' }))
await waitFor(() =>
expect(updateExpert).toHaveBeenLastCalledWith(
assistantExpert.id,
expect.objectContaining({
routingKeywords: ['typescript', '代码 审查']
})
)
)
fireEvent.click(screen.getByRole('button', { name: '新建角色' }))
fireEvent.change(screen.getByLabelText('角色名称'), {
target: { value: '代码审查专家' }
@@ -830,7 +978,8 @@ describe('SettingsPanel runtime files', () => {
expect(createExpert).toHaveBeenCalledWith({
name: '代码审查专家',
description: '检查代码正确性',
systemInstructions: 'Review code and report actionable bugs.'
systemInstructions: 'Review code and report actionable bugs.',
routingKeywords: []
})
)
expect(onExpertsChanged).toHaveBeenLastCalledWith(
+216 -44
View File
@@ -27,6 +27,7 @@ import { McpSettingsSection } from './McpSettingsSection'
import { RolePromptSettingsSection } from './RolePromptSettingsSection'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import { HeartbeatSettings } from './HeartbeatSettings'
import { SegmentedControl } from './WorkspacePrimitives'
import type { AppearanceTheme } from './theme'
type SettingsTab =
@@ -38,6 +39,7 @@ type SettingsTab =
| 'roles'
| 'skills'
| 'mcp'
type ModelType = 'llm' | 'embedding'
type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
apiKey: string
clearApiKey: boolean
@@ -141,6 +143,12 @@ export function SettingsPanel({
useState<string>(defaultRuntimeSettings.knowledgeEmbeddingBaseUrl)
const [knowledgeEmbeddingModel, setKnowledgeEmbeddingModel] =
useState<string>(defaultRuntimeSettings.knowledgeEmbeddingModel)
const [knowledgeEmbeddingApiKey, setKnowledgeEmbeddingApiKey] =
useState('')
const [
clearKnowledgeEmbeddingApiKey,
setClearKnowledgeEmbeddingApiKey
] = useState(false)
const [workspacePath, setWorkspacePath] = useState<string>(
defaultRuntimeSettings.workspacePath
)
@@ -148,6 +156,10 @@ export function SettingsPanel({
useState<RuntimeSettingsInput['toolApproval']>(
defaultRuntimeSettings.toolApproval
)
const [
subagentSmartRoutingEnabled,
setSubagentSmartRoutingEnabled
] = useState(false)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [error, setError] = useState<string>()
@@ -157,6 +169,7 @@ export function SettingsPanel({
const [detection, setDetection] = useState<AgentRuntimeDetection>()
const [detecting, setDetecting] = useState(false)
const [activeTab, setActiveTab] = useState<SettingsTab>('runtime')
const [modelType, setModelType] = useState<ModelType>('llm')
const configurationTab =
activeTab === 'model' ||
activeTab === 'runtime' ||
@@ -173,6 +186,7 @@ export function SettingsPanel({
setSaved(false)
setConnectionResult(undefined)
setConfirmingClear(false)
setModelType('llm')
setSettings(value)
setProvider(value.provider)
setModelProfiles(toModelProfileDrafts(value))
@@ -197,10 +211,15 @@ export function SettingsPanel({
setKnowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
setKnowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setKnowledgeEmbeddingApiKey('')
setClearKnowledgeEmbeddingApiKey(false)
setWorkspacePath(value.workspacePath)
setToolApproval(
value.toolApproval === 'policy' ? 'policy' : 'always'
)
setSubagentSmartRoutingEnabled(
value.subagentSmartRoutingEnabled
)
})
.catch((reason: unknown) => {
setError(reason instanceof Error ? reason.message : '读取设置失败')
@@ -227,6 +246,8 @@ export function SettingsPanel({
clearApiKey: false
}))
)
setKnowledgeEmbeddingApiKey('')
setClearKnowledgeEmbeddingApiKey(false)
setError(undefined)
onClose()
}
@@ -250,6 +271,7 @@ export function SettingsPanel({
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: profile.clearApiKey
? ({ action: 'clear' } as const)
: profile.apiKey.trim()
@@ -265,6 +287,8 @@ export function SettingsPanel({
modelName: defaultProfile.modelName,
modelProtocol: defaultProfile.protocol,
modelAuthentication: defaultProfile.authentication,
imageGenerationQuality:
defaultProfile.imageGenerationQuality,
opencodeBaseUrl,
opencodeEmbedded,
opencodeBinaryPath,
@@ -276,6 +300,14 @@ export function SettingsPanel({
knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel,
knowledgeEmbeddingApiKey: clearKnowledgeEmbeddingApiKey
? { action: 'clear' }
: knowledgeEmbeddingApiKey.trim()
? {
action: 'replace',
value: knowledgeEmbeddingApiKey.trim()
}
: { action: 'keep' },
workspacePath,
apiKey: profileInputs.find(
(profile) => profile.id === defaultProfile.id
@@ -284,7 +316,8 @@ export function SettingsPanel({
defaultModelProfileId: defaultProfile.id,
opencodeModelSource,
continueModelSource,
toolApproval
toolApproval,
subagentSmartRoutingEnabled
})
setSettings(value)
setModelProfiles(toModelProfileDrafts(value))
@@ -305,9 +338,14 @@ export function SettingsPanel({
setKnowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
setKnowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setKnowledgeEmbeddingApiKey('')
setClearKnowledgeEmbeddingApiKey(false)
setToolApproval(
value.toolApproval === 'policy' ? 'policy' : 'always'
)
setSubagentSmartRoutingEnabled(
value.subagentSmartRoutingEnabled
)
setSaved(true)
onSaved(value)
return true
@@ -399,6 +437,8 @@ export function SettingsPanel({
modelName: defaultRuntimeSettings.modelName,
protocol: defaultRuntimeSettings.modelProtocol,
authentication: defaultRuntimeSettings.modelAuthentication,
imageGenerationQuality:
defaultRuntimeSettings.imageGenerationQuality,
apiKeyConfigured: false,
credentialSource: 'none',
apiKey: '',
@@ -540,7 +580,7 @@ export function SettingsPanel({
type="button"
>
<strong></strong>
<small></small>
<small>LLM</small>
</button>
<button
aria-label="Agent Runtime"
@@ -1003,15 +1043,32 @@ export function SettingsPanel({
{activeTab === 'model' && (
<>
<div className="model-type-navigation">
<SegmentedControl
ariaLabel="模型类型"
onChange={setModelType}
options={[
{ label: 'LLM 模型', value: 'llm' },
{ label: '向量模型', value: 'embedding' }
]}
value={modelType}
/>
<small>
{modelType === 'llm'
? '配置对话、推理和图片生成使用的模型连接。'
: '配置知识库语义检索与 GraphRAG 使用的向量模型。'}
</small>
</div>
{modelType === 'llm' && (
<div className="settings-section">
<div className="settings-section__title settings-section__title--actions">
<KeyRound size={17} />
<div>
<strong></strong>
<strong>LLM </strong>
<small>
OpenAI ResponsesAnthropic Messages
OpenAI Chat Completions OpenAI Images
Generations
OpenAI ResponsesAnthropic Messages
OpenAI Chat Completions使
OpenAI Images Generations
</small>
</div>
<button
@@ -1214,6 +1271,30 @@ export function SettingsPanel({
<option value="none"></option>
</select>
</label>
{profile.protocol ===
'openai-images-generations' && (
<label className="field">
<span></span>
<select
aria-label={`图片质量 ${profile.name}`}
onChange={(event) =>
updateModelProfile(profile.id, {
imageGenerationQuality: event.target
.value as ModelProfileDraft['imageGenerationQuality']
})
}
value={profile.imageGenerationQuality}
>
<option value="auto"></option>
<option value="low"></option>
<option value="medium"></option>
<option value="high"></option>
</select>
<small>
OpenAI
</small>
</label>
)}
{profile.authentication === 'api-key' ? (
<>
<label className="field">
@@ -1290,11 +1371,140 @@ export function SettingsPanel({
</p>
)}
</div>
)}
{modelType === 'embedding' && (
<div className="settings-section">
<div className="settings-section__title">
<KeyRound size={17} />
<div>
<strong></strong>
<small>
使 OpenAI Embeddings
</small>
</div>
</div>
<div className="runtime-note">
<label className="check-field">
<input
checked={knowledgeEmbeddingEnabled}
onChange={(event) =>
setKnowledgeEmbeddingEnabled(event.target.checked)
}
type="checkbox"
/>
<span></span>
</label>
<label className="field">
<span> URL</span>
<input
aria-label="向量接口 URL"
disabled={!knowledgeEmbeddingEnabled}
inputMode="url"
onChange={(event) =>
setKnowledgeEmbeddingBaseUrl(event.target.value)
}
placeholder="https://provider.example/v1/embeddings"
value={knowledgeEmbeddingBaseUrl}
/>
<small>
OpenAI Embeddings
</small>
</label>
<label className="field">
<span></span>
<input
aria-label="模型名称"
disabled={!knowledgeEmbeddingEnabled}
onChange={(event) =>
setKnowledgeEmbeddingModel(event.target.value)
}
value={knowledgeEmbeddingModel}
/>
</label>
<label className="field">
<span>API Key</span>
<input
aria-label="API Key(可选)"
autoComplete="off"
disabled={
!knowledgeEmbeddingEnabled ||
settings?.knowledgeEmbeddingCredentialSource ===
'environment' ||
!settings?.secureStorageAvailable
}
onChange={(event) => {
setKnowledgeEmbeddingApiKey(event.target.value)
setClearKnowledgeEmbeddingApiKey(false)
}}
placeholder={
settings?.knowledgeEmbeddingApiKeyConfigured
? '已配置,留空保持不变'
: '本地无认证服务可留空'
}
type="password"
value={knowledgeEmbeddingApiKey}
/>
</label>
<div className="credential-state">
<LockKeyhole size={15} />
<span>
{settings
? credentialLabels[
settings.knowledgeEmbeddingCredentialSource
]
: '尚未配置'}
</span>
{settings?.knowledgeEmbeddingCredentialSource ===
'encrypted' && (
<button
onClick={() => {
setKnowledgeEmbeddingApiKey('')
setClearKnowledgeEmbeddingApiKey(true)
}}
type="button"
>
{clearKnowledgeEmbeddingApiKey
? '保存后清除'
: '清除凭据'}
</button>
)}
</div>
<small>
API Key
退 FTS5
</small>
</div>
</div>
)}
</>
)}
{activeTab === 'security' && (
<>
<div className="settings-section subagent-routing-settings">
<div className="settings-section__title">
<div>
<strong>Subagent </strong>
<small></small>
</div>
</div>
<label className="check-field">
<input
aria-describedby="subagent-smart-routing-help"
checked={subagentSmartRoutingEnabled}
onChange={(event) =>
setSubagentSmartRoutingEnabled(event.target.checked)
}
type="checkbox"
/>
<span> Subagent </span>
</label>
<small id="subagent-smart-routing-help">
Ask Plan
1 使使
</small>
</div>
<label className="field">
<span>Runtime OS </span>
<select
@@ -1340,44 +1550,6 @@ export function SettingsPanel({
</small>
</label>
<div className="runtime-note">
<label className="check-field">
<input
checked={knowledgeEmbeddingEnabled}
onChange={(event) =>
setKnowledgeEmbeddingEnabled(event.target.checked)
}
type="checkbox"
/>
<span> Ollama GraphRAG</span>
</label>
<label className="field">
<span>Ollama </span>
<input
disabled={!knowledgeEmbeddingEnabled}
inputMode="url"
onChange={(event) =>
setKnowledgeEmbeddingBaseUrl(event.target.value)
}
value={knowledgeEmbeddingBaseUrl}
/>
</label>
<label className="field">
<span>Embedding </span>
<input
disabled={!knowledgeEmbeddingEnabled}
onChange={(event) =>
setKnowledgeEmbeddingModel(event.target.value)
}
value={knowledgeEmbeddingModel}
/>
</label>
<small>
Ollama 退
FTS5
</small>
</div>
<div className="settings-section settings-section--danger">
<div>
<strong></strong>
+27
View File
@@ -106,6 +106,33 @@ describe('activity-store', () => {
})
})
it('persists and upserts Subagent state transitions', () => {
const queued: ActivityRecord = {
...makeRecord(1),
kind: 'subagent',
callId: 'child-task-1',
title: '研究专家',
status: 'pending'
}
const completed = upsertActivityRecord([queued], {
...queued,
id: 'replacement-id',
createdAt: 99,
status: 'completed'
})
expect(completed).toEqual([
expect.objectContaining({
id: queued.id,
createdAt: queued.createdAt,
kind: 'subagent',
status: 'completed'
})
])
expect(saveActivityRecords(completed)).toBe(true)
expect(loadActivityRecords()).toEqual(completed)
})
it('reconciles stale active records with durable task outcomes', () => {
const records: ActivityRecord[] = [
{ ...makeRecord(1), status: 'running' },
+9 -3
View File
@@ -12,6 +12,7 @@ const activityKinds = [
'request',
'tool',
'approval',
'subagent',
'result'
] as const
const activityStatuses = [
@@ -86,13 +87,16 @@ export function upsertActivityRecord(
records: readonly ActivityRecord[],
incoming: ActivityRecord
): ActivityRecord[] {
if (incoming.kind !== 'tool' || !incoming.callId) {
if (
(incoming.kind !== 'tool' && incoming.kind !== 'subagent') ||
!incoming.callId
) {
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
}
const existingIndex = records.findIndex(
(record) =>
record.kind === 'tool' &&
record.kind === incoming.kind &&
record.requestId === incoming.requestId &&
record.callId === incoming.callId
)
@@ -153,7 +157,9 @@ export function reconcileActivityRecords(
...record,
status:
terminalStatus === 'completed' &&
(record.kind === 'tool' || record.kind === 'approval')
(record.kind === 'tool' ||
record.kind === 'approval' ||
record.kind === 'subagent')
? 'interrupted'
: terminalStatus,
detail:
+381 -2
View File
@@ -45,6 +45,8 @@
--font-section-title: 14px;
--font-body: 12px;
--font-caption: 10px;
--font-family-mono:
"Cascadia Code", "SFMono-Regular", Consolas, monospace;
font-family:
Inter, "SF Pro Display", "Segoe UI", "PingFang SC", "Microsoft YaHei",
sans-serif;
@@ -699,6 +701,17 @@ textarea:focus-visible {
gap: 8px;
}
.assistant-sidebar__section-description {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
margin: 0;
background: var(--surface-subtle);
color: var(--text-secondary);
font-size: var(--font-caption);
line-height: 1.6;
}
.assistant-sidebar__section h3 {
display: flex;
align-items: center;
@@ -857,6 +870,18 @@ textarea:focus-visible {
background: #e6f4ff;
}
.assistant-sidebar__row--subtask {
width: calc(100% - var(--space-4));
margin-left: var(--space-4);
border-left: 2px solid var(--accent-selected);
background: var(--surface-subtle);
}
.assistant-sidebar__subtask-meta {
color: var(--text-secondary) !important;
white-space: normal !important;
}
.assistant-sidebar__row span,
.assistant-sidebar__context span,
.assistant-sidebar__library {
@@ -1642,6 +1667,119 @@ textarea:focus-visible {
justify-content: flex-end;
}
.message-attachments {
display: flex;
max-width: 100%;
margin-bottom: var(--space-2);
flex-wrap: wrap;
gap: var(--space-2);
}
.message--user .message-attachments {
justify-content: flex-end;
}
.message-attachment {
display: flex;
min-width: 0;
max-width: min(100%, 320px);
align-items: center;
padding: var(--space-2);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--text-primary);
gap: var(--space-2);
}
.message-attachment--image {
width: min(100%, 240px);
flex: 1 1 180px;
align-items: flex-end;
flex-direction: column;
}
.message-image-button {
display: block;
width: 100%;
padding: 0;
border: 0;
border-radius: var(--radius-control);
background: transparent;
cursor: zoom-in;
}
.message-image-button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.message-attachment img {
display: block;
width: min(240px, 100%);
max-height: 180px;
align-self: stretch;
border-radius: calc(var(--radius-control) - 2px);
background: var(--surface-subtle);
object-fit: contain;
}
.message-attachment__icon {
display: grid;
width: 32px;
height: 32px;
flex: 0 0 auto;
place-items: center;
border-radius: var(--radius-control);
background: var(--accent-subtle);
color: var(--accent);
}
.message-attachment__details {
display: flex;
min-width: 0;
max-width: 100%;
align-self: stretch;
flex-direction: column;
justify-content: center;
}
.message-attachment__details strong {
overflow: hidden;
color: var(--text-primary);
font-size: var(--font-body);
text-overflow: ellipsis;
white-space: nowrap;
}
.message-attachment__details small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.message-image-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.message-image-actions button {
display: inline-flex;
min-height: 28px;
padding: var(--space-1) var(--space-2);
align-items: center;
border-radius: var(--radius-control);
background: transparent;
color: var(--accent);
cursor: pointer;
font-size: var(--font-caption);
gap: var(--space-1);
}
.message-image-actions button:hover {
background: var(--accent-subtle);
}
.message__meta strong {
color: #1f1f1f;
font-size: 11px;
@@ -1794,6 +1932,10 @@ textarea:focus-visible {
font-size: 9px;
}
.message-generated-image > .message-image-actions {
margin-top: var(--space-1);
}
.message__status {
display: flex;
align-items: center;
@@ -1828,8 +1970,19 @@ textarea:focus-visible {
gap: 8px;
}
.tool-activity span {
.tool-activity__content {
display: grid;
flex: 1;
gap: var(--space-1);
min-width: 0;
}
.tool-activity__content code {
color: var(--danger);
font-family: var(--font-family-mono);
font-size: var(--font-caption);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.tool-activity small {
@@ -1837,6 +1990,58 @@ textarea:focus-visible {
text-transform: uppercase;
}
.subagent-status-list {
display: grid;
margin-top: var(--space-2);
gap: var(--space-2);
}
.subagent-status-card {
display: grid;
align-items: center;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
color: var(--text-secondary);
gap: var(--space-2);
grid-template-columns: auto minmax(0, 1fr) auto;
}
.subagent-status-card--running {
border-color: var(--accent-selected);
}
.subagent-status-card--failed,
.subagent-status-card--cancelled {
border-color: var(--danger-border);
background: var(--danger-subtle);
}
.subagent-status-card > div {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.subagent-status-card strong,
.subagent-status-card span {
color: var(--text-primary);
font-size: var(--font-body);
}
.subagent-status-card small {
color: var(--text-muted);
font-size: var(--font-caption);
}
.subagent-status-card p {
margin: 0;
color: var(--danger);
font-size: var(--font-caption);
overflow-wrap: anywhere;
}
.approval-card {
display: grid;
align-items: center;
@@ -1917,7 +2122,7 @@ textarea:focus-visible {
.context-list {
display: flex;
padding: 0 1px 9px;
overflow-x: auto;
flex-wrap: wrap;
gap: 7px;
}
@@ -1967,6 +2172,152 @@ textarea:focus-visible {
color: #ff4d4f;
}
.window-capture-backdrop {
position: fixed;
z-index: 70;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.image-viewer-backdrop {
position: fixed;
z-index: 80;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.image-viewer-dialog {
display: grid;
width: min(1120px, 100%);
max-height: calc(100vh - 70px);
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-3);
}
.image-viewer-dialog__header,
.image-viewer-dialog__header > div {
display: flex;
align-items: center;
gap: var(--space-2);
}
.image-viewer-dialog__header {
min-width: 0;
justify-content: space-between;
}
.image-viewer-dialog__header > strong {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
font-size: var(--font-section-title);
text-overflow: ellipsis;
white-space: nowrap;
}
.image-viewer-dialog__header .secondary-button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.image-viewer-dialog__content {
display: grid;
min-height: 0;
overflow: auto;
border-radius: var(--radius-control);
background: var(--surface-subtle);
place-items: center;
}
.image-viewer-dialog__content img {
display: block;
max-width: 100%;
max-height: calc(100vh - 160px);
object-fit: contain;
}
.window-capture-dialog {
display: grid;
width: min(520px, 100%);
max-height: min(680px, calc(100vh - 70px));
padding: var(--space-4);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-3);
}
.window-capture-dialog__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.window-capture-dialog__header > div {
display: grid;
gap: var(--space-1);
}
.window-capture-dialog__header strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.window-capture-dialog__header small {
color: var(--text-secondary);
font-size: var(--font-body);
}
.window-capture-dialog__list {
display: grid;
min-height: 0;
padding: var(--space-1);
overflow-y: auto;
gap: var(--space-2);
}
.window-capture-dialog__list > button {
display: flex;
width: 100%;
min-width: 0;
min-height: 40px;
align-items: center;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
background: var(--surface-subtle);
color: var(--text-primary);
cursor: pointer;
gap: var(--space-2);
text-align: left;
}
.window-capture-dialog__list > button:hover {
border-color: var(--accent);
background: var(--accent-subtle);
}
.window-capture-dialog__list > button span {
min-width: 0;
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.composer:focus-within {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgb(22 119 255 / 12%);
@@ -2424,6 +2775,19 @@ textarea:focus-visible {
white-space: nowrap;
}
.model-type-navigation {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.model-type-navigation > small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.model-connection-manager {
display: grid;
min-width: 0;
@@ -2578,6 +2942,10 @@ textarea:focus-visible {
line-height: 1.6;
}
.field-error {
color: var(--danger) !important;
}
.role-prompt-detail__actions {
display: flex;
align-items: center;
@@ -3373,6 +3741,12 @@ textarea:focus-visible {
accent-color: #1677ff;
}
.subagent-routing-settings > small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.55;
}
.settings-section--danger {
border-color: #ff4d4f;
background: #fafafa;
@@ -5541,6 +5915,11 @@ textarea:focus-visible {
justify-content: center;
}
.model-type-navigation {
align-items: stretch;
flex-direction: column;
}
.role-prompt-add {
width: 100%;
justify-content: center;