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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
6ef1795b81
commit
b3fdf96962
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 Messages、OpenAI 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>
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1596
-25
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()]
|
||||
}
|
||||
Reference in New Issue
Block a user