feat: expand secure assistant workflows

Harden runtime execution and add local knowledge, Smart Heartbeat, usage visibility, responsive product surfaces, and cross-platform packaging support.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-02 10:04:59 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 6ef1795b81
commit b3fdf96962
82 changed files with 17608 additions and 825 deletions
+97 -1
View File
@@ -2,9 +2,11 @@ import {
cleanup,
fireEvent,
render,
screen
screen,
within
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import {
MAX_ACTIVITY_RECORDS,
@@ -27,6 +29,50 @@ function makeRecord(
}
}
function makeTokenUsage(): TokenUsageSummary {
return {
totals: {
callCount: 2,
input: 125,
output: 25,
cacheRead: 40,
cacheWrite: 10,
totalTokens: 200
},
records: [
{
requestId: 'request-1',
projectId: 'project-1',
projectName: '项目甲',
conversationId: 'conversation-1',
conversationTitle: '会话甲',
runtime: 'model',
provider: 'openai',
model: 'gpt-5',
callCount: 1,
input: 100,
output: 20,
cacheRead: 40,
cacheWrite: 10,
totalTokens: 170
},
{
requestId: 'request-2',
conversationId: '',
runtime: 'model',
provider: '',
model: '',
callCount: 1,
input: 25,
output: 5,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 30
}
]
}
}
describe('ActivityPanel', () => {
afterEach(() => {
cleanup()
@@ -44,6 +90,7 @@ describe('ActivityPanel', () => {
makeRecord(3, 'denied'),
makeRecord(4)
]}
tokenUsage={makeTokenUsage()}
/>
)
@@ -69,6 +116,7 @@ describe('ActivityPanel', () => {
onClear={onClear}
onOpenConversation={vi.fn()}
records={[makeRecord(1)]}
tokenUsage={makeTokenUsage()}
/>
)
@@ -80,6 +128,7 @@ describe('ActivityPanel', () => {
onClear={onClear}
onOpenConversation={vi.fn()}
records={[]}
tokenUsage={makeTokenUsage()}
/>
)
expect(
@@ -102,10 +151,57 @@ describe('ActivityPanel', () => {
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={records}
tokenUsage={makeTokenUsage()}
/>
)
expect(screen.getByText('活动 499')).toBeInTheDocument()
expect(screen.queryByText('活动 500')).not.toBeInTheDocument()
})
it('shows totals without double-counting cache tokens', () => {
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={[]}
tokenUsage={makeTokenUsage()}
/>
)
const stats = screen.getByLabelText('Token 用量统计')
expect(
within(stats).getByText('150')
).toBeInTheDocument()
const projectRow = screen.getByRole('row', {
name: '项目甲gpt-5 · openai 100 20 10 40 120'
})
expect(projectRow).toBeInTheDocument()
expect(
within(projectRow).queryByText('170')
).not.toBeInTheDocument()
})
it('groups token usage and displays fallback labels', () => {
render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={[]}
tokenUsage={makeTokenUsage()}
/>
)
expect(screen.getByText('项目甲')).toBeInTheDocument()
expect(screen.getByText('未归属项目')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '按会话' }))
expect(screen.getByText('会话甲')).toBeInTheDocument()
expect(screen.getByText('已删除会话')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '按模型' }))
expect(screen.getByText('gpt-5')).toBeInTheDocument()
expect(screen.getByText('未知模型')).toBeInTheDocument()
})
})
+152 -3
View File
@@ -1,14 +1,21 @@
import { Activity, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
import {
MAX_ACTIVITY_RECORDS,
type ActivityRecord
} from './activity-store'
import {
getTokenUsageTotals,
groupTokenUsage,
type TokenUsageGroup
} from './token-usage'
type ActivityFilter = 'all' | 'active' | 'failed'
export type ActivityPanelProps = {
records: readonly ActivityRecord[]
tokenUsage: TokenUsageSummary
onClear: () => void
onOpenConversation: (conversationId: string) => void
}
@@ -18,7 +25,9 @@ const statusLabels: Record<ActivityRecord['status'], string> = {
running: '进行中',
completed: '已完成',
failed: '失败',
denied: '已拒绝'
denied: '已拒绝',
cancelled: '已取消',
interrupted: '已中断'
}
const kindLabels: Record<ActivityRecord['kind'], string> = {
@@ -37,6 +46,20 @@ const filters: ReadonlyArray<{
{ value: 'failed', label: '失败' }
]
const tokenGroups: ReadonlyArray<{
value: TokenUsageGroup
label: string
columnLabel: string
}> = [
{ value: 'project', label: '按项目', columnLabel: '项目' },
{
value: 'conversation',
label: '按会话',
columnLabel: '会话'
},
{ value: 'model', label: '按模型', columnLabel: '模型' }
]
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
@@ -45,12 +68,19 @@ const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
second: '2-digit'
})
const tokenCountFormatter = new Intl.NumberFormat('zh-CN')
function isActive(record: ActivityRecord): boolean {
return record.status === 'pending' || record.status === 'running'
}
function isFailed(record: ActivityRecord): boolean {
return record.status === 'failed' || record.status === 'denied'
return (
record.status === 'failed' ||
record.status === 'denied' ||
record.status === 'cancelled' ||
record.status === 'interrupted'
)
}
function matchesFilter(
@@ -90,17 +120,20 @@ function emptyMessage(filter: ActivityFilter): string {
return '当前没有等待中或正在运行的活动。'
}
if (filter === 'failed') {
return '当前没有失败或被拒绝的活动。'
return '当前没有失败、取消或中断的活动。'
}
return '尚无活动记录。任务请求、工具调用和审批决定会显示在这里。'
}
export function ActivityPanel({
records,
tokenUsage,
onClear,
onOpenConversation
}: ActivityPanelProps): React.JSX.Element {
const [filter, setFilter] = useState<ActivityFilter>('all')
const [tokenGroup, setTokenGroup] =
useState<TokenUsageGroup>('project')
const visibleRecords = useMemo(
() => records.slice(0, MAX_ACTIVITY_RECORDS),
@@ -112,6 +145,17 @@ export function ActivityPanel({
)
const activeCount = visibleRecords.filter(isActive).length
const failedCount = visibleRecords.filter(isFailed).length
const tokenTotals = useMemo(
() => getTokenUsageTotals(tokenUsage),
[tokenUsage]
)
const tokenRows = useMemo(
() => groupTokenUsage(tokenUsage, tokenGroup),
[tokenGroup, tokenUsage]
)
const tokenGroupLabel =
tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ??
'项目'
return (
<section
@@ -137,6 +181,111 @@ export function ActivityPanel({
</button>
</header>
<section
aria-labelledby="token-usage-title"
className="token-usage"
>
<header className="token-usage__header">
<h3 id="token-usage-title">Token </h3>
<div
aria-label="Token 用量分组"
className="token-usage__groups"
role="group"
>
{tokenGroups.map((item) => (
<button
aria-pressed={tokenGroup === item.value}
className={
tokenGroup === item.value
? 'token-usage__group token-usage__group--active'
: 'token-usage__group'
}
key={item.value}
onClick={() => setTokenGroup(item.value)}
type="button"
>
{item.label}
</button>
))}
</div>
</header>
<dl aria-label="Token 用量统计" className="token-usage__stats">
<div>
<dt></dt>
<dd>{tokenCountFormatter.format(tokenTotals.inputTokens)}</dd>
</div>
<div>
<dt></dt>
<dd>{tokenCountFormatter.format(tokenTotals.outputTokens)}</dd>
</div>
<div>
<dt></dt>
<dd>
{tokenCountFormatter.format(tokenTotals.cacheWriteTokens)}
</dd>
</div>
<div>
<dt></dt>
<dd>
{tokenCountFormatter.format(tokenTotals.cacheReadTokens)}
</dd>
</div>
<div>
<dt></dt>
<dd>{tokenCountFormatter.format(tokenTotals.totalTokens)}</dd>
</div>
</dl>
<div className="token-usage__table-scroll">
<table aria-label={`Token 用量${tokenGroupLabel}明细`}>
<thead>
<tr>
<th scope="col">{tokenGroupLabel}</th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{tokenRows.length === 0 ? (
<tr>
<td className="token-usage__empty" colSpan={6}>
Token
</td>
</tr>
) : (
tokenRows.map((row) => (
<tr key={row.key}>
<th scope="row">
<span>{row.label}</span>
{row.detail && <small>{row.detail}</small>}
</th>
<td>
{tokenCountFormatter.format(row.inputTokens)}
</td>
<td>
{tokenCountFormatter.format(row.outputTokens)}
</td>
<td>
{tokenCountFormatter.format(row.cacheWriteTokens)}
</td>
<td>
{tokenCountFormatter.format(row.cacheReadTokens)}
</td>
<td>
{tokenCountFormatter.format(row.totalTokens)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
<dl aria-label="活动统计" className="activity-panel__stats">
<div>
<dt></dt>
+448 -1
View File
@@ -4,6 +4,7 @@ import {
fireEvent,
render,
screen,
within,
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -36,6 +37,7 @@ const api: DesktopApi = {
})),
show: vi.fn(async () => {}),
hide: vi.fn(async () => {}),
clearLocalData: vi.fn(async () => {}),
onNewConversation: vi.fn(() => () => {}),
onOpenSettings: vi.fn(() => () => {})
},
@@ -44,6 +46,7 @@ const api: DesktopApi = {
id: 'model' as const,
label: 'sonnet-5',
available: true,
supportsToolExecution: false,
detail: 'Ready'
})),
run,
@@ -61,6 +64,8 @@ const api: DesktopApi = {
provider: 'auto',
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
opencodeBaseUrl: '',
opencodeEmbedded: false,
opencodeBinaryPath: '',
@@ -68,6 +73,10 @@ const api: DesktopApi = {
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat',
runtimeSandboxMode: 'auto',
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434',
knowledgeEmbeddingModel: 'nomic-embed-text',
workspacePath: 'C:\\Users\\test',
apiKeyConfigured: false,
credentialSource: 'none',
@@ -77,6 +86,8 @@ const api: DesktopApi = {
name: '默认模型',
baseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
apiKeyConfigured: false,
credentialSource: 'none'
}
@@ -92,6 +103,8 @@ const api: DesktopApi = {
provider: input.provider,
modelBaseUrl: input.modelBaseUrl,
modelName: input.modelName,
modelProtocol: input.modelProtocol,
modelAuthentication: input.modelAuthentication,
opencodeBaseUrl: input.opencodeBaseUrl,
opencodeEmbedded: input.opencodeEmbedded,
opencodeBinaryPath: input.opencodeBinaryPath,
@@ -99,6 +112,10 @@ const api: DesktopApi = {
continueBinaryPath: input.continueBinaryPath,
continueConfigPath: input.continueConfigPath,
continueMode: input.continueMode,
runtimeSandboxMode: input.runtimeSandboxMode,
knowledgeEmbeddingEnabled: input.knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl: input.knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel: input.knowledgeEmbeddingModel,
workspacePath: input.workspacePath,
apiKeyConfigured: input.apiKey.action === 'replace',
credentialSource:
@@ -110,6 +127,8 @@ const api: DesktopApi = {
name: '默认模型',
baseUrl: input.modelBaseUrl,
modelName: input.modelName,
protocol: input.modelProtocol,
authentication: input.modelAuthentication,
apiKey: input.apiKey
}
]
@@ -150,6 +169,7 @@ const api: DesktopApi = {
id: 'model',
label: 'sonnet-5',
available: true,
supportsToolExecution: false,
detail: 'Ready'
})
)
@@ -182,10 +202,27 @@ const api: DesktopApi = {
}))
},
tasks: {
list: vi.fn(async () => [])
list: vi.fn(async () => []),
setStatus: vi.fn(async () => {})
},
usage: {
getTokenSummary: vi.fn(async () => ({
totals: {
callCount: 0,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0
},
records: []
}))
},
artifacts: {
list: vi.fn(async () => []),
get: vi.fn(async () => {
throw new Error('Artifact not found')
}),
importFiles: vi.fn(async () => [])
},
memory: {
@@ -215,6 +252,36 @@ const api: DesktopApi = {
remove: vi.fn(async () => {}),
runNow: vi.fn(async () => {})
},
heartbeats: {
list: vi.fn(async () => []),
create: vi.fn(async (input) => ({
...input,
id: crypto.randomUUID(),
nextRunAt: '2026-08-01T09:00:00.000Z',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
})),
update: vi.fn(async (heartbeatId, input) => ({
...input,
id: heartbeatId,
nextRunAt: '2026-08-01T09:00:00.000Z',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
})),
setPaused: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
runNow: vi.fn(async (heartbeatId) => ({
id: crypto.randomUUID(),
configId: heartbeatId,
trigger: 'manual' as const,
scheduledFor: '2026-08-01T00:00:00.000Z',
status: 'completed' as const,
attemptCount: 1,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
})),
history: vi.fn(async () => ({ runs: [], entries: [] }))
},
experts: {
list: vi.fn(async () => []),
create: vi.fn(async (input) => ({
@@ -314,6 +381,13 @@ describe('App', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'model',
label: 'sonnet-5',
available: true,
supportsToolExecution: false,
detail: 'Ready'
})
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: api
@@ -336,6 +410,11 @@ describe('App', () => {
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
expect(request?.prompt).toBe('帮我分析项目')
const userMessage = screen
.getAllByText('帮我分析项目')
.map((element) => element.closest('article'))
.find((element) => element?.classList.contains('message--user'))
expect(userMessage).toHaveClass('message--user')
act(() => {
if (!request) {
@@ -355,6 +434,311 @@ describe('App', () => {
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
})
it('loads token usage in activity and refreshes it when a run finishes', async () => {
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
totals: {
callCount: 1,
input: 100,
output: 20,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 120
},
records: [
{
requestId: 'usage-request-1',
projectId,
projectName: project.name,
conversationId: 'usage-conversation-1',
conversationTitle: '用量会话',
runtime: 'model',
provider: 'anthropic',
model: 'sonnet-5',
callCount: 1,
input: 100,
output: 20,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 120
}
]
})
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '统计用量' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
fireEvent.click(screen.getByText('任务与活动'))
const stats = await screen.findByLabelText('Token 用量统计')
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce()
)
expect(within(stats).getByText('120')).toBeInTheDocument()
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
totals: {
callCount: 2,
input: 300,
output: 45,
cacheRead: 10,
cacheWrite: 5,
totalTokens: 360
},
records: [
{
requestId: request.requestId,
projectId,
projectName: project.name,
conversationId: request.conversationId,
conversationTitle: '用量会话',
runtime: 'model',
provider: 'anthropic',
model: 'sonnet-5',
callCount: 2,
input: 300,
output: 45,
cacheRead: 10,
cacheWrite: 5,
totalTokens: 360
}
]
})
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledTimes(2)
)
expect(within(stats).getByText('345')).toBeInTheDocument()
})
it('shows and changes the work mode in the composer', async () => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'opencode',
label: 'OpenCode',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
render(<App />)
const mode = await screen.findByLabelText('工作模式')
expect(mode).toHaveValue('ask')
expect(mode.closest('.composer')).not.toBeNull()
expect(
await screen.findByText(/Ask 模式:只读问答,不会调用工具/)
).toBeInTheDocument()
fireEvent.change(mode, { target: { value: 'execute' } })
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '执行任务' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() =>
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '执行任务',
workMode: 'execute'
})
)
)
})
it('disables Execute for a runtime without tool support', async () => {
render(<App />)
const mode = await screen.findByLabelText('工作模式')
expect(
within(mode).getByRole('option', {
name: 'Execute · 受控执行'
})
).toBeDisabled()
expect(mode).toHaveValue('ask')
})
it('terminalizes tools and activity when a request is cancelled', async () => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'opencode',
label: 'OpenCode',
available: true,
supportsToolExecution: true,
detail: 'Ready'
})
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '执行长任务' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'tool',
callId: 'call-1',
name: 'bash',
state: 'running',
summary: 'OpenCode 工具:bash'
})
agentListener?.({
requestId: request.requestId,
type: 'error',
status: 'cancelled',
message: '请求已取消'
})
})
expect(await screen.findByText('已取消')).toBeInTheDocument()
fireEvent.click(screen.getByText('任务与活动'))
expect((await screen.findAllByText('已取消')).length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: '进行中' }))
expect(
screen.getByText('当前没有等待中或正在运行的活动。')
).toBeInTheDocument()
})
it('switches runtime profiles from the composer dropdown', async () => {
render(<App />)
const runtimeButton = await screen.findByRole('button', {
name: /sonnet-5/u
})
fireEvent.click(runtimeButton)
expect(
await screen.findByRole('menu', { name: 'Runtime 和模型' })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('menuitemradio', {
name: /.*sonnet-5/u
})
)
await waitFor(() =>
expect(api.settings.updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
provider: 'model',
defaultModelProfileId: modelProfileId
})
)
)
expect(
screen.queryByRole('heading', { name: '设置中心' })
).not.toBeInTheDocument()
})
it('opens project creation as an unobscured dialog', async () => {
render(<App />)
const newProjectButton = await screen.findByLabelText('新建项目')
fireEvent.click(newProjectButton)
let dialog = screen.getByRole('dialog', { name: '新建项目' })
expect(dialog).toHaveClass('project-create-card')
expect(within(dialog).getByRole('button', { name: '创建' }))
.toBeDisabled()
expect(within(dialog).getByLabelText('名称')).toHaveFocus()
fireEvent.keyDown(document, { key: 'Escape' })
expect(
screen.queryByRole('dialog', { name: '新建项目' })
).not.toBeInTheDocument()
expect(newProjectButton).toHaveFocus()
fireEvent.click(newProjectButton)
dialog = screen.getByRole('dialog', { name: '新建项目' })
fireEvent.change(within(dialog).getByLabelText('名称'), {
target: { value: '新项目' }
})
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }))
await waitFor(() =>
expect(api.projects.create).toHaveBeenCalledWith(
expect.objectContaining({
name: '新项目',
rootPath: ''
})
)
)
})
it('marks an image model and renders its generated artifact', async () => {
vi.mocked(api.agent.getStatus).mockResolvedValueOnce({
id: 'model',
label: 'gpt-image-2',
available: true,
supportsToolExecution: false,
detail: 'OpenAI Images Generations',
capability: 'image-generation'
})
render(<App />)
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'描述你想生成的图片…'
)
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenCalled()
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '生成一只蓝色的猫' }
})
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
const artifactId = '00000000-0000-4000-8000-000000000301'
vi.mocked(api.artifacts.get).mockResolvedValueOnce(
{
id: artifactId,
projectId,
taskId: request.requestId,
kind: 'image',
title: '生成一只蓝色的猫',
mimeType: 'image/png',
content:
'data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAA',
byteSize: 42,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
)
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'artifact',
artifactId,
kind: 'image',
title: '生成一只蓝色的猫'
})
})
expect(
await screen.findByRole('img', { name: '生成一只蓝色的猫' })
).toHaveAttribute('src', expect.stringMatching(/^data:image\/png/u))
})
it('can dispatch a request to the parallel expert team', async () => {
render(<App />)
@@ -473,4 +857,67 @@ describe('App', () => {
fireEvent.click(screen.getByLabelText('关闭助手工作栏'))
expect(sidebar).not.toHaveClass('assistant-sidebar--open')
})
it('opens Smart Heartbeat as a first-class workspace', async () => {
render(<App />)
fireEvent.click(
screen.getByRole('button', { name: '智能心跳' })
)
expect(
await screen.findByRole('heading', { name: '智能心跳' })
).toBeInTheDocument()
expect(
screen.getByRole('tab', { name: '成长概览' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '配置智能心跳' })
).toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
})
it('gives the knowledge workspace the full content width', async () => {
render(<App />)
fireEvent.click(screen.getByRole('button', { name: '知识库' }))
expect(
await screen.findByLabelText('知识工作区')
).toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
expect(
screen.queryByLabelText('专家角色')
).not.toBeInTheDocument()
})
it('keeps Smart Heartbeat available when the runtime is not configured', async () => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'setup',
label: '需要配置模型',
available: false,
supportsToolExecution: false,
detail: '请配置模型'
})
render(<App />)
expect(
await screen.findByRole('heading', { name: '设置中心' })
).toBeInTheDocument()
await waitFor(() =>
expect(api.agent.getStatus).toHaveBeenCalledOnce()
)
fireEvent.click(
screen.getByRole('button', { name: '智能心跳' })
)
expect(
await screen.findByRole('heading', { name: '智能心跳' })
).toBeInTheDocument()
expect(api.agent.getStatus).toHaveBeenCalledOnce()
})
})
+932 -69
View File
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
import '@testing-library/jest-dom/vitest'
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within
} from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
AssistantHeartbeatConfig,
AssistantHeartbeatEntry,
AssistantHeartbeatRun,
AssistantMemory,
AssistantTask
} from '../../shared/assistant-contracts'
import { HeartbeatCenter, type HeartbeatCenterProps } from './HeartbeatCenter'
const config: AssistantHeartbeatConfig = {
id: 'heartbeat-1',
name: '智能成长回顾',
timezone: 'Asia/Shanghai',
recurrence: {
type: 'daily',
localTime: '09:00'
},
enabled: true,
lookbackHours: 48,
retentionDays: 90,
nextRunAt: '2026-08-02T01:00:00.000Z',
lastRunAt: '2026-08-01T01:00:00.000Z',
lastStatus: 'completed',
createdAt: '2026-07-31T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:00.000Z'
}
const runs: AssistantHeartbeatRun[] = [
{
id: 'run-completed',
configId: config.id,
trigger: 'scheduled',
scheduledFor: '2026-08-01T01:00:00.000Z',
status: 'completed',
attemptCount: 1,
completedAt: '2026-08-01T01:00:30.000Z',
entryId: 'entry-1',
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:30.000Z'
},
{
id: 'run-failed',
configId: config.id,
trigger: 'manual',
scheduledFor: '2026-07-31T01:00:00.000Z',
status: 'failed',
attemptCount: 2,
error: '模型暂时不可用',
createdAt: '2026-07-31T01:00:00.000Z',
updatedAt: '2026-07-31T01:01:00.000Z'
}
]
const entry: AssistantHeartbeatEntry = {
id: 'entry-1',
configId: config.id,
runId: 'run-completed',
scheduledFor: '2026-08-01T01:00:00.000Z',
summary: '本次心跳发现用户偏好简洁回复,并建议整理交付计划。',
highlights: ['回复偏好已经稳定', '项目存在一个待整理的交付计划'],
proposedMemoryIds: ['memory-1'],
followUpTaskIds: ['task-1'],
createdAt: '2026-08-01T01:00:30.000Z'
}
const memory: AssistantMemory = {
id: 'memory-1',
scope: 'global',
type: 'preference',
content: '用户偏好简洁且可执行的中文回复。',
confidence: 0.92,
salience: 0.88,
status: 'proposed',
createdAt: '2026-08-01T01:00:30.000Z',
updatedAt: '2026-08-01T01:00:30.000Z'
}
const task: AssistantTask = {
id: 'task-1',
title: '整理交付计划',
instructions: '梳理当前任务并形成明确的交付步骤。',
origin: 'assistant',
status: 'paused',
createdAt: '2026-08-01T01:00:30.000Z'
}
function createProps(
overrides: Partial<HeartbeatCenterProps> = {}
): HeartbeatCenterProps {
return {
configs: [config],
runs,
entries: [entry],
memories: [memory],
tasks: [task],
onCreate: vi.fn(async () => {}),
onSetPaused: vi.fn(async () => {}),
onRemove: vi.fn(async () => {}),
onRunNow: vi.fn(async () => {}),
onRefresh: vi.fn(async () => {}),
onSetMemoryStatus: vi.fn(async () => {}),
onSetTaskStatus: vi.fn(async () => {}),
onUseFollowUpTask: vi.fn(),
...overrides
}
}
describe('HeartbeatCenter', () => {
afterEach(() => {
cleanup()
})
it('shows heartbeat health, growth dimensions, and the latest report', () => {
render(<HeartbeatCenter {...createProps()} />)
expect(
screen.getByRole('heading', { name: '智能心跳' })
).toBeInTheDocument()
expect(screen.getByText('1 个计划运行中')).toBeInTheDocument()
expect(screen.getByText('50%')).toBeInTheDocument()
expect(
screen.getByText('本次心跳发现用户偏好简洁回复,并建议整理交付计划。')
).toBeInTheDocument()
const dimensions = screen.getByLabelText('智能心跳成长维度')
expect(
within(dimensions).getByText('记忆沉淀')
).toBeInTheDocument()
expect(
within(dimensions).getByText('行动转化')
).toBeInTheDocument()
expect(within(dimensions).getByText('2')).toBeInTheDocument()
})
it('turns heartbeat findings into explicit user actions', async () => {
const onSetMemoryStatus = vi.fn(async () => {})
const onSetTaskStatus = vi.fn(async () => {})
const onUseFollowUpTask = vi.fn()
render(
<HeartbeatCenter
{...createProps({
onSetMemoryStatus,
onSetTaskStatus,
onUseFollowUpTask
})}
/>
)
fireEvent.click(
screen.getByRole('tab', { name: /待处理建议/ })
)
expect(screen.getByText(memory.content)).toBeInTheDocument()
expect(screen.getByText(task.title)).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '确认记忆' })
)
await waitFor(() =>
expect(onSetMemoryStatus).toHaveBeenCalledWith(
memory.id,
'confirmed'
)
)
fireEvent.click(
screen.getByRole('button', { name: /带入对话处理/ })
)
expect(onUseFollowUpTask).toHaveBeenCalledWith(task)
fireEvent.click(
screen.getByRole('button', { name: '标记完成' })
)
await waitFor(() =>
expect(onSetTaskStatus).toHaveBeenCalledWith(
task.id,
'completed'
)
)
})
it('runs, refreshes, and exposes auditable heartbeat history', async () => {
const onRunNow = vi.fn(async () => {})
const onRefresh = vi.fn(async () => {})
render(
<HeartbeatCenter
{...createProps({ onRefresh, onRunNow })}
/>
)
fireEvent.click(
screen.getByRole('button', { name: '运行一次心跳' })
)
await waitFor(() =>
expect(onRunNow).toHaveBeenCalledWith(config.id)
)
fireEvent.click(
screen.getByRole('button', { name: '刷新智能心跳' })
)
await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce())
fireEvent.click(screen.getByRole('tab', { name: '心跳轨迹' }))
expect(screen.getByText('模型暂时不可用')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '展开完整报告' })
)
expect(screen.getByText(entry.highlights[0]!)).toBeInTheDocument()
})
it('guides first-time users to create a heartbeat plan', () => {
render(
<HeartbeatCenter
{...createProps({
configs: [],
runs: [],
entries: [],
memories: [],
tasks: []
})}
/>
)
fireEvent.click(
screen.getByRole('button', { name: '配置智能心跳' })
)
expect(
screen.getByRole('tab', { name: '心跳计划' })
).toHaveAttribute('aria-selected', 'true')
expect(
screen.getByRole('button', { name: '启用智能心跳' })
).toBeInTheDocument()
})
})
File diff suppressed because it is too large Load Diff
+252
View File
@@ -0,0 +1,252 @@
import { HeartPulse } from 'lucide-react'
import { useState } from 'react'
import type {
AssistantHeartbeatConfig,
HeartbeatCreateInput
} from '../../shared/assistant-contracts'
type HeartbeatSettingsProps = {
heartbeats: AssistantHeartbeatConfig[]
variant?: 'settings' | 'sidebar'
onCreate: (input: HeartbeatCreateInput) => Promise<void>
onSetPaused: (heartbeatId: string, paused: boolean) => Promise<void>
onRemove: (heartbeatId: string) => Promise<void>
onRunNow: (heartbeatId: string) => Promise<void>
}
const heartbeatStatusLabels: Record<
NonNullable<AssistantHeartbeatConfig['lastStatus']>,
string
> = {
claimed: '运行中',
completed: '已完成',
failed: '失败',
skipped: '已跳过'
}
export function HeartbeatSettings({
heartbeats,
variant = 'settings',
onCreate,
onSetPaused,
onRemove,
onRunNow
}: HeartbeatSettingsProps): React.JSX.Element {
const [time, setTime] = useState('09:00')
const [recurrence, setRecurrence] = useState<'daily' | 'weekly'>(
'daily'
)
const [weekday, setWeekday] = useState(1)
const [pendingAction, setPendingAction] = useState<string>()
const [error, setError] = useState<string>()
const [confirmingRemoveId, setConfirmingRemoveId] =
useState<string>()
const runAction = async (
actionId: string,
action: () => Promise<void>
): Promise<void> => {
if (pendingAction) {
return
}
setPendingAction(actionId)
setError(undefined)
try {
await action()
} catch (reason) {
setError(
reason instanceof Error ? reason.message : '智能心跳操作失败'
)
} finally {
setPendingAction(undefined)
}
}
return (
<div className={`heartbeat-settings heartbeat-settings--${variant}`}>
<div className="heartbeat-settings__intro">
<h3>
<HeartPulse size={15} />
</h3>
<p>
</p>
</div>
<div
className={`heartbeat-settings__form${
recurrence === 'weekly'
? ' heartbeat-settings__form--weekly'
: ''
}`}
>
<select
aria-label="心跳重复规则"
onChange={(event) =>
setRecurrence(event.target.value as 'daily' | 'weekly')
}
value={recurrence}
>
<option value="daily"></option>
<option value="weekly"></option>
</select>
{recurrence === 'weekly' && (
<select
aria-label="心跳星期"
onChange={(event) => setWeekday(Number(event.target.value))}
value={weekday}
>
<option value={1}></option>
<option value={2}></option>
<option value={3}></option>
<option value={4}></option>
<option value={5}></option>
<option value={6}></option>
<option value={0}></option>
</select>
)}
<input
aria-label="心跳时间"
onChange={(event) => setTime(event.target.value)}
type="time"
value={time}
/>
<button
aria-label="启用智能心跳"
className="primary-button"
disabled={!time || pendingAction !== undefined}
onClick={() =>
void runAction('create', () =>
onCreate({
name: '智能成长回顾',
timezone:
Intl.DateTimeFormat().resolvedOptions().timeZone ||
'UTC',
recurrence:
recurrence === 'daily'
? {
type: 'daily',
localTime: time
}
: {
type: 'weekly',
localTime: time,
weekday
},
enabled: true,
lookbackHours:
recurrence === 'daily' ? 48 : 24 * 14,
retentionDays: 90
})
)
}
type="button"
>
{pendingAction === 'create' ? '启用中…' : '启用智能心跳'}
</button>
</div>
{error && (
<p className="heartbeat-settings__error" role="alert">
{error}
</p>
)}
{heartbeats.length === 0 ? (
<p className="heartbeat-settings__empty">
</p>
) : (
<div className="heartbeat-settings__list">
{heartbeats.map((heartbeat) => (
<article
className="heartbeat-settings__item"
key={heartbeat.id}
>
<span>
<strong>{heartbeat.name}</strong>
<small>
{heartbeat.enabled ? '运行中' : '已暂停'} · {' '}
{new Date(heartbeat.nextRunAt).toLocaleString('zh-CN')}
{heartbeat.lastStatus
? ` · 上次 ${heartbeatStatusLabels[heartbeat.lastStatus]}`
: ''}
</small>
</span>
<div className="heartbeat-settings__actions">
<button
aria-label={`${
heartbeat.enabled ? '暂停' : '恢复'
} ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(
`pause:${heartbeat.id}`,
() =>
onSetPaused(
heartbeat.id,
heartbeat.enabled
)
)
}
type="button"
>
{heartbeat.enabled ? '暂停' : '恢复'}
</button>
<button
aria-label={`立即心跳 ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(`run:${heartbeat.id}`, () =>
onRunNow(heartbeat.id)
)
}
type="button"
>
</button>
{confirmingRemoveId === heartbeat.id ? (
<>
<button
aria-label={`确认删除 ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() =>
void runAction(
`remove:${heartbeat.id}`,
async () => {
await onRemove(heartbeat.id)
setConfirmingRemoveId(undefined)
}
)
}
type="button"
>
</button>
<button
aria-label={`取消删除 ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() => setConfirmingRemoveId(undefined)}
type="button"
>
</button>
</>
) : (
<button
aria-label={`删除 ${heartbeat.name}`}
disabled={pendingAction !== undefined}
onClick={() =>
setConfirmingRemoveId(heartbeat.id)
}
type="button"
>
</button>
)}
</div>
</article>
))}
</div>
)}
</div>
)
}
@@ -36,6 +36,7 @@ function createProps(
libraryId: library.id,
name: '产品手册',
kind: 'directory',
location: 'D:\\Private\\产品手册',
status: 'ready',
documentCount: 1,
lastSyncedAt: '2026-07-30T08:00:00.000Z'
@@ -47,6 +48,7 @@ function createProps(
libraryId: library.id,
sourceId: 'source-1',
name: '架构说明.md',
path: 'D:\\Private\\架构说明.md',
status: 'ready',
indexProgress: 100,
chunkCount: 12,
@@ -182,6 +184,153 @@ describe('KnowledgeWorkspace', () => {
expect(screen.getByText('架构说明.md')).toBeInTheDocument()
})
it('renders and filters graph nodes with their relationships', () => {
render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
expect(
screen.getByRole('button', { name: '实体 GoodBuddy' })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '实体 Electron' })
).toBeInTheDocument()
expect(screen.getByText('使用')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('搜索图谱实体'), {
target: { value: 'Electron' }
})
expect(
screen.queryByRole('button', { name: '实体 GoodBuddy' })
).not.toBeInTheDocument()
expect(screen.queryByText('使用')).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('搜索图谱实体'), {
target: { value: '' }
})
fireEvent.change(screen.getByLabelText('筛选实体类型'), {
target: { value: '产品' }
})
expect(
screen.getByRole('button', { name: '实体 GoodBuddy' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: '实体 Electron' })
).not.toBeInTheDocument()
})
it('provides responsive workspace and graph layout hooks', () => {
render(<KnowledgeWorkspace {...createProps()} />)
const workspace = screen.getByLabelText('知识工作区')
expect(workspace).toHaveClass('knowledge-workspace')
expect(workspace.querySelector('aside')).toHaveClass(
'knowledge-workspace__sidebar'
)
expect(workspace.querySelector('main')).toHaveClass(
'knowledge-workspace__main'
)
expect(screen.getByLabelText('搜索文档').closest('label')).toHaveClass(
'knowledge-documents__search'
)
expect(screen.getByText('本地文件 · 架构说明.md')).toBeInTheDocument()
expect(screen.queryByText('D:\\Private\\架构说明.md')).not
.toBeInTheDocument()
expect(screen.queryByTitle('D:\\Private\\架构说明.md')).not
.toBeInTheDocument()
expect(screen.queryByTitle('D:\\Private\\产品手册')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
fireEvent.click(screen.getByRole('button', { name: '实体 GoodBuddy' }))
expect(screen.getByLabelText('知识图谱画布').parentElement).toHaveClass(
'knowledge-graph--with-details'
)
expect(screen.getByLabelText('实体详情')).toHaveClass(
'knowledge-graph__detail'
)
})
it('supports graph zoom, keyboard selection, and related-node navigation', () => {
render(<KnowledgeWorkspace {...createProps()} />)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
const graph = screen.getByLabelText('实体关系图')
expect(graph).toHaveAttribute('viewBox', '0 0 900 560')
fireEvent.click(screen.getByRole('button', { name: '放大图谱' }))
expect(screen.getByText('115%')).toBeInTheDocument()
expect(graph.getAttribute('viewBox')).not.toBe('0 0 900 560')
fireEvent.keyDown(
screen.getByRole('button', { name: '实体 GoodBuddy' }),
{ key: 'Enter' }
)
expect(screen.getByLabelText('实体详情')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '查看 Electron' }))
expect(
screen.getByRole('heading', { name: 'Electron' })
).toBeInTheDocument()
})
it('creates relationships, merges entities, and opens graph evidence', async () => {
const onCreateRelation = vi.fn()
const onMergeEntities = vi.fn()
const onOpenEvidence = vi.fn()
render(
<KnowledgeWorkspace
{...createProps({
onCreateRelation,
onMergeEntities,
onOpenEvidence
})}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
fireEvent.click(screen.getByRole('button', { name: '实体 GoodBuddy' }))
fireEvent.click(
screen.getByRole('button', { name: /\.md/u })
)
expect(onOpenEvidence).toHaveBeenCalledWith(
expect.objectContaining({ id: 'evidence-1' })
)
fireEvent.click(screen.getByRole('button', { name: '新增' }))
fireEvent.change(screen.getByLabelText('关系类型'), {
target: { value: '依赖' }
})
fireEvent.change(screen.getByLabelText('说明'), {
target: { value: '桌面运行基础' }
})
fireEvent.click(screen.getByRole('button', { name: '新增关系' }))
await waitFor(() =>
expect(onCreateRelation).toHaveBeenCalledWith({
sourceId: 'entity-1',
targetId: 'entity-2',
type: '依赖',
description: '桌面运行基础'
})
)
fireEvent.change(screen.getByLabelText('选择合并目标'), {
target: { value: 'entity-2' }
})
fireEvent.click(screen.getByRole('button', { name: '合并到目标实体' }))
expect(onMergeEntities).toHaveBeenCalledWith('entity-1', 'entity-2')
})
it('renders an explicit empty graph state', () => {
render(
<KnowledgeWorkspace
{...createProps({ graphNodes: [], graphRelations: [] })}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '知识图谱' }))
expect(
screen.getByText('当前知识库尚未生成实体关系。')
).toBeInTheDocument()
})
it('confirms that deleting a managed library removes managed copies', async () => {
const onDeleteLibrary = vi.fn()
render(
+61 -79
View File
@@ -241,8 +241,6 @@ const documentStatusLabels: Record<KnowledgeDocumentStatus, string> = {
const styles = {
workspace: {
display: 'grid',
gridTemplateColumns: '260px minmax(0, 1fr)',
minHeight: 620,
overflow: 'hidden',
border: '1px solid #d9d9d9',
borderRadius: 8,
@@ -254,9 +252,7 @@ const styles = {
display: 'flex',
flexDirection: 'column' as const,
gap: 16,
padding: 18,
background: '#fafafa',
borderRight: '1px solid #f0f0f0'
background: '#fafafa'
},
surface: {
border: '1px solid #d9d9d9',
@@ -346,6 +342,19 @@ function formatSize(size: number | undefined): string {
return `${(value / 1024 / 1024).toFixed(1)} MB`
}
function formatDocumentLocation(value: string): string {
try {
const url = new URL(value)
if (url.protocol === 'http:' || url.protocol === 'https:') {
return `${url.origin}${url.pathname}`
}
} catch {
// Local paths are intentionally reduced below.
}
const filename = value.split(/[\\/]/u).filter(Boolean).at(-1)
return filename ? `本地文件 · ${filename}` : '本地文件'
}
function toErrorMessage(reason: unknown): string {
return reason instanceof Error && reason.message
? reason.message
@@ -747,16 +756,10 @@ function DocumentsView({
}, [documents, query])
return (
<div style={{ display: 'grid', gap: 18 }}>
<div className="knowledge-documents" style={{ display: 'grid', gap: 18 }}>
<section aria-labelledby="sources-title">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap'
}}
className="knowledge-documents__section-heading"
>
<div>
<h3 id="sources-title" style={{ margin: 0 }}>
@@ -766,7 +769,7 @@ function DocumentsView({
</p>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<div className="knowledge-documents__import-actions">
<button
onClick={() => fileInputRef.current?.click()}
style={styles.button}
@@ -843,6 +846,7 @@ function DocumentsView({
{urlOpen && (
<form
aria-label="导入 URL"
className="knowledge-documents__url-form"
onSubmit={(event) => {
event.preventDefault()
const value = url.trim()
@@ -878,8 +882,6 @@ function DocumentsView({
}}
style={{
...styles.surface,
display: 'flex',
gap: 8,
marginTop: 12,
padding: 12
}}
@@ -968,11 +970,11 @@ function DocumentsView({
>
{sources.map((source) => (
<li
className="knowledge-source-row"
key={source.id}
style={{
...styles.surface,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: 12,
padding: 12
}}
@@ -999,7 +1001,7 @@ function DocumentsView({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
title={source.location ?? source.name}
title={source.name}
>
{source.name}
</strong>
@@ -1043,7 +1045,7 @@ function DocumentsView({
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<div className="knowledge-source-row__actions">
{source.status === 'syncing' ? (
<button
aria-label={`暂停 ${source.name}`}
@@ -1104,22 +1106,17 @@ function DocumentsView({
<section aria-labelledby="documents-title">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12
}}
className="knowledge-documents__section-heading"
>
<h3 id="documents-title" style={{ margin: 0 }}>
</h3>
<label
className="knowledge-documents__search"
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
width: 'min(300px, 50%)'
alignItems: 'center'
}}
>
<Search
@@ -1147,7 +1144,7 @@ function DocumentsView({
: '没有与搜索条件匹配的文档。'}
</div>
) : (
<div style={{ overflowX: 'auto', marginTop: 12 }}>
<div className="knowledge-documents__table-scroll">
<table
style={{
width: '100%',
@@ -1189,9 +1186,8 @@ function DocumentsView({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
title={document.path}
>
{document.path}
{formatDocumentLocation(document.path)}
</div>
)}
</td>
@@ -1538,18 +1534,15 @@ function GraphView({
return (
<div
style={{
display: 'grid',
gridTemplateColumns:
selectedNode || creatingEntity
? 'minmax(0, 1fr) 340px'
: '1fr',
gap: 14,
minHeight: 560
}}
className={
selectedNode || creatingEntity
? 'knowledge-graph knowledge-graph--with-details'
: 'knowledge-graph'
}
>
<section
aria-label="知识图谱画布"
className="knowledge-graph__canvas"
style={{
...styles.surface,
display: 'grid',
@@ -1558,16 +1551,12 @@ function GraphView({
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 10,
borderBottom: '1px solid #f0f0f0',
background: '#fafafa'
}}
className="knowledge-graph__toolbar"
>
<label style={{ position: 'relative', flex: 1 }}>
<label
className="knowledge-graph__search"
style={{ position: 'relative' }}
>
<Search
aria-hidden="true"
size={15}
@@ -1584,8 +1573,9 @@ function GraphView({
</label>
<select
aria-label="筛选实体类型"
className="knowledge-graph__filter"
onChange={(event) => setTypeFilter(event.currentTarget.value)}
style={{ ...styles.input, width: 150 }}
style={styles.input}
value={typeFilter}
>
<option value="all"></option>
@@ -1674,9 +1664,9 @@ function GraphView({
}}
ref={svgRef}
role="img"
className="knowledge-graph__svg"
style={{
width: '100%',
minHeight: 500,
background: '#fafafa',
touchAction: 'none'
}}
@@ -1795,11 +1785,11 @@ function GraphView({
{creatingEntity && (
<aside
aria-label="新增实体面板"
className="knowledge-graph__detail"
style={{
...styles.surface,
padding: 15,
overflowY: 'auto',
maxHeight: 620
overflowY: 'auto'
}}
>
<h3 style={{ marginTop: 0 }}></h3>
@@ -1816,11 +1806,11 @@ function GraphView({
{selectedNode && (
<aside
aria-label="实体详情"
className="knowledge-graph__detail"
style={{
...styles.surface,
padding: 15,
overflowY: 'auto',
maxHeight: 620
overflowY: 'auto'
}}
>
<div
@@ -2185,9 +2175,10 @@ export function KnowledgeWorkspace({
<section
aria-busy={loading}
aria-label="知识工作区"
className="knowledge-workspace"
style={styles.workspace}
>
<aside style={styles.sidebar}>
<aside className="knowledge-workspace__sidebar" style={styles.sidebar}>
<div
style={{
display: 'flex',
@@ -2221,7 +2212,11 @@ export function KnowledgeWorkspace({
<Plus aria-hidden="true" size={16} />
</button>
<nav aria-label="知识库列表" style={{ flex: 1 }}>
<nav
aria-label="知识库列表"
className="knowledge-workspace__library-nav"
style={{ flex: 1 }}
>
{libraries.length === 0 ? (
<div
style={{
@@ -2309,7 +2304,10 @@ export function KnowledgeWorkspace({
</nav>
</aside>
<main style={{ minWidth: 0, background: '#ffffff' }}>
<main
className="knowledge-workspace__main"
style={{ minWidth: 0, background: '#ffffff' }}
>
{creating ? (
<CreateLibraryWizard
onCancel={() => setCreating(false)}
@@ -2352,14 +2350,7 @@ export function KnowledgeWorkspace({
) : (
<>
<header
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
padding: '20px 22px 15px',
borderBottom: '1px solid #f0f0f0'
}}
className="knowledge-workspace__header"
>
<div>
<span
@@ -2386,13 +2377,7 @@ export function KnowledgeWorkspace({
</p>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
justifyContent: 'flex-end'
}}
className="knowledge-workspace__header-actions"
>
<label
style={{
@@ -2418,6 +2403,7 @@ export function KnowledgeWorkspace({
{selectedLibrary.graphEnabled && (
<select
aria-label="知识图谱抽取策略"
className="knowledge-workspace__strategy"
onChange={(event) =>
void onUpdateLibrary(selectedLibrary.id, {
graphEnabled: true,
@@ -2426,7 +2412,7 @@ export function KnowledgeWorkspace({
.value as KnowledgeGraphStrategy
})
}
style={{ ...styles.input, width: 170 }}
style={styles.input}
value={selectedLibrary.graphStrategy}
>
{Object.entries(strategyLabels).map(([value, label]) => (
@@ -2449,12 +2435,8 @@ export function KnowledgeWorkspace({
</header>
<div
aria-label="知识库视图"
className="knowledge-workspace__tabs"
role="tablist"
style={{
display: 'flex',
gap: 6,
padding: '12px 22px 0'
}}
>
<button
aria-selected={visibleTab === 'documents'}
@@ -2499,7 +2481,7 @@ export function KnowledgeWorkspace({
</button>
)}
</div>
<div style={{ padding: 22 }}>
<div className="knowledge-workspace__body">
{visibleTab === 'documents' ? (
<DocumentsView
documents={libraryDocuments}
@@ -0,0 +1,54 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { MarkdownRenderer } from './MarkdownRenderer'
describe('MarkdownRenderer', () => {
afterEach(cleanup)
it('renders CommonMark and GitHub Flavored Markdown', () => {
render(
<MarkdownRenderer>{`# 标题
- [x] 已完成
| 名称 | 数量 |
| --- | ---: |
| Token | 42 |
\`\`\`ts
const ready = true
\`\`\``}</MarkdownRenderer>
)
expect(
screen.getByRole('heading', { name: '标题', level: 1 })
).toBeInTheDocument()
expect(screen.getByRole('checkbox')).toBeChecked()
expect(screen.getByRole('table')).toBeInTheDocument()
expect(screen.getByText('const ready = true')).toBeInTheDocument()
})
it('opens safe links externally and does not render raw HTML', () => {
const { container } = render(
<MarkdownRenderer>{`[Factory](https://factory.ai)
[不安全链接](javascript:alert(1))
<script>window.bad = true</script>`}</MarkdownRenderer>
)
expect(screen.getByRole('link', { name: 'Factory' })).toHaveAttribute(
'rel',
'noopener noreferrer'
)
expect(screen.getByRole('link', { name: 'Factory' })).toHaveAttribute(
'target',
'_blank'
)
expect(
screen.getByText('不安全链接').closest('a')?.getAttribute('href') ??
''
).not.toMatch(/^javascript:/u)
expect(container.querySelector('script')).not.toBeInTheDocument()
})
})
+32
View File
@@ -0,0 +1,32 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { Components } from 'react-markdown'
const components: Components = {
a: ({ children, node, ...properties }) => {
void node
return (
<a {...properties} rel="noopener noreferrer" target="_blank">
{children}
</a>
)
}
}
type MarkdownRendererProps = {
children: string
}
export function MarkdownRenderer({
children
}: MarkdownRendererProps): React.JSX.Element {
return (
<ReactMarkdown
components={components}
remarkPlugins={[remarkGfm]}
skipHtml
>
{children}
</ReactMarkdown>
)
}
+160 -119
View File
@@ -1,5 +1,5 @@
import { Archive, FolderOpen, Plus, X } from 'lucide-react'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type {
AssistantProject,
ProjectCreateInput,
@@ -9,15 +9,13 @@ import type {
type ProjectSwitcherProps = {
projects: AssistantProject[]
activeProjectId: string
workMode: WorkMode
onArchive: (projectId: string) => Promise<void>
onCreate: (input: ProjectCreateInput) => Promise<AssistantProject>
onSelect: (projectId: string) => void
onSelectRoot: () => Promise<string | undefined>
onWorkModeChange: (mode: WorkMode) => void
}
const workModeLabels: Record<WorkMode, string> = {
export const workModeLabels: Record<WorkMode, string> = {
ask: 'Ask · 只读问答',
plan: 'Plan · 先审计划',
execute: 'Execute · 受控执行'
@@ -26,16 +24,17 @@ const workModeLabels: Record<WorkMode, string> = {
export function ProjectSwitcher({
projects,
activeProjectId,
workMode,
onArchive,
onCreate,
onSelect,
onSelectRoot,
onWorkModeChange
onSelectRoot
}: ProjectSwitcherProps): React.JSX.Element {
const [creating, setCreating] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string>()
const createButtonRef = useRef<HTMLButtonElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const restoreCreateButtonFocus = useRef(false)
const [draft, setDraft] = useState<ProjectCreateInput>({
name: '',
description: '',
@@ -43,6 +42,46 @@ export function ProjectSwitcher({
defaultWorkMode: 'ask'
})
useEffect(() => {
if (!creating) {
if (restoreCreateButtonFocus.current) {
createButtonRef.current?.focus()
restoreCreateButtonFocus.current = false
}
return
}
restoreCreateButtonFocus.current = true
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && !saving) {
setCreating(false)
return
}
if (event.key !== 'Tab') {
return
}
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])'
)
if (!focusable?.length) {
return
}
const first = focusable[0]!
const last = focusable[focusable.length - 1]!
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (
!event.shiftKey &&
document.activeElement === last
) {
event.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [creating, saving])
const create = async (): Promise<void> => {
setSaving(true)
setError(undefined)
@@ -81,129 +120,131 @@ export function ProjectSwitcher({
aria-label="新建项目"
className="icon-button"
onClick={() => setCreating(true)}
ref={createButtonRef}
type="button"
>
<Plus size={15} />
</button>
</div>
<select
aria-label="工作模式"
className="project-switcher__mode"
onChange={(event) =>
onWorkModeChange(event.target.value as WorkMode)
}
value={workMode}
>
{Object.entries(workModeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
{creating && (
<div className="project-create-card">
<header>
<strong></strong>
<button
aria-label="关闭新建项目"
className="icon-button"
onClick={() => setCreating(false)}
type="button"
>
<X size={14} />
</button>
</header>
<label>
<span></span>
<input
maxLength={120}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value
}))
}
value={draft.name}
/>
</label>
<label>
<span></span>
<textarea
maxLength={2_000}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value
}))
}
rows={3}
value={draft.description}
/>
</label>
<label>
<span></span>
<div className="project-create-card__path">
<input readOnly value={draft.rootPath} />
<div
className="project-create-backdrop"
onMouseDown={(event) => {
if (event.currentTarget === event.target && !saving) {
setCreating(false)
}
}}
>
<div
aria-labelledby="project-create-title"
aria-modal="true"
className="project-create-card"
ref={dialogRef}
role="dialog"
>
<header>
<strong id="project-create-title"></strong>
<button
aria-label="选择项目根目录"
className="secondary-button"
onClick={() => {
void onSelectRoot().then((rootPath) => {
if (rootPath) {
setDraft((current) => ({
...current,
rootPath
}))
}
})
}}
aria-label="关闭新建项目"
className="icon-button"
onClick={() => setCreating(false)}
type="button"
>
<FolderOpen size={14} />
<X size={14} />
</button>
</header>
<label>
<span></span>
<input
autoFocus
maxLength={120}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value
}))
}
value={draft.name}
/>
</label>
<label>
<span></span>
<textarea
maxLength={2_000}
onChange={(event) =>
setDraft((current) => ({
...current,
description: event.target.value
}))
}
rows={3}
value={draft.description}
/>
</label>
<label>
<span></span>
<div className="project-create-card__path">
<input readOnly value={draft.rootPath} />
<button
aria-label="选择项目根目录"
className="secondary-button"
onClick={() => {
void onSelectRoot().then((rootPath) => {
if (rootPath) {
setDraft((current) => ({
...current,
rootPath
}))
}
})
}}
type="button"
>
<FolderOpen size={14} />
</button>
</div>
</label>
<label>
<span></span>
<select
onChange={(event) =>
setDraft((current) => ({
...current,
defaultWorkMode: event.target.value as WorkMode
}))
}
value={draft.defaultWorkMode}
>
{Object.entries(workModeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{error && <p className="project-create-card__error">{error}</p>}
<div className="project-create-card__actions">
{projects.length > 1 && activeProjectId && (
<button
className="secondary-button"
onClick={() => {
void onArchive(activeProjectId)
}}
type="button"
>
<Archive size={13} />
</button>
)}
<button
className="primary-button"
disabled={saving || !draft.name.trim()}
onClick={() => void create()}
type="button"
>
{saving ? '创建中' : '创建'}
</button>
</div>
</label>
<label>
<span></span>
<select
onChange={(event) =>
setDraft((current) => ({
...current,
defaultWorkMode: event.target.value as WorkMode
}))
}
value={draft.defaultWorkMode}
>
{Object.entries(workModeLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{error && <p className="project-create-card__error">{error}</p>}
<div className="project-create-card__actions">
{projects.length > 1 && activeProjectId && (
<button
className="secondary-button"
onClick={() => {
void onArchive(activeProjectId)
}}
type="button"
>
<Archive size={13} />
</button>
)}
<button
className="primary-button"
disabled={saving || !draft.name.trim()}
onClick={() => void create()}
type="button"
>
{saving ? '创建中' : '创建'}
</button>
</div>
</div>
)}
+129 -19
View File
@@ -14,21 +14,24 @@ import {
XCircle
} from 'lucide-react'
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type {
AssistantMemory,
AssistantSchedule,
AssistantHeartbeatConfig,
AssistantHeartbeatEntry,
HeartbeatCreateInput,
ScheduleCreateInput,
AssistantTask,
WorkspaceChanges
} from '../../shared/assistant-contracts'
import { MarkdownRenderer } from './MarkdownRenderer'
import type {
ApprovalDecision,
ContextAttachment,
KnowledgeLibrary
} from '../../shared/contracts'
import type { ActivityRecord } from './activity-store'
import { HeartbeatSettings } from './HeartbeatSettings'
export type AssistantSidebarTab =
| 'tasks'
@@ -65,17 +68,32 @@ type RightAssistantSidebarProps = {
approvals: PendingSidebarApproval[]
memories: AssistantMemory[]
schedules: AssistantSchedule[]
heartbeats: AssistantHeartbeatConfig[]
heartbeatEntries: AssistantHeartbeatEntry[]
workspaceChanges?: WorkspaceChanges
onClose: () => void
onOpenHeartbeat: () => void
onOpenConversation: (conversationId: string) => void
onImportArtifacts: () => Promise<void>
onLoadArtifact: (artifactId: string) => Promise<void>
onRemoveAttachment: (attachmentId: string) => void
onCreateMemory: (content: string) => Promise<void>
onCreateSchedule: (input: ScheduleCreateInput) => Promise<void>
onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise<void>
onSetHeartbeatPaused: (
heartbeatId: string,
paused: boolean
) => Promise<void>
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
onRunHeartbeat: (heartbeatId: string) => Promise<void>
onRemoveSchedule: (scheduleId: string) => Promise<void>
onRunSchedule: (scheduleId: string) => Promise<void>
onRefreshChanges: () => Promise<void>
onRemoveMemory: (memoryId: string) => Promise<void>
onSetMemoryStatus: (
memoryId: string,
status: AssistantMemory['status']
) => Promise<void>
onRespondApproval: (
approval: PendingSidebarApproval,
decision: ApprovalDecision
@@ -112,17 +130,26 @@ export function RightAssistantSidebar({
approvals,
memories,
schedules,
heartbeats,
heartbeatEntries,
workspaceChanges,
onClose,
onOpenHeartbeat,
onOpenConversation,
onImportArtifacts,
onLoadArtifact,
onRemoveAttachment,
onCreateMemory,
onCreateSchedule,
onCreateHeartbeat,
onSetHeartbeatPaused,
onRemoveHeartbeat,
onRunHeartbeat,
onRemoveSchedule,
onRunSchedule,
onRefreshChanges,
onRemoveMemory,
onSetMemoryStatus,
onRespondApproval,
onTabChange
}: RightAssistantSidebarProps): React.JSX.Element {
@@ -364,6 +391,14 @@ export function RightAssistantSidebar({
</div>
</article>
))}
<HeartbeatSettings
heartbeats={heartbeats}
onCreate={onCreateHeartbeat}
onRemove={onRemoveHeartbeat}
onRunNow={onRunHeartbeat}
onSetPaused={onSetHeartbeatPaused}
variant="sidebar"
/>
</section>
)}
@@ -451,15 +486,83 @@ export function RightAssistantSidebar({
className="assistant-sidebar__memory"
key={memory.id}
>
<span>{memory.content}</span>
<button
aria-label={`删除记忆 ${memory.content.slice(0, 24)}`}
className="icon-button"
onClick={() => void onRemoveMemory(memory.id)}
type="button"
>
<X size={13} />
</button>
<span>
{memory.content}
{memory.status === 'proposed' && (
<small></small>
)}
</span>
<div>
{memory.status === 'proposed' && (
<>
<button
onClick={() =>
void onSetMemoryStatus(
memory.id,
'confirmed'
)
}
type="button"
>
</button>
<button
onClick={() =>
void onSetMemoryStatus(
memory.id,
'rejected'
)
}
type="button"
>
</button>
</>
)}
<button
aria-label={`删除记忆 ${memory.content.slice(0, 24)}`}
className="icon-button"
onClick={() => void onRemoveMemory(memory.id)}
type="button"
>
<X size={13} />
</button>
</div>
</article>
))
)}
<h3>
<RefreshCw size={15} />
<button
aria-label="打开智能心跳中心"
className="icon-button"
onClick={onOpenHeartbeat}
type="button"
>
<ChevronRight size={14} />
</button>
</h3>
{heartbeatEntries.length === 0 ? (
<p className="assistant-sidebar__empty">
</p>
) : (
heartbeatEntries.slice(0, 10).map((entry) => (
<article
className="assistant-sidebar__schedule"
key={entry.id}
>
<span>
<strong>
{new Date(entry.createdAt).toLocaleString('zh-CN')}
</strong>
<small>
{entry.proposedMemoryIds.length} ·{' '}
{entry.followUpTaskIds.length}
</small>
</span>
<p>{entry.summary}</p>
</article>
))
)}
@@ -492,6 +595,7 @@ export function RightAssistantSidebar({
onClick={() => {
setSelectedArtifactId(artifact.id)
onTabChange('preview')
void onLoadArtifact(artifact.id)
}}
type="button"
>
@@ -579,13 +683,19 @@ export function RightAssistantSidebar({
<strong>{preview.title}</strong>
<small>{formatTime(preview.createdAt)}</small>
</header>
<div className="markdown-body">
<div className="markdown-body markdown-content">
{preview.mimeType.startsWith('image/') ? (
<img
alt={preview.title}
className="assistant-sidebar__image-preview"
src={preview.content}
/>
preview.content ? (
<img
alt={preview.title}
className="assistant-sidebar__image-preview"
src={preview.content}
/>
) : (
<p className="assistant-sidebar__empty">
</p>
)
) : preview.mimeType === 'text/html' ? (
<iframe
className="assistant-sidebar__web-preview"
@@ -596,9 +706,9 @@ export function RightAssistantSidebar({
) : preview.mimeType === 'application/json' ? (
<pre>{preview.content}</pre>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<MarkdownRenderer>
{preview.content}
</ReactMarkdown>
</MarkdownRenderer>
)}
</div>
</>
+261 -1
View File
@@ -18,6 +18,8 @@ const runtimeSettings: RuntimeSettings = {
provider: 'auto',
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
opencodeBaseUrl: '',
opencodeEmbedded: false,
opencodeBinaryPath: '',
@@ -25,6 +27,10 @@ const runtimeSettings: RuntimeSettings = {
continueBinaryPath: '',
continueConfigPath: '',
continueMode: 'chat',
runtimeSandboxMode: 'auto',
knowledgeEmbeddingEnabled: false,
knowledgeEmbeddingBaseUrl: 'http://127.0.0.1:11434',
knowledgeEmbeddingModel: 'nomic-embed-text',
workspacePath: 'C:\\Workspace',
apiKeyConfigured: false,
credentialSource: 'none',
@@ -34,6 +40,8 @@ const runtimeSettings: RuntimeSettings = {
name: '默认模型',
baseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
apiKeyConfigured: false,
credentialSource: 'none'
}
@@ -118,6 +126,13 @@ const setSkillEnabled = vi.fn(async (_skillId: string, enabled: boolean) => ({
enabled
}))
}))
const heartbeatSettingsProps = {
heartbeats: [],
onCreateHeartbeat: vi.fn(async () => {}),
onSetHeartbeatPaused: vi.fn(async () => {}),
onRemoveHeartbeat: vi.fn(async () => {}),
onRunHeartbeat: vi.fn(async () => {})
}
describe('SettingsPanel runtime files', () => {
beforeEach(() => {
@@ -135,6 +150,7 @@ describe('SettingsPanel runtime files', () => {
id: 'continue',
label: 'Continue',
available: true,
supportsToolExecution: true,
detail: 'Ready'
}))
},
@@ -162,6 +178,7 @@ describe('SettingsPanel runtime files', () => {
it('automatically detects runtimes and displays path, version, and detail', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
@@ -188,6 +205,7 @@ describe('SettingsPanel runtime files', () => {
it('selects, warns about, clears, and saves a custom binary', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
@@ -234,6 +252,7 @@ describe('SettingsPanel runtime files', () => {
it('adds model connections and assigns one to OpenCode', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
@@ -243,7 +262,9 @@ describe('SettingsPanel runtime files', () => {
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
await screen.findByDisplayValue('默认模型')
fireEvent.click(screen.getByRole('button', { name: '添加' }))
fireEvent.click(
screen.getByRole('button', { name: '添加自定义' })
)
const nameInputs = screen.getAllByLabelText('名称')
fireEvent.change(nameInputs[1]!, {
target: { value: 'OpenCode 独立模型' }
@@ -277,9 +298,248 @@ describe('SettingsPanel runtime files', () => {
)
})
it('adds the Ollama preset with OpenAI protocol and no authentication', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const preset = await screen.findByLabelText('模型预设')
fireEvent.change(preset, { target: { value: 'ollama' } })
fireEvent.click(
screen.getByRole('button', { name: '从预设添加' })
)
expect(
screen
.getAllByLabelText('名称')
.some((input) => (input as HTMLInputElement).value === 'Ollama(本机)')
).toBe(true)
expect(
screen.getByDisplayValue('http://127.0.0.1:11434/v1')
).toBeInTheDocument()
expect(
screen.getByLabelText('接口协议 Ollama(本机)')
).toHaveValue('openai-chat-completions')
expect(
screen.getByLabelText('认证方式 Ollama(本机)')
).toHaveValue('none')
expect(
screen.getByText('无需认证,不会发送 API Key')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
name: 'Ollama(本机)',
protocol: 'openai-chat-completions',
authentication: 'none',
apiKey: { action: 'keep' }
})
])
})
)
)
})
it('marks the BigToken gpt-image-2 preset as image generation', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '模型连接' }))
const preset = await screen.findByLabelText('模型预设')
fireEvent.change(preset, {
target: { value: 'bigtoken-gpt-image-2' }
})
fireEvent.click(
screen.getByRole('button', { name: '从预设添加' })
)
expect(
screen.getByLabelText('接口协议 BigToken GPT Image 2')
).toHaveValue('openai-images-generations')
expect(screen.getByText('图像生成', { selector: 'span' }))
.toBeInTheDocument()
const defaultConnections = screen.getAllByRole('radio')
fireEvent.click(defaultConnections.at(-1)!)
vi.mocked(window.goodbuddy.settings.testRuntime).mockResolvedValueOnce({
id: 'model',
label: 'gpt-image-2',
available: true,
supportsToolExecution: false,
detail: '图像接口将在发送提示词时实际验证',
capability: 'image-generation'
})
fireEvent.click(screen.getByRole('button', { name: '保存并测试' }))
await waitFor(() =>
expect(updateRuntime).toHaveBeenCalledWith(
expect.objectContaining({
modelProfiles: expect.arrayContaining([
expect.objectContaining({
name: 'BigToken GPT Image 2',
modelName: 'gpt-image-2',
protocol: 'openai-images-generations'
})
])
})
)
)
expect(
await screen.findByText('图像接口将在发送提示词时实际验证')
).toBeInTheDocument()
expect(screen.queryByText('连接成功:gpt-image-2'))
.not.toBeInTheDocument()
})
it('manages heartbeat automation from Settings', async () => {
const onCreateHeartbeat = vi.fn(async () => {})
const onSetHeartbeatPaused = vi.fn(async () => {})
const onRemoveHeartbeat = vi.fn(async () => {})
const onRunHeartbeat = vi.fn(async () => {})
render(
<SettingsPanel
{...heartbeatSettingsProps}
heartbeats={[
{
id: 'heartbeat-1',
name: '长期记忆回顾',
timezone: 'Asia/Shanghai',
recurrence: {
type: 'daily',
localTime: '09:00'
},
enabled: true,
lookbackHours: 48,
retentionDays: 90,
nextRunAt: '2026-08-02T01:00:00.000Z',
createdAt: '2026-08-01T01:00:00.000Z',
updatedAt: '2026-08-01T01:00:00.000Z'
}
]}
onCreateHeartbeat={onCreateHeartbeat}
onRemoveHeartbeat={onRemoveHeartbeat}
onRunHeartbeat={onRunHeartbeat}
onSetHeartbeatPaused={onSetHeartbeatPaused}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '自动化' }))
expect(
await screen.findByRole('heading', { name: '智能心跳' })
).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('心跳时间'), {
target: { value: '08:30' }
})
fireEvent.click(
screen.getByRole('button', { name: '启用智能心跳' })
)
await waitFor(() =>
expect(onCreateHeartbeat).toHaveBeenCalledWith(
expect.objectContaining({
recurrence: {
type: 'daily',
localTime: '08:30'
},
enabled: true
})
)
)
const pauseButton = screen.getByRole('button', {
name: '暂停 长期记忆回顾'
})
fireEvent.click(pauseButton)
await waitFor(() =>
expect(onSetHeartbeatPaused).toHaveBeenCalledWith(
'heartbeat-1',
true
)
)
await waitFor(() => expect(pauseButton).toBeEnabled())
const runButton = screen.getByRole('button', {
name: '立即心跳 长期记忆回顾'
})
fireEvent.click(runButton)
await waitFor(() =>
expect(onRunHeartbeat).toHaveBeenCalledWith('heartbeat-1')
)
await waitFor(() => expect(runButton).toBeEnabled())
fireEvent.click(
screen.getByRole('button', {
name: '删除 长期记忆回顾'
})
)
fireEvent.click(
screen.getByRole('button', {
name: '确认删除 长期记忆回顾'
})
)
await waitFor(() =>
expect(onRemoveHeartbeat).toHaveBeenCalledWith('heartbeat-1')
)
})
it('prevents duplicate heartbeat actions and reports failures', async () => {
let rejectCreate: (reason: Error) => void = () => {}
const onCreateHeartbeat = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectCreate = reject
})
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
onCreateHeartbeat={onCreateHeartbeat}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '自动化' }))
const createButton = screen.getByRole('button', {
name: '启用智能心跳'
})
fireEvent.click(createButton)
fireEvent.click(createButton)
expect(onCreateHeartbeat).toHaveBeenCalledOnce()
expect(createButton).toBeDisabled()
rejectCreate(new Error('创建心跳失败'))
expect(await screen.findByRole('alert')).toHaveTextContent(
'创建心跳失败'
)
expect(createButton).toBeEnabled()
})
it('shows Skills and MCP as first-class settings tabs', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
+392 -52
View File
@@ -9,6 +9,10 @@ import {
X
} from 'lucide-react'
import { useEffect, useState } from 'react'
import type {
AssistantHeartbeatConfig,
HeartbeatCreateInput
} from '../../shared/assistant-contracts'
import type {
AgentRuntimeDetection,
RuntimeFileSelectionKind,
@@ -17,10 +21,21 @@ import type {
RuntimeModelSource
} from '../../shared/contracts'
import { defaultRuntimeSettings } from '../../shared/contracts'
import {
modelProfilePresets,
type ModelProfilePreset
} from '../../shared/model-presets'
import { McpSettingsSection } from './McpSettingsSection'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import { HeartbeatSettings } from './HeartbeatSettings'
type SettingsTab = 'model' | 'runtime' | 'security' | 'skills' | 'mcp'
type SettingsTab =
| 'model'
| 'runtime'
| 'security'
| 'automation'
| 'skills'
| 'mcp'
type ModelProfileDraft = RuntimeSettings['modelProfiles'][number] & {
apiKey: string
clearApiKey: boolean
@@ -32,6 +47,14 @@ type SettingsPanelProps = {
onClose: () => void
onSaved: (settings: RuntimeSettings) => void
onClearLocalData: () => Promise<void>
heartbeats: AssistantHeartbeatConfig[]
onCreateHeartbeat: (input: HeartbeatCreateInput) => Promise<void>
onSetHeartbeatPaused: (
heartbeatId: string,
paused: boolean
) => Promise<void>
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
onRunHeartbeat: (heartbeatId: string) => Promise<void>
}
const credentialLabels: Record<
@@ -58,7 +81,12 @@ export function SettingsPanel({
presentation = 'modal',
onClose,
onSaved,
onClearLocalData
onClearLocalData,
heartbeats,
onCreateHeartbeat,
onSetHeartbeatPaused,
onRemoveHeartbeat,
onRunHeartbeat
}: SettingsPanelProps): React.JSX.Element | null {
const [settings, setSettings] = useState<RuntimeSettings>()
const [provider, setProvider] =
@@ -66,6 +94,9 @@ export function SettingsPanel({
defaultRuntimeSettings.provider
)
const [modelProfiles, setModelProfiles] = useState<ModelProfileDraft[]>([])
const [selectedPresetId, setSelectedPresetId] = useState<string>(
modelProfilePresets[0].id
)
const [defaultModelProfileId, setDefaultModelProfileId] = useState('')
const [opencodeModelSource, setOpencodeModelSource] =
useState<RuntimeModelSource>({ kind: 'platform' })
@@ -93,6 +124,16 @@ export function SettingsPanel({
useState<RuntimeSettingsInput['continueMode']>(
defaultRuntimeSettings.continueMode
)
const [runtimeSandboxMode, setRuntimeSandboxMode] =
useState<RuntimeSettingsInput['runtimeSandboxMode']>(
defaultRuntimeSettings.runtimeSandboxMode
)
const [knowledgeEmbeddingEnabled, setKnowledgeEmbeddingEnabled] =
useState<boolean>(defaultRuntimeSettings.knowledgeEmbeddingEnabled)
const [knowledgeEmbeddingBaseUrl, setKnowledgeEmbeddingBaseUrl] =
useState<string>(defaultRuntimeSettings.knowledgeEmbeddingBaseUrl)
const [knowledgeEmbeddingModel, setKnowledgeEmbeddingModel] =
useState<string>(defaultRuntimeSettings.knowledgeEmbeddingModel)
const [workspacePath, setWorkspacePath] = useState<string>(
defaultRuntimeSettings.workspacePath
)
@@ -138,6 +179,10 @@ export function SettingsPanel({
setContinueBinaryPath(value.continueBinaryPath)
setContinueConfigPath(value.continueConfigPath)
setContinueMode(value.continueMode)
setRuntimeSandboxMode(value.runtimeSandboxMode)
setKnowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
setKnowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setWorkspacePath(value.workspacePath)
setToolApproval(
value.toolApproval === 'policy' ? 'policy' : 'always'
@@ -189,6 +234,8 @@ export function SettingsPanel({
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
apiKey: profile.clearApiKey
? ({ action: 'clear' } as const)
: profile.apiKey.trim()
@@ -202,6 +249,8 @@ export function SettingsPanel({
provider,
modelBaseUrl: defaultProfile.baseUrl,
modelName: defaultProfile.modelName,
modelProtocol: defaultProfile.protocol,
modelAuthentication: defaultProfile.authentication,
opencodeBaseUrl,
opencodeEmbedded,
opencodeBinaryPath,
@@ -209,6 +258,10 @@ export function SettingsPanel({
continueBinaryPath,
continueConfigPath,
continueMode,
runtimeSandboxMode,
knowledgeEmbeddingEnabled,
knowledgeEmbeddingBaseUrl,
knowledgeEmbeddingModel,
workspacePath,
apiKey: profileInputs.find(
(profile) => profile.id === defaultProfile.id
@@ -229,6 +282,10 @@ export function SettingsPanel({
setContinueBinaryPath(value.continueBinaryPath)
setContinueConfigPath(value.continueConfigPath)
setContinueMode(value.continueMode)
setRuntimeSandboxMode(value.runtimeSandboxMode)
setKnowledgeEmbeddingEnabled(value.knowledgeEmbeddingEnabled)
setKnowledgeEmbeddingBaseUrl(value.knowledgeEmbeddingBaseUrl)
setKnowledgeEmbeddingModel(value.knowledgeEmbeddingModel)
setToolApproval(
value.toolApproval === 'policy' ? 'policy' : 'always'
)
@@ -256,7 +313,11 @@ export function SettingsPanel({
if (!status.available) {
throw new Error(status.detail)
}
setConnectionResult(`连接成功:${status.label}`)
setConnectionResult(
status.capability === 'image-generation'
? status.detail
: `连接成功:${status.label}`
)
} catch (reason) {
setError(
reason instanceof Error ? reason.message : 'Runtime 连接测试失败'
@@ -317,6 +378,8 @@ export function SettingsPanel({
name: `模型连接 ${profiles.length + 1}`,
baseUrl: defaultRuntimeSettings.modelBaseUrl,
modelName: defaultRuntimeSettings.modelName,
protocol: defaultRuntimeSettings.modelProtocol,
authentication: defaultRuntimeSettings.modelAuthentication,
apiKeyConfigured: false,
credentialSource: 'none',
apiKey: '',
@@ -328,6 +391,37 @@ export function SettingsPanel({
}
}
const addPresetProfile = (preset: ModelProfilePreset): void => {
const id = crypto.randomUUID()
setModelProfiles((profiles) => {
const usedNames = new Set(profiles.map((profile) => profile.name))
let name = preset.name
let suffix = 2
while (usedNames.has(name)) {
name = `${preset.name} ${suffix}`
suffix += 1
}
return [
...profiles,
{
id,
name,
baseUrl: preset.baseUrl,
modelName: preset.modelName,
protocol: preset.protocol,
authentication: preset.authentication,
apiKeyConfigured: false,
credentialSource: 'none',
apiKey: '',
clearApiKey: false
}
]
})
if (!defaultModelProfileId) {
setDefaultModelProfileId(id)
}
}
const removeModelProfile = (id: string): void => {
if (modelProfiles.length <= 1) {
setError('请至少保留一个模型连接')
@@ -357,6 +451,16 @@ export function SettingsPanel({
? { kind: 'platform' }
: { kind: 'profile', profileId: value }
const isOpenCodeCompatible = (
profile: ModelProfileDraft
): boolean =>
profile.protocol === 'anthropic-messages' &&
profile.authentication === 'api-key'
const isContinueCompatible = (
profile: ModelProfileDraft
): boolean => profile.protocol !== 'openai-images-generations'
const detectionSummary = (
value: AgentRuntimeDetection['opencode'] | undefined
): React.JSX.Element => (
@@ -398,7 +502,7 @@ export function SettingsPanel({
<p className="eyebrow">SETTINGS</p>
<h2 id="settings-title"></h2>
<p className="settings-panel__description">
Agent Runtime
Agent Runtime
</p>
</div>
<button
@@ -443,6 +547,16 @@ export function SettingsPanel({
<strong></strong>
<small></small>
</button>
<button
aria-label="自动化"
aria-selected={activeTab === 'automation'}
onClick={() => setActiveTab('automation')}
role="tab"
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-label="Skills"
aria-selected={activeTab === 'skills'}
@@ -569,15 +683,25 @@ export function SettingsPanel({
>
<option value="platform">使 OpenCode </option>
{modelProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
<option
disabled={!isOpenCodeCompatible(profile)}
key={profile.id}
value={profile.id}
>
{profile.name}
{isOpenCodeCompatible(profile)
? '(兼容)'
: '(不兼容)'}
</option>
))}
</select>
{opencodeModelSource.kind === 'profile' &&
opencodeBaseUrl && (
{opencodeModelSource.kind === 'profile' && (
<small>
GoodBuddy OpenCode
OpenCode API Key Anthropic
Messages
{opencodeBaseUrl
? ',且仅支持由 GoodBuddy 启动的本机 OpenCode。'
: '。'}
</small>
)}
</label>
@@ -705,11 +829,22 @@ export function SettingsPanel({
>
<option value="platform">使 Continue </option>
{modelProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
<option
disabled={!isContinueCompatible(profile)}
key={profile.id}
value={profile.id}
>
{profile.name}
{isContinueCompatible(profile)
? '(兼容)'
: '(不兼容)'}
</option>
))}
</select>
<small>
Continue Anthropic MessagesOpenAI Chat
Completions
</small>
</label>
<label className="field">
<span>Continue </span>
@@ -795,7 +930,10 @@ export function SettingsPanel({
<KeyRound size={17} />
<div>
<strong></strong>
<small> Anthropic Messages </small>
<small>
OpenAI Images Generations
</small>
</div>
<button
className="secondary-button"
@@ -803,7 +941,47 @@ export function SettingsPanel({
type="button"
>
<Plus size={14} />
</button>
</div>
<div className="runtime-note">
<label className="field">
<span></span>
<select
aria-label="模型预设"
onChange={(event) =>
setSelectedPresetId(event.target.value)
}
value={selectedPresetId}
>
{modelProfilePresets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</select>
<small>
{
modelProfilePresets.find(
(preset) => preset.id === selectedPresetId
)?.description
}
</small>
</label>
<button
className="secondary-button"
onClick={() => {
const preset = modelProfilePresets.find(
(candidate) => candidate.id === selectedPresetId
)
if (preset) {
addPresetProfile(preset)
}
}}
type="button"
>
<Plus size={14} />
</button>
</div>
{modelProfiles.map((profile) => {
@@ -823,6 +1001,11 @@ export function SettingsPanel({
/>
<span></span>
</label>
{profile.protocol === 'openai-images-generations' && (
<span className="model-capability-badge">
</span>
)}
<button
aria-label={`删除模型连接 ${profile.name}`}
className="icon-button"
@@ -870,49 +1053,136 @@ export function SettingsPanel({
/>
</label>
<label className="field">
<span>API Key</span>
<input
autoComplete="off"
disabled={
environmentManaged ||
!settings?.secureStorageAvailable
}
<span></span>
<select
aria-label={`接口协议 ${profile.name}`}
onChange={(event) =>
updateModelProfile(profile.id, {
apiKey: event.target.value,
clearApiKey: false
})
}
placeholder={
profile.apiKeyConfigured
? '已配置,留空保持不变'
: '输入 API Key'
}
type="password"
value={profile.apiKey}
/>
</label>
<div className="credential-state">
<LockKeyhole size={15} />
<span>
{credentialLabels[profile.credentialSource]}
</span>
{profile.credentialSource === 'encrypted' && (
<button
onClick={() =>
updateModelProfile(profile.id, {
apiKey: '',
clearApiKey: true
})
{
const protocol = event.target
.value as ModelProfileDraft['protocol']
updateModelProfile(profile.id, { protocol })
if (
protocol !== 'anthropic-messages' &&
opencodeModelSource.kind === 'profile' &&
opencodeModelSource.profileId === profile.id
) {
setOpencodeModelSource({ kind: 'platform' })
}
if (
protocol === 'openai-images-generations' &&
continueModelSource.kind === 'profile' &&
continueModelSource.profileId === profile.id
) {
setContinueModelSource({ kind: 'platform' })
}
}
type="button"
>
{profile.clearApiKey
? '保存后清除'
: '清除凭据'}
</button>
)}
</div>
}
value={profile.protocol}
>
<option value="anthropic-messages">
Anthropic Messages
</option>
<option value="openai-chat-completions">
OpenAI Chat Completions
</option>
<option value="openai-images-generations">
OpenAI Images Generations
</option>
</select>
</label>
<label className="field">
<span></span>
<select
aria-label={`认证方式 ${profile.name}`}
onChange={(event) => {
const authentication = event.target
.value as ModelProfileDraft['authentication']
updateModelProfile(profile.id, {
authentication,
apiKey: '',
clearApiKey:
authentication === 'none' &&
profile.apiKeyConfigured
})
if (
authentication !== 'api-key' &&
opencodeModelSource.kind === 'profile' &&
opencodeModelSource.profileId === profile.id
) {
setOpencodeModelSource({ kind: 'platform' })
}
}}
value={profile.authentication}
>
<option value="api-key">API Key</option>
<option value="none"></option>
</select>
</label>
{profile.authentication === 'api-key' ? (
<>
<label className="field">
<span>API Key</span>
<input
autoComplete="off"
disabled={
environmentManaged ||
!settings?.secureStorageAvailable
}
onChange={(event) =>
updateModelProfile(profile.id, {
apiKey: event.target.value,
clearApiKey: false
})
}
placeholder={
profile.apiKeyConfigured
? '已配置,留空保持不变'
: '输入 API Key'
}
type="password"
value={profile.apiKey}
/>
</label>
<div className="credential-state">
<LockKeyhole size={15} />
<span>
{credentialLabels[profile.credentialSource]}
</span>
{profile.credentialSource === 'encrypted' && (
<button
onClick={() =>
updateModelProfile(profile.id, {
apiKey: '',
clearApiKey: true
})
}
type="button"
>
{profile.clearApiKey
? '保存后清除'
: '清除凭据'}
</button>
)}
</div>
</>
) : (
<div className="credential-state">
<LockKeyhole size={15} />
<span> API Key</span>
</div>
)}
<small>
{profile.protocol === 'openai-images-generations'
? '图像生成'
: '文本对话'}{' '}
· Continue
{isContinueCompatible(profile) ? '兼容' : '不兼容'} ·
OpenCode
{isOpenCodeCompatible(profile)
? '兼容'
: '不兼容(仅支持 Anthropic Messages + API Key'}
</small>
</div>
)
})}
@@ -928,6 +1198,27 @@ export function SettingsPanel({
{activeTab === 'security' && (
<>
<label className="field">
<span>Runtime OS </span>
<select
aria-label="Runtime OS 沙箱"
value={runtimeSandboxMode}
onChange={(event) =>
setRuntimeSandboxMode(
event.target
.value as RuntimeSettingsInput['runtimeSandboxMode']
)
}
>
<option value="auto">Linux </option>
<option value="strict"></option>
<option value="off"></option>
</select>
<small>
bubblewrap Linux
OpenCode Runtime Continue
</small>
</label>
<label className="field">
<span>Agent </span>
<select
@@ -946,6 +1237,44 @@ 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>
@@ -997,6 +1326,17 @@ export function SettingsPanel({
</div>
</>
)}
{activeTab === 'automation' && (
<div className="settings-section">
<HeartbeatSettings
heartbeats={heartbeats}
onCreate={onCreateHeartbeat}
onRemove={onRemoveHeartbeat}
onRunNow={onRunHeartbeat}
onSetPaused={onSetHeartbeatPaused}
/>
</div>
)}
{activeTab === 'skills' && <SkillsSettingsSection />}
{activeTab === 'mcp' && <McpSettingsSection />}
</div>
+54
View File
@@ -4,7 +4,9 @@ import {
MAX_ACTIVITY_DETAIL_LENGTH,
MAX_ACTIVITY_RECORDS,
loadActivityRecords,
reconcileActivityRecords,
saveActivityRecords,
upsertActivityRecord,
type ActivityRecord
} from './activity-store'
@@ -77,4 +79,56 @@ describe('activity-store', () => {
false
)
})
it('upserts transitions for one call while preserving distinct calls', () => {
const first = {
...makeRecord(1),
callId: 'call-1',
status: 'running' as const
}
const updated = upsertActivityRecord([first], {
...makeRecord(2),
callId: 'call-1',
status: 'failed'
})
const withSecondCall = upsertActivityRecord(updated, {
...makeRecord(3),
callId: 'call-2',
status: 'completed'
})
expect(withSecondCall).toHaveLength(2)
expect(withSecondCall.find((record) => record.callId === 'call-1'))
.toMatchObject({
id: first.id,
createdAt: first.createdAt,
status: 'failed'
})
})
it('reconciles stale active records with durable task outcomes', () => {
const records: ActivityRecord[] = [
{ ...makeRecord(1), status: 'running' },
{
...makeRecord(2),
requestId: 'missing-task',
status: 'pending'
}
]
const reconciled = reconcileActivityRecords(records, [
{
id: 'request-1',
title: 'task',
instructions: 'task',
origin: 'user',
status: 'cancelled',
createdAt: new Date(0).toISOString()
}
])
expect(reconciled.map((record) => record.status)).toEqual([
'cancelled',
'interrupted'
])
})
})
+93 -1
View File
@@ -1,3 +1,5 @@
import type { AssistantTask } from '../../shared/assistant-contracts'
export const ACTIVITY_STORAGE_KEY = 'goodbuddy.activity-records.v1'
export const MAX_ACTIVITY_RECORDS = 500
export const MAX_ACTIVITY_DETAIL_LENGTH = 4_000
@@ -17,13 +19,16 @@ const activityStatuses = [
'running',
'completed',
'failed',
'denied'
'denied',
'cancelled',
'interrupted'
] as const
export type ActivityRecord = {
id: string
conversationId: string
requestId: string
callId?: string
kind: (typeof activityKinds)[number]
title: string
detail: string
@@ -61,6 +66,8 @@ function isActivityRecord(value: unknown): value is ActivityRecord {
isBoundedString(candidate.id, MAX_ID_LENGTH) &&
isBoundedString(candidate.conversationId, MAX_ID_LENGTH) &&
isBoundedString(candidate.requestId, MAX_ID_LENGTH) &&
(candidate.callId === undefined ||
isBoundedString(candidate.callId, MAX_ID_LENGTH)) &&
activityKinds.some((kind) => kind === candidate.kind) &&
isBoundedString(candidate.title, MAX_TITLE_LENGTH) &&
isBoundedString(
@@ -75,6 +82,91 @@ function isActivityRecord(value: unknown): value is ActivityRecord {
)
}
export function upsertActivityRecord(
records: readonly ActivityRecord[],
incoming: ActivityRecord
): ActivityRecord[] {
if (incoming.kind !== 'tool' || !incoming.callId) {
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
}
const existingIndex = records.findIndex(
(record) =>
record.kind === 'tool' &&
record.requestId === incoming.requestId &&
record.callId === incoming.callId
)
if (existingIndex < 0) {
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
}
const existing = records[existingIndex]!
return [
{
...incoming,
id: existing.id,
createdAt: existing.createdAt
},
...records.filter((_, index) => index !== existingIndex)
].slice(0, MAX_ACTIVITY_RECORDS)
}
function taskTerminalStatus(
task: AssistantTask
): ActivityRecord['status'] | undefined {
if (task.status === 'completed') {
return 'completed'
}
if (task.status === 'failed') {
return 'failed'
}
if (task.status === 'cancelled') {
return 'cancelled'
}
if (task.status === 'interrupted') {
return 'interrupted'
}
return undefined
}
export function reconcileActivityRecords(
records: readonly ActivityRecord[],
tasks: readonly AssistantTask[],
activeRequestIds: ReadonlySet<string> = new Set()
): ActivityRecord[] {
const tasksById = new Map(tasks.map((task) => [task.id, task]))
return records.map((record) => {
if (
activeRequestIds.has(record.requestId) ||
(record.status !== 'pending' && record.status !== 'running')
) {
return record
}
const task = tasksById.get(record.requestId)
const terminalStatus = task
? taskTerminalStatus(task)
: 'interrupted'
if (!terminalStatus) {
return record
}
return {
...record,
status:
terminalStatus === 'completed' &&
(record.kind === 'tool' || record.kind === 'approval')
? 'interrupted'
: terminalStatus,
detail:
terminalStatus === 'interrupted'
? `${record.detail}\n应用重启时此活动尚未结束。`.slice(
0,
MAX_ACTIVITY_DETAIL_LENGTH
)
: record.detail
}
})
}
/**
* Loads only records matching the persisted activity schema. Corrupt storage,
* inaccessible storage and oversized payloads are treated as an empty history.
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
import {
getTokenUsageTotals,
groupTokenUsage
} from './token-usage'
function makeTokenUsage(): TokenUsageSummary {
return {
totals: {
callCount: 2,
input: 112,
output: 23,
cacheRead: 40,
cacheWrite: 10,
totalTokens: 999
},
records: [
{
requestId: 'request-1',
projectId: 'project-1',
projectName: '项目一',
conversationId: 'conversation-1',
conversationTitle: '会话一',
runtime: 'model',
provider: 'openai',
model: 'gpt-5',
callCount: 1,
input: 100,
output: 20,
cacheRead: 40,
cacheWrite: 10,
totalTokens: 999
},
{
requestId: 'request-2',
projectId: 'project-1',
projectName: '项目一',
conversationId: 'conversation-2',
conversationTitle: '会话二',
runtime: 'model',
provider: 'openai',
model: 'gpt-5',
callCount: 1,
input: 12,
output: 3,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 999
}
]
}
}
describe('token usage aggregation', () => {
it('groups records and keeps cache tokens out of total tokens', () => {
const usage = makeTokenUsage()
expect(getTokenUsageTotals(usage)).toEqual({
inputTokens: 112,
outputTokens: 23,
cacheReadTokens: 40,
cacheWriteTokens: 10,
totalTokens: 135
})
expect(groupTokenUsage(usage, 'project')).toEqual([
{
key: 'project:project-1:model:openai:gpt-5',
label: '项目一',
detail: 'gpt-5 · openai',
inputTokens: 112,
outputTokens: 23,
cacheReadTokens: 40,
cacheWriteTokens: 10,
totalTokens: 135
}
])
expect(groupTokenUsage(usage, 'conversation')).toHaveLength(2)
expect(groupTokenUsage(usage, 'model')).toEqual([
expect.objectContaining({
label: 'gpt-5',
detail: 'openai',
totalTokens: 135
})
])
})
it('uses fallback labels when grouping metadata is unavailable', () => {
const usage = makeTokenUsage()
usage.records = [
{
...usage.records[0]!,
projectId: undefined,
projectName: undefined,
conversationId: '',
conversationTitle: undefined,
provider: '',
model: ''
}
]
expect(groupTokenUsage(usage, 'project')[0]?.label).toBe(
'未归属项目'
)
expect(groupTokenUsage(usage, 'conversation')[0]?.label).toBe(
'已删除会话'
)
expect(groupTokenUsage(usage, 'model')[0]?.label).toBe('未知模型')
})
it('keeps project and conversation totals separated by model', () => {
const usage = makeTokenUsage()
usage.records.push({
...usage.records[0]!,
requestId: 'request-3',
provider: 'anthropic',
model: 'claude-sonnet',
input: 7,
output: 2
})
expect(groupTokenUsage(usage, 'project')).toHaveLength(2)
expect(groupTokenUsage(usage, 'conversation')).toHaveLength(3)
})
})
+123
View File
@@ -0,0 +1,123 @@
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
export type TokenUsageGroup = 'project' | 'conversation' | 'model'
export type TokenUsageTotals = {
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
totalTokens: number
}
export type TokenUsageGroupRow = TokenUsageTotals & {
key: string
label: string
detail?: string
}
type TokenUsageRecord = TokenUsageSummary['records'][number]
function usageNumbers(source: unknown): TokenUsageTotals {
const values = source as Record<string, unknown>
const read = (preferred: string, legacy: string): number => {
const value = values[preferred] ?? values[legacy]
return typeof value === 'number' && Number.isFinite(value)
? value
: 0
}
const inputTokens = read('inputTokens', 'input')
const outputTokens = read('outputTokens', 'output')
return {
inputTokens,
outputTokens,
cacheReadTokens: read('cacheReadTokens', 'cacheRead'),
cacheWriteTokens: read('cacheWriteTokens', 'cacheWrite'),
totalTokens: inputTokens + outputTokens
}
}
function groupIdentity(
record: TokenUsageRecord,
group: TokenUsageGroup
): Pick<TokenUsageGroupRow, 'key' | 'label' | 'detail'> {
const model = record.model.trim()
const provider = record.provider.trim()
const modelKey = `${provider}:${model}`
const modelLabel = model || '未知模型'
const modelDetail = provider
? `${modelLabel} · ${provider}`
: modelLabel
if (group === 'project') {
const projectKey = record.projectId
? `project:${record.projectId}`
: 'project:unassigned'
return {
key: `${projectKey}:model:${modelKey}`,
label: record.projectName?.trim() || '未归属项目',
detail: modelDetail
}
}
if (group === 'conversation') {
const conversationKey = record.conversationId
? `conversation:${record.conversationId}`
: 'conversation:deleted'
return {
key: `${conversationKey}:model:${modelKey}`,
label: record.conversationTitle?.trim() || '已删除会话',
detail: modelDetail
}
}
return {
key: `model:${modelKey}`,
label: modelLabel,
detail: provider || undefined
}
}
export function getTokenUsageTotals(
tokenUsage: TokenUsageSummary
): TokenUsageTotals {
return usageNumbers(tokenUsage.totals)
}
export function groupTokenUsage(
tokenUsage: TokenUsageSummary,
group: TokenUsageGroup
): TokenUsageGroupRow[] {
const rows = new Map<string, TokenUsageGroupRow>()
for (const record of tokenUsage.records) {
const identity = groupIdentity(record, group)
const usage = usageNumbers(record)
const existing = rows.get(identity.key)
if (existing) {
existing.inputTokens += usage.inputTokens
existing.outputTokens += usage.outputTokens
existing.cacheReadTokens += usage.cacheReadTokens
existing.cacheWriteTokens += usage.cacheWriteTokens
existing.totalTokens = existing.inputTokens + existing.outputTokens
if (
(existing.label === '未归属项目' ||
existing.label === '已删除会话') &&
identity.label !== existing.label
) {
existing.label = identity.label
}
continue
}
rows.set(identity.key, {
...identity,
...usage
})
}
return [...rows.values()]
}