feat: prepare GoodBuddy 0.8.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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
@@ -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('子专家:研究专家 · 智能路由')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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} />
|
||||
项目工作区
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 Responses、Anthropic Messages 和
|
||||
OpenAI 兼容 Chat Completions;另可配置 OpenAI Images
|
||||
Generations 图像生成接口
|
||||
支持 OpenAI Responses、Anthropic 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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user