feat: make heartbeat trigger incremental reviews and suggest from graph changes
Heartbeat no longer writes its own report; it triggers the shared incremental review and, per plan intervention, derives evidence-backed suggestions from the review's graph changes. Manual review is incremental by default with explicit reanalysis. The Supervisor tab is renamed Smart heartbeat with heartbeat plans, and the redundant inner plan frame is removed.
This commit is contained in:
@@ -952,7 +952,7 @@ describe('ActivityPanel', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: '系统任务' }))
|
||||
const stats = screen.getByLabelText('系统任务用量统计')
|
||||
expect(within(stats).getByText('18')).toBeInTheDocument()
|
||||
expect(screen.getByRole('row', { name: '自动监督 7 1 0 0 0% 8' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('row', { name: '智能心跳 7 1 0 0 0% 8' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '监督者' }))
|
||||
expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 9 1 0 0 0% 10' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '按模型' }))
|
||||
|
||||
@@ -730,7 +730,10 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
continueContext: vi.fn(async () => ({})),
|
||||
continue: vi.fn(async () => undefined),
|
||||
knowledgePreview: vi.fn(async () => ({})),
|
||||
knowledgeCommit: vi.fn(async () => ({}))
|
||||
knowledgeCommit: vi.fn(async () => ({})),
|
||||
suggestions: vi.fn(async () => []),
|
||||
suggestionAction: vi.fn(async () => { throw new Error('unused') }),
|
||||
retrySuggestions: vi.fn(async () => 0)
|
||||
},
|
||||
experts: {
|
||||
list: vi.fn(async () => []),
|
||||
@@ -13228,82 +13231,24 @@ describe("App", () => {
|
||||
await screen.findByRole("tab", { name: "工作回顾" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "回顾当前进展" }),
|
||||
screen.getByRole("button", { name: "回顾" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("excludes ignored heartbeat suggestions from the navigation badge", async () => {
|
||||
const heartbeatId = "00000000-0000-4000-8000-000000000701";
|
||||
const cancelledTaskId = "00000000-0000-4000-8000-000000000801";
|
||||
const pendingTaskId = "00000000-0000-4000-8000-000000000802";
|
||||
vi.mocked(api.heartbeats.list).mockResolvedValue([
|
||||
{
|
||||
id: heartbeatId,
|
||||
scope: { kind: "projects", projectIds: [projectId] },
|
||||
name: "每日回顾",
|
||||
timezone: "Asia/Shanghai",
|
||||
recurrence: { type: "daily", localTime: "09:00" },
|
||||
enabled: true,
|
||||
lookbackHours: 24,
|
||||
retentionDays: 30,
|
||||
nextRunAt: "2026-08-05T01:00:00.000Z",
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
updatedAt: "2026-08-01T00:00:00.000Z",
|
||||
},
|
||||
it("counts only pending supervisor suggestions in the navigation badge", async () => {
|
||||
vi.mocked(api.supervision.suggestions).mockResolvedValue([
|
||||
{ id: "00000000-0000-4000-8000-000000000901", resultId: null, heartbeatRunId: null, scope: { kind: "global" },
|
||||
kind: "open_item", title: "待处理建议", detail: "继续处理此建议。", sourceIds: [], entityId: null,
|
||||
relationId: null, taskId: null, status: "pending", createdAt: "2026-08-04T01:00:00.000Z" },
|
||||
]);
|
||||
vi.mocked(api.heartbeats.history).mockResolvedValue({
|
||||
runs: [],
|
||||
entries: [
|
||||
{
|
||||
id: "00000000-0000-4000-8000-000000000901",
|
||||
configId: heartbeatId,
|
||||
runId: "00000000-0000-4000-8000-000000000902",
|
||||
scheduledFor: "2026-08-04T01:00:00.000Z",
|
||||
summary: "建议处理两个后续行动。",
|
||||
highlights: [],
|
||||
proposedMemoryIds: [],
|
||||
followUpTaskIds: [cancelledTaskId, pendingTaskId],
|
||||
createdAt: "2026-08-04T01:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([
|
||||
{
|
||||
id: cancelledTaskId,
|
||||
projectId,
|
||||
title: "已忽略建议",
|
||||
instructions: "无需继续处理。",
|
||||
origin: "assistant",
|
||||
status: "cancelled",
|
||||
createdAt: "2026-08-04T01:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: pendingTaskId,
|
||||
projectId,
|
||||
title: "待处理建议",
|
||||
instructions: "继续处理此建议。",
|
||||
origin: "assistant",
|
||||
status: "paused",
|
||||
createdAt: "2026-08-04T01:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
render(<App />);
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText("1 条待处理建议"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("2 条待处理建议")).not.toBeInTheDocument();
|
||||
expect(await screen.findByLabelText("1 条待处理建议")).toBeInTheDocument();
|
||||
expect(api.supervision.suggestions).toHaveBeenCalledWith({ status: "pending", limit: 100 });
|
||||
} finally {
|
||||
cleanup();
|
||||
vi.mocked(api.heartbeats.list).mockResolvedValue([]);
|
||||
vi.mocked(api.heartbeats.history).mockResolvedValue({
|
||||
runs: [],
|
||||
entries: [],
|
||||
});
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([]);
|
||||
vi.mocked(api.supervision.suggestions).mockResolvedValue([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13409,7 +13354,7 @@ describe("App", () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("监督者加载失败")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "回顾当前进展" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "回顾" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("graph navigation opens the pinned sidebar result and reenters the keepalive heartbeat route", async () => {
|
||||
@@ -13477,7 +13422,7 @@ describe("App", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "监督者" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "自动监督" }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "智能心跳" }));
|
||||
expect(await screen.findAllByText("旧项目心跳")).not.toHaveLength(0);
|
||||
selectProjectOption(secondProject.name);
|
||||
fireEvent.click(screen.getByRole("button", { name: "监督者" }));
|
||||
@@ -13488,7 +13433,7 @@ describe("App", () => {
|
||||
expect(dialog.closest('.app-shell')).toBeNull();
|
||||
expect(document.querySelector('.app-shell')).toHaveProperty('inert', true);
|
||||
fireEvent.change(within(dialog).getByLabelText('计划名称'), { target: { value: 'Updated paused plan' } });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存自动监督计划' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '保存心跳计划' }));
|
||||
await waitFor(() => expect(api.heartbeats.update).toHaveBeenCalledWith('00000000-0000-4000-8000-000000000701', expect.objectContaining({
|
||||
name: 'Updated paused plan', enabled: false, scope: { kind: 'projects', projectIds: [projectId] }
|
||||
})));
|
||||
@@ -14493,19 +14438,18 @@ describe("App", () => {
|
||||
render(<App />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '监督者' }))
|
||||
fireEvent.click(await screen.findByRole('tab', { name: '设置' }))
|
||||
const report = await screen.findByLabelText('心跳报告超时(秒)')
|
||||
await waitFor(() => expect(report).toBeEnabled())
|
||||
expect(report).toHaveValue(240)
|
||||
fireEvent.change(report, { target: { value: '600' } })
|
||||
fireEvent.change(screen.getByLabelText('监督者整理超时(秒)'), { target: { value: '30' } })
|
||||
const organize = await screen.findByLabelText('监督者整理超时(秒)')
|
||||
await waitFor(() => expect(organize).toBeEnabled())
|
||||
expect(organize).toHaveValue(240)
|
||||
expect(screen.queryByLabelText('心跳报告超时(秒)')).not.toBeInTheDocument()
|
||||
fireEvent.change(organize, { target: { value: '30' } })
|
||||
expect(screen.getByLabelText('监督模型并发数')).toHaveValue(1)
|
||||
fireEvent.change(screen.getByLabelText('监督模型并发数'), { target: { value: '2' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存模型设置' }))
|
||||
await waitFor(() => expect(api.updates!.updateSettings).toHaveBeenLastCalledWith({ heartbeatReportTimeoutSeconds: 600, supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 2 }))
|
||||
await waitFor(() => expect(api.updates!.updateSettings).toHaveBeenLastCalledWith({ supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 2 }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '保存模型设置' })).toBeDisabled())
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作回顾' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
|
||||
expect(screen.getByLabelText('心跳报告超时(秒)')).toHaveValue(600)
|
||||
expect(screen.getByLabelText('监督者整理超时(秒)')).toHaveValue(30)
|
||||
expect(screen.getByLabelText('监督模型并发数')).toHaveValue(2)
|
||||
fireEvent.change(screen.getByLabelText(/每次读取来源条数/), { target: { value: '17' } })
|
||||
|
||||
@@ -3743,25 +3743,8 @@ function App(): React.JSX.Element {
|
||||
})),
|
||||
[activeProjectId, assistantArtifacts],
|
||||
);
|
||||
const pendingHeartbeatSuggestionCount = useMemo(() => {
|
||||
const memoryIds = new Set(
|
||||
heartbeatEntries.flatMap((entry) => entry.proposedMemoryIds),
|
||||
);
|
||||
const taskIds = new Set(
|
||||
heartbeatEntries.flatMap((entry) => entry.followUpTaskIds),
|
||||
);
|
||||
return (
|
||||
heartbeatMemories.filter(
|
||||
(memory) => memoryIds.has(memory.id) && memory.status === "proposed",
|
||||
).length +
|
||||
assistantTasks.filter(
|
||||
(task) =>
|
||||
taskIds.has(task.id) &&
|
||||
task.status !== "completed" &&
|
||||
task.status !== "cancelled",
|
||||
).length
|
||||
);
|
||||
}, [assistantTasks, heartbeatEntries, heartbeatMemories]);
|
||||
// Pending supervisor suggestions; earlier report proposals are migrated into them.
|
||||
const [pendingHeartbeatSuggestionCount, setPendingHeartbeatSuggestionCount] = useState(0);
|
||||
|
||||
const updateMessage = useCallback(
|
||||
(
|
||||
@@ -5780,11 +5763,15 @@ function App(): React.JSX.Element {
|
||||
window.goodbuddy.memory.list(),
|
||||
]);
|
||||
const history = await window.goodbuddy.heartbeats.history();
|
||||
// Main returns no suggestions while the supervisor is disabled.
|
||||
const suggestions = await window.goodbuddy.supervision?.suggestions?.({ status: "pending", limit: 100 })
|
||||
.catch(() => []) ?? [];
|
||||
return {
|
||||
configs,
|
||||
memories,
|
||||
runs: history.runs,
|
||||
entries: history.entries,
|
||||
pendingSuggestions: suggestions.length,
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -5798,6 +5785,7 @@ function App(): React.JSX.Element {
|
||||
setHeartbeatMemories(result.memories);
|
||||
setHeartbeatRuns(result.runs);
|
||||
setHeartbeatEntries(result.entries);
|
||||
setPendingHeartbeatSuggestionCount(result.pendingSuggestions);
|
||||
}, [loadHeartbeats]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -5823,6 +5811,7 @@ function App(): React.JSX.Element {
|
||||
setHeartbeatMemories(result.memories);
|
||||
setHeartbeatRuns(result.runs);
|
||||
setHeartbeatEntries(result.entries);
|
||||
setPendingHeartbeatSuggestionCount(result.pendingSuggestions);
|
||||
setHeartbeatLoadError(undefined);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
|
||||
@@ -280,7 +280,7 @@ describe('HeartbeatCenter', () => {
|
||||
const activity = vi.fn(async () => [{ id: 'run', kind: 'supervision', trigger: 'manual', status: 'completed', scope: old.scope, startedAt: old.createdAt, completedAt: old.createdAt, timeRange: old.timeRange, error: null, summary: 'Activity summary', resultId: 'old', heartbeatStatus: null, supervisionStatus: 'completed' }])
|
||||
vi.stubGlobal('goodbuddy', { supervision: { execution: async () => ({ active: false }), overview, graph, activity } })
|
||||
renderComponent(<HeartbeatCenter {...createProps()} />)
|
||||
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['工作回顾', '故事线图谱', '自动监督', '活动记录', '设置'])
|
||||
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['工作回顾', '故事线图谱', '智能心跳', '活动记录', '设置'])
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看回顾' }))
|
||||
expect(await screen.findByText('Older review body')).toBeVisible()
|
||||
@@ -293,7 +293,7 @@ describe('HeartbeatCenter', () => {
|
||||
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'])('edits separate supervision timeouts and preserves failed edits (%s)', async (language) => {
|
||||
it.each(['zh-CN', 'en-US'])('edits the supervision timeout and concurrency and preserves failed edits (%s)', async (language) => {
|
||||
await i18n.changeLanguage(language)
|
||||
const onUpdateApplicationSettings = vi.fn(async () => false)
|
||||
const applicationSettings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
|
||||
@@ -301,19 +301,18 @@ describe('HeartbeatCenter', () => {
|
||||
const view = render(<HeartbeatCenter {...props} />, false)
|
||||
fireEvent.click(screen.getByRole('tab', { name: i18n.t('supervisor.settings', { ns: 'heartbeat' }) }))
|
||||
const t = (key: string) => i18n.t(key, { ns: 'heartbeat' })
|
||||
const report = screen.getByLabelText(t('timeouts.report'))
|
||||
// The heartbeat no longer runs its own report model call.
|
||||
expect(screen.queryByLabelText(t('timeouts.report'))).not.toBeInTheDocument()
|
||||
const organize = screen.getByLabelText(t('timeouts.organize'))
|
||||
const concurrency = screen.getByLabelText(t('timeouts.concurrency'))
|
||||
expect(concurrency).toHaveValue(1)
|
||||
expect(screen.getByText(t('timeouts.concurrencyHelp'))).toBeVisible()
|
||||
expect(report).toHaveValue(240)
|
||||
expect(organize).toHaveValue(240)
|
||||
expect(screen.getByText(t('timeouts.transport'))).toBeVisible()
|
||||
for (const value of ['', '29', '601', '30.5']) {
|
||||
fireEvent.change(report, { target: { value } })
|
||||
fireEvent.change(organize, { target: { value } })
|
||||
expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled()
|
||||
}
|
||||
fireEvent.change(report, { target: { value: '600' } })
|
||||
fireEvent.change(organize, { target: { value: '30' } })
|
||||
for (const value of ['', '0', '5', '1.5']) {
|
||||
fireEvent.change(concurrency, { target: { value } })
|
||||
@@ -321,16 +320,14 @@ describe('HeartbeatCenter', () => {
|
||||
}
|
||||
fireEvent.change(concurrency, { target: { value: '4' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: t('timeouts.save') }))
|
||||
await waitFor(() => expect(onUpdateApplicationSettings).toHaveBeenCalledExactlyOnceWith({ heartbeatReportTimeoutSeconds: 600, supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 4 }))
|
||||
await waitFor(() => expect(onUpdateApplicationSettings).toHaveBeenCalledExactlyOnceWith({ supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 4 }))
|
||||
view.rerender(<HeartbeatCenter {...props} applicationSettingsError="Save failed" />)
|
||||
expect(report).toHaveValue(600)
|
||||
expect(organize).toHaveValue(30)
|
||||
expect(concurrency).toHaveValue(4)
|
||||
expect(screen.getByText('Save failed')).toBeVisible()
|
||||
view.rerender(<HeartbeatCenter {...props} applicationSettings={{ ...applicationSettings, heartbeatReportTimeoutSeconds: 600, supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 4 }} />)
|
||||
view.rerender(<HeartbeatCenter {...props} applicationSettings={{ ...applicationSettings, supervisorOrganizeTimeoutSeconds: 30, supervisorModelConcurrency: 4 }} />)
|
||||
expect(screen.getByRole('button', { name: t('timeouts.save') })).toBeDisabled()
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'])('opens complete automatic supervision settings without creating defaults (%s)', async (language) => {
|
||||
await i18n.changeLanguage(language)
|
||||
const props = createProps({ configs: [], runs: [], entries: [] })
|
||||
@@ -338,7 +335,8 @@ describe('HeartbeatCenter', () => {
|
||||
const t = (key: string) => i18n.t(key, { ns: 'heartbeat' })
|
||||
expect(screen.getByRole('tab', { name: t('supervisor.automatic') })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByText(t('settings.empty'))).toBeVisible()
|
||||
expect(screen.getByText(t('settings.scheduleHelp'))).toBeVisible()
|
||||
// One status line only: the empty state replaces the generic help.
|
||||
expect(screen.queryByText(t('settings.scheduleHelp'))).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: t('settings.createTitle') }))
|
||||
expect(screen.getByLabelText(t('settings.recurrenceAriaLabel'))).toHaveValue('daily')
|
||||
@@ -361,11 +359,11 @@ describe('HeartbeatCenter', () => {
|
||||
})
|
||||
const view = render(<HeartbeatCenter {...props} />)
|
||||
const t = (key: string) => i18n.t(key, { ns: 'heartbeat' })
|
||||
expect(screen.queryByLabelText(t('center.metrics.ariaLabel'))).not.toBeInTheDocument()
|
||||
for (const key of ['center.trend.title', 'center.suggestions.memoryTitle', 'center.suggestions.taskTitle', 'center.history.timelineTitle', 'center.history.auditTitle']) {
|
||||
for (const key of ['center.metrics.ariaLabel', 'center.trend.title', 'center.suggestions.memoryTitle', 'center.suggestions.taskTitle', 'center.history.timelineTitle', 'center.history.auditTitle']) {
|
||||
expect(screen.queryByRole('heading', { name: t(key) })).not.toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getByText(t('settings.scheduleHelp'))).toBeVisible()
|
||||
expect(screen.queryByLabelText(t('center.metrics.ariaLabel'))).not.toBeInTheDocument()
|
||||
expect(screen.getByText(t(state === 'unconfigured' ? 'settings.empty' : state === 'paused' ? 'settings.allPaused' : 'settings.scheduleHelp'))).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: t('settings.refreshPlans') })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: t('settings.createTitle') })).toBeEnabled()
|
||||
if (state !== 'unconfigured') {
|
||||
@@ -378,16 +376,12 @@ describe('HeartbeatCenter', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: t('settings.close') }))
|
||||
expect(props.onRunNow).not.toHaveBeenCalled()
|
||||
expect(props.onCreate).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(<HeartbeatCenter {...props} runs={[runs[1]!]} />)
|
||||
expect(screen.getByLabelText(t('center.metrics.ariaLabel'))).toBeVisible()
|
||||
expect(screen.getByText(runs[1]!.error!)).toBeVisible()
|
||||
view.rerender(<HeartbeatCenter {...props} entries={[entry]} />)
|
||||
// Earlier reports stay readable; statistics and the run audit are not shown.
|
||||
view.rerender(<HeartbeatCenter {...props} runs={[runs[1]!]} entries={[entry]} />)
|
||||
expect(screen.getByText(entry.summary)).toBeVisible()
|
||||
expect(screen.getByText(memory.content)).toBeVisible()
|
||||
expect(screen.getByText(task.title)).toBeVisible()
|
||||
expect(screen.queryByLabelText(t('center.metrics.ariaLabel'))).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(runs[1]!.error!)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['daily', 'weekly'])('saves the explicitly configured %s plan and rejects invalid windows', async (frequency) => {
|
||||
const props = createProps({ configs: [], runs: [], entries: [] })
|
||||
render(<HeartbeatCenter {...props} />, false)
|
||||
@@ -406,7 +400,7 @@ describe('HeartbeatCenter', () => {
|
||||
recurrence: frequency === 'weekly'
|
||||
? { type: 'weekly', localTime: '16:35', weekday: 5 }
|
||||
: { type: 'daily', localTime: '16:35' },
|
||||
lookbackHours: 168, retentionDays: 30
|
||||
lookbackHours: 168, retentionDays: 30, intervention: 'suggest'
|
||||
}))
|
||||
expect(props.onRunNow).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -419,22 +413,22 @@ describe('HeartbeatCenter', () => {
|
||||
const onUpdate = vi.fn(async () => {}).mockRejectedValueOnce(new Error('保存失败,请重试'))
|
||||
const props = createProps({ configs: [saved], onUpdate })
|
||||
render(<HeartbeatCenter {...props} />, false)
|
||||
expect(screen.getByText('所有自动监督计划已暂停,目前仅支持手动回顾。')).toBeVisible()
|
||||
expect(screen.getByText('所有心跳计划已暂停,目前仅支持手动回顾。')).toBeVisible()
|
||||
expect(screen.getByText('周三 17:45 · Pacific/Honolulu')).toBeVisible()
|
||||
expect(screen.getByText('回顾最近 48 小时 · 运行历史保留 90 天')).toBeVisible()
|
||||
expect(screen.getByText('回顾最近 48 小时 · 运行历史保留 90 天 · 生成建议')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: `编辑 ${config.name}` }))
|
||||
expect(screen.getByLabelText('监督频率')).toHaveValue('weekly')
|
||||
expect(screen.getByLabelText('监督星期')).toHaveValue('3')
|
||||
expect(screen.getByText(/计划时区:Pacific\/Honolulu/)).toBeVisible()
|
||||
fireEvent.change(screen.getByLabelText('计划名称'), { target: { value: '每周复盘' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存自动监督计划' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存心跳计划' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('保存失败,请重试')
|
||||
expect(screen.getByLabelText('计划名称')).toHaveValue('每周复盘')
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存自动监督计划' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存心跳计划' }))
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(2))
|
||||
expect(onUpdate).toHaveBeenLastCalledWith(config.id, {
|
||||
name: '每周复盘', scope: config.scope, timezone: saved.timezone,
|
||||
recurrence: saved.recurrence, enabled: false, lookbackHours: 48, retentionDays: 90
|
||||
recurrence: saved.recurrence, enabled: false, lookbackHours: 48, retentionDays: 90, intervention: 'suggest'
|
||||
})
|
||||
expect(props.onCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -458,54 +452,27 @@ describe('HeartbeatCenter', () => {
|
||||
it('renders English interface copy while preserving heartbeat content', async () => {
|
||||
await i18n.changeLanguage('en-US')
|
||||
render(<HeartbeatCenter {...createProps()} />)
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
level: 1,
|
||||
name: 'Supervisor'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Supervisor' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('SMART HEARTBEAT')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Run now' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText(entry.summary)).toBeVisible()
|
||||
expect(screen.getByText('Project: Default project')).toHaveClass(
|
||||
'scope-badge'
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: i18n.t('supervisor.automatic', { ns: 'heartbeat' }) })
|
||||
)
|
||||
expect(screen.getByText(task.title)).toBeInTheDocument()
|
||||
expect(screen.getByText(task.instructions)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Handle in conversation/ })
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: 'Automatic supervision' })
|
||||
)
|
||||
expect(screen.getByText('Project: Default project')).toHaveClass('scope-badge')
|
||||
expect(screen.getByText(/Supervisor suggestions|Suggest/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('settings.createTitle', { ns: 'heartbeat' }) }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'Selected projects' })
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Update memory only' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Selected projects' }))
|
||||
expect(screen.getByLabelText('Default project')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows heartbeat health, run metrics, and the latest report', () => {
|
||||
it('shows plan status and keeps earlier reports readable without statistics', () => {
|
||||
render(<HeartbeatCenter {...createProps()} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 1, name: '监督者' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('项目:默认项目')).toHaveClass(
|
||||
'scope-badge'
|
||||
)
|
||||
expect(screen.getByRole('heading', { level: 1, name: '监督者' })).toBeInTheDocument()
|
||||
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
|
||||
expect(screen.getByText('1 个计划运行中')).toBeInTheDocument()
|
||||
expect(screen.getByText('50%')).toBeInTheDocument()
|
||||
expect(screen.queryByText('50%')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(entry.summary)).toBeVisible()
|
||||
|
||||
expect(screen.getByRole('region', { name: '当前状态' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: '心跳计划' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('distinguishes multi-project-only scope from global and project scope', async () => {
|
||||
const secondProject = {
|
||||
...createProps().projects[0]!,
|
||||
@@ -565,176 +532,62 @@ describe('HeartbeatCenter', () => {
|
||||
|
||||
it('uses level-two headings for direct tab panel sections', () => {
|
||||
render(<HeartbeatCenter {...createProps()} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '当前状态' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '报告趋势' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: '最近回顾' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('CURRENT PULSE')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('GROWTH TREND')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('LATEST REPORT')).not.toBeInTheDocument()
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '待确认记忆' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '行动建议' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('MEMORY GROWTH')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('NEXT ACTIONS')).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.queryByRole('heading', { level: 2, name: '最近回顾' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '自动监督报告' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: '运行记录' })).toBeVisible()
|
||||
expect(
|
||||
screen.queryByText('HEARTBEAT TIMELINE')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('RUN AUDIT')).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('heading', { level: 2, name: '心跳计划' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 2, name: '历史心跳报告' })).toBeInTheDocument()
|
||||
for (const name of ['报告趋势', '待确认记忆', '行动建议', '运行记录', '最近回顾']) {
|
||||
expect(screen.queryByRole('heading', { name })).not.toBeInTheDocument()
|
||||
}
|
||||
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 2, name: '回顾算法' })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 2, name: '回顾算法' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新建计划' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('turns heartbeat findings into explicit user actions', async () => {
|
||||
const onSetMemoryStatus = vi.fn(async () => {})
|
||||
const onSetTaskStatus = vi.fn(async () => {})
|
||||
it('turns supervisor suggestions into explicit actions with evidence', async () => {
|
||||
const suggestion = { id: '00000000-0000-4000-8000-000000000901', resultId: 'result-1', heartbeatRunId: 'run-completed',
|
||||
scope: { kind: 'global' }, kind: 'open_item', title: '决定每页读取条数', detail: '50 还是 100 仍未决定', sourceIds: ['source-1'],
|
||||
entityId: null, relationId: null, taskId: null, status: 'pending', createdAt: '2026-09-23T10:00:00Z' }
|
||||
const suggestions = vi.fn(async () => [suggestion, { ...suggestion, id: '00000000-0000-4000-8000-000000000902', kind: 'conflict', title: '暂停与持续执行存在分歧' }])
|
||||
const suggestionAction = vi.fn(async () => ({ ...suggestion, status: 'accepted', taskId: task.id }))
|
||||
const source = vi.fn(async () => ({ title: '监督者回顾调度', content: '每页读取多少条消息还没定', sourceType: 'conversation', sourceId: 'conversation-1' }))
|
||||
Object.assign(window.goodbuddy.supervision, { suggestions, suggestionAction, source })
|
||||
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: /带入对话处理/ })
|
||||
)
|
||||
const onOpenConversation = vi.fn()
|
||||
render(<HeartbeatCenter {...createProps({ onUseFollowUpTask, onOpenConversation })} />)
|
||||
const panel = await screen.findByRole('region', { name: '监督建议' })
|
||||
expect(within(panel).getByText('未决事项')).toBeVisible()
|
||||
expect(within(panel).getAllByText('50 还是 100 仍未决定')[0]).toBeVisible()
|
||||
fireEvent.click(within(panel).getAllByRole('button', { name: '查看依据(1 处)' })[0]!)
|
||||
expect(await within(panel).findByText('每页读取多少条消息还没定')).toBeVisible()
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '打开会话' }))
|
||||
expect(onOpenConversation).toHaveBeenCalledWith('conversation-1')
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '转为任务' }))
|
||||
await waitFor(() => expect(suggestionAction).toHaveBeenCalledWith({ id: suggestion.id, action: 'accept' }))
|
||||
expect(onUseFollowUpTask).toHaveBeenCalledWith(task)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '标记完成' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(onSetTaskStatus).toHaveBeenCalledWith(
|
||||
task.id,
|
||||
'completed'
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(within(panel).queryByText('决定每页读取条数')).not.toBeInTheDocument())
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '忽略' }))
|
||||
await waitFor(() => expect(suggestionAction).toHaveBeenLastCalledWith({ id: '00000000-0000-4000-8000-000000000902', action: 'dismiss' }))
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: '监督建议' })).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
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('tab', { name: '自动监督' }))
|
||||
it('runs and refreshes plans and keeps earlier reports expandable and paged', async () => {
|
||||
const props = createProps({ entries: Array.from({ length: 21 }, (_, index) => ({ ...entry, id: `entry-${index}`, summary: `Report ${index}` })) })
|
||||
render(<HeartbeatCenter {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: `立即运行 ${config.name}` }))
|
||||
await waitFor(() =>
|
||||
expect(onRunNow).toHaveBeenCalledWith(config.id)
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '刷新自动监督' })
|
||||
)
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce())
|
||||
|
||||
expect(screen.getByText('模型暂时不可用')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
|
||||
expect(screen.queryByText('模型暂时不可用')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '展开完整报告' })
|
||||
)
|
||||
expect(within(screen.getByRole('region', { name: '自动监督报告' })).getByText(entry.highlights[0]!)).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps long automatic suggestions expandable after leaving and returning to the tab', () => {
|
||||
const longMemory = { ...memory, content: memory.content.repeat(20) }
|
||||
const longTask = { ...task, instructions: task.instructions.repeat(20) }
|
||||
render(<HeartbeatCenter {...createProps({ memories: [longMemory], tasks: [longTask] })} />)
|
||||
const memorySection = screen.getByRole('region', { name: '待确认记忆' })
|
||||
const expand = within(memorySection).getByRole('button', { name: i18n.t('center.suggestions.expandContent', { ns: 'heartbeat' }) })
|
||||
fireEvent.click(expand)
|
||||
expect(expand).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(within(memorySection).getByText(longMemory.content)).toHaveClass('heartbeat-center__suggestion-content--expanded')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作回顾' }))
|
||||
expect(screen.queryByText(longMemory.content)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
expect(screen.getByText(longMemory.content)).toHaveClass('heartbeat-center__suggestion-content--expanded')
|
||||
const taskSection = screen.getByRole('region', { name: '行动建议' })
|
||||
fireEvent.click(within(taskSection).getByRole('button', { name: i18n.t('center.suggestions.expandContent', { ns: 'heartbeat' }) }))
|
||||
expect(within(taskSection).getByText(longTask.instructions)).toHaveClass('heartbeat-center__suggestion-content--expanded')
|
||||
})
|
||||
|
||||
it('keeps report pagination, suggestion actions and run pagination in automatic supervision', async () => {
|
||||
const props = createProps({
|
||||
entries: Array.from({ length: 21 }, (_, index) => ({ ...entry, id: `entry-${index}`, summary: `Report ${index}` })),
|
||||
runs: Array.from({ length: 21 }, (_, index) => ({ ...runs[1]!, id: `run-${index}`, error: `Run error ${index}` }))
|
||||
})
|
||||
renderComponent(<HeartbeatCenter {...props} />)
|
||||
expect(screen.queryByRole('region', { name: '自动监督报告' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
const reports = screen.getByRole('region', { name: '自动监督报告' })
|
||||
await waitFor(() => expect(props.onRunNow).toHaveBeenCalledWith(config.id))
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新智能心跳' }))
|
||||
await waitFor(() => expect(props.onRefresh).toHaveBeenCalledOnce())
|
||||
const reports = screen.getByRole('region', { name: '历史心跳报告' })
|
||||
expect(within(reports).queryByText('Report 20')).not.toBeInTheDocument()
|
||||
fireEvent.click(within(reports).getByRole('button', { name: i18n.t('center.history.loadMoreReports', { ns: 'heartbeat' }) }))
|
||||
expect(within(reports).getByText('Report 20')).toBeVisible()
|
||||
fireEvent.click(within(reports).getAllByRole('button', { name: '展开完整报告' })[20]!)
|
||||
expect(within(reports).getByText(entry.highlights[0]!)).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '忽略' }))
|
||||
await waitFor(() => expect(props.onSetMemoryStatus).toHaveBeenCalledWith(memory.id, 'rejected'))
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('center.suggestions.ignoreSuggestion', { ns: 'heartbeat' }) }))
|
||||
await waitFor(() => expect(props.onSetTaskStatus).toHaveBeenCalledWith(task.id, 'cancelled'))
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '工作回顾' }), { key: 'End' })
|
||||
expect(screen.getByRole('tab', { name: '设置' })).toHaveFocus()
|
||||
expect(screen.queryByRole('region', { name: '自动监督报告' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: '运行记录' })).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '设置' }), { key: 'ArrowLeft' })
|
||||
expect(screen.getByRole('tab', { name: '活动记录' })).toHaveFocus()
|
||||
expect(screen.queryByRole('region', { name: '运行记录' })).not.toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByRole('tab', { name: '活动记录' }), { key: 'ArrowLeft' })
|
||||
expect(screen.getByRole('tab', { name: '自动监督' })).toHaveFocus()
|
||||
const audit = screen.getByRole('region', { name: '运行记录' })
|
||||
expect(within(audit).queryByText('Run error 20')).not.toBeInTheDocument()
|
||||
fireEvent.click(within(audit).getByRole('button', { name: i18n.t('center.history.loadMoreRuns', { ns: 'heartbeat' }) }))
|
||||
expect(within(audit).getByText('Run error 20')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作回顾' }))
|
||||
expect(screen.queryByRole('region', { name: '自动监督报告' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
expect(within(screen.getByRole('region', { name: '自动监督报告' })).getByText('Report 20')).toBeVisible()
|
||||
expect(screen.queryByRole('region', { name: '历史心跳报告' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '智能心跳' }))
|
||||
expect(within(screen.getByRole('region', { name: '历史心跳报告' })).getByText('Report 20')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '收起报告' })).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps successful supervision results independent of automatic reports (empty=%s)', async (empty) => {
|
||||
window.goodbuddy.supervision.overview = vi.fn(async () => [{ id: 'result', storyLineId: 'story', sourceId: null, summary: 'Successful work review', changeDigest: '', createdAt: '2026-09-22T00:00:00Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' }, openItems: [] }]) as never
|
||||
renderComponent(<HeartbeatCenter {...createProps(empty ? { configs: [], entries: [], runs: [], memories: [], tasks: [] } : {})} />)
|
||||
@@ -742,12 +595,12 @@ describe('HeartbeatCenter', () => {
|
||||
const recap = screen.getByRole('tabpanel', { name: '工作回顾' })
|
||||
expect(recap.querySelector('#heartbeat-panel-history, #heartbeat-panel-suggestions, .scope-badge')).toBeNull()
|
||||
expect(within(recap).getAllByRole('button', { name: '刷新' })).toHaveLength(1)
|
||||
expect(within(recap).queryByText(/暂无自动监督报告|还没有成功回顾/)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
expect(within(recap).queryByText(/暂无历史心跳报告|还没有成功回顾/)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '智能心跳' }))
|
||||
if (empty) {
|
||||
expect(screen.queryByRole('region', { name: '自动监督报告' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: '历史心跳报告' })).not.toBeInTheDocument()
|
||||
} else {
|
||||
expect(within(screen.getByRole('region', { name: '自动监督报告' })).getByText(entry.summary)).toBeVisible()
|
||||
expect(within(screen.getByRole('region', { name: '历史心跳报告' })).getByText(entry.summary)).toBeVisible()
|
||||
}
|
||||
expect(screen.queryByText('Successful work review')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -755,7 +608,7 @@ describe('HeartbeatCenter', () => {
|
||||
it('explains the irreversible impact before deleting a plan', () => {
|
||||
render(<HeartbeatCenter {...createProps()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '智能心跳' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: `删除 ${config.name}`
|
||||
@@ -772,7 +625,7 @@ describe('HeartbeatCenter', () => {
|
||||
|
||||
it('keeps scope choices visible while showing help for the selected scope', () => {
|
||||
render(<HeartbeatCenter {...createProps()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '智能心跳' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: i18n.t('settings.createTitle', { ns: 'heartbeat' }) }))
|
||||
const help = screen.getByRole('button', { name: '项目范围' })
|
||||
expect(help.closest('label, [role="tab"]')).toBeNull()
|
||||
@@ -787,7 +640,7 @@ describe('HeartbeatCenter', () => {
|
||||
vi.stubGlobal('goodbuddy', {})
|
||||
renderComponent(<HeartbeatCenter {...createProps()} />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('监督者服务暂不可用')
|
||||
expect(screen.queryByRole('region', { name: '当前状态' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: '心跳计划' })).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '设置' }))
|
||||
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1)
|
||||
expect(screen.getByRole('heading', { name: '回顾算法' })).toBeInTheDocument()
|
||||
@@ -801,7 +654,7 @@ describe('HeartbeatCenter', () => {
|
||||
await screen.findByText('还没有成功回顾')
|
||||
fireEvent.change(screen.getByLabelText('关注范围'), { target: { value: props.projects[0]!.id } })
|
||||
fireEvent.change(screen.getByLabelText('时间范围'), { target: { value: '30' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
expect(run).toHaveBeenCalledWith(expect.objectContaining({
|
||||
trigger: 'manual', scope: { kind: 'projects', projectIds: [props.projects[0]!.id] }
|
||||
@@ -831,7 +684,7 @@ describe('HeartbeatCenter', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '创建自动监督计划' })
|
||||
screen.getByRole('button', { name: '创建心跳计划' })
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '指定项目' })
|
||||
@@ -863,7 +716,7 @@ describe('HeartbeatCenter', () => {
|
||||
<HeartbeatCenter {...createProps({ onUpdate })} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
fireEvent.click(screen.getByRole('tab', { name: '智能心跳' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: `编辑 ${config.name}` })
|
||||
)
|
||||
@@ -871,7 +724,7 @@ describe('HeartbeatCenter', () => {
|
||||
target: { value: '更新后的回顾' }
|
||||
})
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '保存自动监督计划' })
|
||||
screen.getByRole('button', { name: '保存心跳计划' })
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -899,10 +752,10 @@ describe('HeartbeatCenter', () => {
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '创建自动监督计划' })
|
||||
screen.getByRole('button', { name: '创建心跳计划' })
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '自动监督' })
|
||||
screen.getByRole('tab', { name: '智能心跳' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
screen.getByRole('button', { name: '保存并启用计划' })
|
||||
@@ -1003,13 +856,13 @@ describe('HeartbeatCenter', () => {
|
||||
const props = createProps()
|
||||
render(<HeartbeatCenter {...props} />, false)
|
||||
for (const editing of [false, true]) {
|
||||
const opener = screen.getByRole('button', { name: editing ? `编辑 ${config.name}` : '创建自动监督计划' })
|
||||
const opener = screen.getByRole('button', { name: editing ? `编辑 ${config.name}` : '创建心跳计划' })
|
||||
opener.focus()
|
||||
fireEvent.click(opener)
|
||||
const dismiss = () => {
|
||||
if (action === 'Escape') fireEvent.keyDown(screen.getByLabelText('计划名称'), { key: 'Escape' })
|
||||
else if (action === 'backdrop') fireEvent.mouseDown(screen.getByRole('dialog').parentElement!)
|
||||
else fireEvent.click(screen.getByRole('button', { name: action === 'X' ? '关闭自动监督计划' : '取消' }))
|
||||
else fireEvent.click(screen.getByRole('button', { name: action === 'X' ? '关闭心跳计划' : '取消' }))
|
||||
}
|
||||
dismiss()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
@@ -1019,7 +872,7 @@ describe('HeartbeatCenter', () => {
|
||||
dismiss()
|
||||
expect(screen.getByRole('dialog')).toHaveAccessibleName('放弃未保存的计划修改?')
|
||||
expect(screen.getByRole('button', { name: '继续编辑' })).toHaveFocus()
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭自动监督计划' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭心跳计划' }))
|
||||
await waitFor(() => expect(screen.getByLabelText('计划名称')).toHaveFocus())
|
||||
expect(screen.getByLabelText('计划名称')).toHaveValue('Unsaved draft')
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
@@ -1034,32 +887,32 @@ describe('HeartbeatCenter', () => {
|
||||
let rejectSave!: (reason: Error) => void
|
||||
const onCreate = vi.fn(() => new Promise<void>((_resolve, reject) => { rejectSave = reject }))
|
||||
render(<HeartbeatCenter {...createProps({ onCreate })} />, false)
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建自动监督计划' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建心跳计划' }))
|
||||
fireEvent.change(screen.getByLabelText('计划名称'), { target: { value: 'Pending draft' } })
|
||||
const save = screen.getByRole('button', { name: '保存并启用计划' })
|
||||
fireEvent.click(save)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toHaveAttribute('aria-busy', 'true')
|
||||
for (const button of [save, screen.getByRole('button', { name: '关闭自动监督计划' }), screen.getByRole('button', { name: '取消' })]) {
|
||||
for (const button of [save, screen.getByRole('button', { name: '关闭心跳计划' }), screen.getByRole('button', { name: '取消' })]) {
|
||||
expect(button).toBeDisabled()
|
||||
fireEvent.click(button)
|
||||
}
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
fireEvent.mouseDown(dialog.parentElement!)
|
||||
expect(dialog).toHaveAccessibleName('创建自动监督计划')
|
||||
expect(dialog).toHaveAccessibleName('创建心跳计划')
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
rejectSave(new Error('Save unavailable'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Save unavailable')
|
||||
expect(screen.getByLabelText('计划名称')).toHaveValue('Pending draft')
|
||||
expect(save).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭自动监督计划' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭心跳计划' }))
|
||||
expect(dialog).toHaveAccessibleName('放弃未保存的计划修改?')
|
||||
})
|
||||
|
||||
it('keeps a failed create draft, guards Escape/backdrop/cancel, traps focus and restores the opener', async () => {
|
||||
const onCreate = vi.fn(async () => {}).mockRejectedValueOnce(new Error('Cannot save plan'))
|
||||
render(<HeartbeatCenter {...createProps({ onCreate })} />, false)
|
||||
const opener = screen.getByRole('button', { name: '创建自动监督计划' })
|
||||
const opener = screen.getByRole('button', { name: '创建心跳计划' })
|
||||
opener.focus()
|
||||
fireEvent.click(opener)
|
||||
const name = screen.getByLabelText('计划名称')
|
||||
@@ -1075,7 +928,7 @@ describe('HeartbeatCenter', () => {
|
||||
const discard = screen.getByRole('button', { name: '放弃修改' })
|
||||
discard.focus()
|
||||
fireEvent.keyDown(discard, { key: 'Tab' })
|
||||
expect(screen.getByRole('button', { name: '关闭自动监督计划' })).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '关闭心跳计划' })).toHaveFocus()
|
||||
fireEvent.keyDown(discard, { key: 'Escape' })
|
||||
expect(screen.getByLabelText('计划名称')).toHaveValue('Draft plan')
|
||||
fireEvent.mouseDown(screen.getByRole('dialog').parentElement!)
|
||||
@@ -1112,9 +965,9 @@ describe('HeartbeatCenter', () => {
|
||||
expect(filter).toHaveFocus()
|
||||
await waitFor(() => expect(activity).toHaveBeenLastCalledWith({ limit: 50, offset: 0 }))
|
||||
expect(screen.queryByText('模型暂时不可用')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
|
||||
expect(screen.getByText('模型暂时不可用')).toBeVisible()
|
||||
expect(screen.getByText('Other plan failure')).toBeVisible()
|
||||
// Execution history lives only in Activity records; plans no longer repeat an audit list.
|
||||
fireEvent.click(screen.getByRole('tab', { name: '智能心跳' }))
|
||||
expect(screen.queryByText('Other plan failure')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps the entire automatic overview with one create action out of activity (empty=%s)', async (empty) => {
|
||||
@@ -1122,24 +975,25 @@ describe('HeartbeatCenter', () => {
|
||||
window.goodbuddy.supervision.activity = activity
|
||||
const props = createProps(empty ? { configs: [], entries: [], runs: [], memories: [], tasks: [] } : {})
|
||||
render(<HeartbeatCenter {...props} />)
|
||||
const panel = screen.getByRole('tabpanel', { name: '自动监督' })
|
||||
expect(within(panel).getByRole('heading', { name: '当前状态' })).toBeVisible()
|
||||
const panel = screen.getByRole('tabpanel', { name: '智能心跳' })
|
||||
expect(within(panel).getByRole('heading', { name: '心跳计划' })).toBeVisible()
|
||||
for (const name of ['报告趋势', '运行记录']) {
|
||||
expect(within(panel).queryByRole('heading', { name }) !== null).toBe(!empty)
|
||||
expect(within(panel).queryByRole('heading', { name })).not.toBeInTheDocument()
|
||||
}
|
||||
expect(panel.querySelector('.heartbeat-center__metrics') !== null).toBe(!empty)
|
||||
expect(panel.querySelector('.heartbeat-center__metrics')).toBeNull()
|
||||
expect(within(panel).queryByRole('heading', { name: '历史心跳报告' }) !== null).toBe(!empty)
|
||||
expect(panel.querySelector('.scope-badge')).toBeVisible()
|
||||
expect(within(panel).getAllByRole('button', { name: '创建自动监督计划' })).toHaveLength(1)
|
||||
expect(within(panel).getAllByRole('button', { name: '创建心跳计划' })).toHaveLength(1)
|
||||
expect(panel.querySelectorAll('#heartbeat-panel-overview .empty-state')).toHaveLength(0)
|
||||
expect(within(panel).queryByText(i18n.t('settings.empty', { ns: 'heartbeat' })) !== null).toBe(empty)
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '刷新自动监督' }))
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '刷新智能心跳' }))
|
||||
await waitFor(() => expect(props.onRefresh).toHaveBeenCalledOnce())
|
||||
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
|
||||
await waitFor(() => expect(activity).toHaveBeenCalledOnce())
|
||||
const activityPanel = screen.getByRole('tabpanel', { name: '活动记录' })
|
||||
expect(activityPanel.querySelector('#heartbeat-panel-overview, #heartbeat-panel-plans, #heartbeat-runs-title, .heartbeat-center__metrics')).toBeNull()
|
||||
expect(within(activityPanel).queryByRole('button', { name: '创建自动监督计划' })).not.toBeInTheDocument()
|
||||
expect(within(activityPanel).queryByRole('button', { name: '刷新自动监督' })).not.toBeInTheDocument()
|
||||
expect(within(activityPanel).queryByRole('button', { name: '创建心跳计划' })).not.toBeInTheDocument()
|
||||
expect(within(activityPanel).queryByRole('button', { name: '刷新智能心跳' })).not.toBeInTheDocument()
|
||||
fireEvent.click(within(activityPanel).getByRole('button', { name: '刷新' }))
|
||||
await waitFor(() => expect(activity).toHaveBeenCalledTimes(2))
|
||||
expect(props.onRefresh).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
HeartPulse,
|
||||
History,
|
||||
Lightbulb,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
XCircle
|
||||
} from 'lucide-react'
|
||||
import { SupervisionSuggestionsPanel } from './SupervisionSuggestionsPanel'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
@@ -76,13 +71,6 @@ export type HeartbeatCenterProps = {
|
||||
onRetryLoad: () => void | Promise<void>
|
||||
}
|
||||
|
||||
function percentage(numerator: number, denominator: number): number {
|
||||
if (denominator <= 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.round((numerator / denominator) * 100)
|
||||
}
|
||||
|
||||
function byNewest<T extends { createdAt: string }>(left: T, right: T): number {
|
||||
return (
|
||||
new Date(right.createdAt).getTime() -
|
||||
@@ -167,6 +155,10 @@ function UnifiedSupervisorCenter(props: HeartbeatCenterProps): React.JSX.Element
|
||||
}}
|
||||
/>}
|
||||
<HeartbeatSections {...props} pageTab={pageTab}
|
||||
onOpenResult={(resultId) => {
|
||||
setActivityNavigation({ resultId, tab: 'graph' })
|
||||
setPageTab('graph')
|
||||
}}
|
||||
onOpenActivity={(id) => {
|
||||
setActivityPlanId(id)
|
||||
setPageTab('activity')
|
||||
@@ -185,34 +177,26 @@ function UnifiedSupervisorCenter(props: HeartbeatCenterProps): React.JSX.Element
|
||||
|
||||
function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Element {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const heartbeat = props.applicationSettings?.heartbeatReportTimeoutSeconds ?? defaultSupervisionTimeoutSeconds
|
||||
const supervisor = props.applicationSettings?.supervisorOrganizeTimeoutSeconds ?? defaultSupervisionTimeoutSeconds
|
||||
const concurrency = props.applicationSettings?.supervisorModelConcurrency ?? defaultSupervisorModelConcurrency
|
||||
const [parallel, setParallel] = useState(String(concurrency))
|
||||
const [report, setReport] = useState(String(heartbeat))
|
||||
const [organize, setOrganize] = useState(String(supervisor))
|
||||
const [saved, setSaved] = useState({ heartbeat, supervisor, concurrency })
|
||||
if (saved.heartbeat !== heartbeat || saved.supervisor !== supervisor || saved.concurrency !== concurrency) {
|
||||
setSaved({ heartbeat, supervisor, concurrency })
|
||||
const [saved, setSaved] = useState({ supervisor, concurrency })
|
||||
if (saved.supervisor !== supervisor || saved.concurrency !== concurrency) {
|
||||
setSaved({ supervisor, concurrency })
|
||||
setParallel(String(concurrency))
|
||||
setReport(String(heartbeat))
|
||||
setOrganize(String(supervisor))
|
||||
}
|
||||
const valid = supervisionTimeoutSecondsSchema.safeParse(Number(report)).success &&
|
||||
supervisionTimeoutSecondsSchema.safeParse(Number(organize)).success &&
|
||||
const valid = supervisionTimeoutSecondsSchema.safeParse(Number(organize)).success &&
|
||||
supervisorModelConcurrencySchema.safeParse(Number(parallel)).success
|
||||
const disabled = props.applicationSettingsPending || props.applicationSettingsLocked || !props.applicationSettings || !props.onUpdateApplicationSettings
|
||||
return <form className="heartbeat-settings heartbeat-settings__editor" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!valid || disabled) return
|
||||
void props.onUpdateApplicationSettings?.({ heartbeatReportTimeoutSeconds: Number(report), supervisorOrganizeTimeoutSeconds: Number(organize), supervisorModelConcurrency: Number(parallel) })
|
||||
void props.onUpdateApplicationSettings?.({ supervisorOrganizeTimeoutSeconds: Number(organize), supervisorModelConcurrency: Number(parallel) })
|
||||
}}>
|
||||
<h2>{t('timeouts.title')}</h2>
|
||||
<p>{t('timeouts.help')}</p>
|
||||
<label className="heartbeat-settings__field">{t('timeouts.report')}
|
||||
<input type="number" min={30} max={600} step={1} value={report} disabled={disabled}
|
||||
onChange={(event) => setReport(event.target.value)} />
|
||||
</label>
|
||||
<label className="heartbeat-settings__field">{t('timeouts.organize')}
|
||||
<input type="number" min={30} max={600} step={1} value={organize} disabled={disabled}
|
||||
onChange={(event) => setOrganize(event.target.value)} />
|
||||
@@ -228,17 +212,15 @@ function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Elemen
|
||||
<p>{props.applicationSettingsError}</p>
|
||||
<button className="secondary-button" type="button" disabled={props.applicationSettingsPending} onClick={props.onRetryApplicationSettings}>{t('center.actions.retry')}</button>
|
||||
</div>}
|
||||
<button className="primary-button" type="submit" disabled={disabled || !valid || (Number(report) === heartbeat && Number(organize) === supervisor && Number(parallel) === concurrency)}>{t('timeouts.save')}</button>
|
||||
<button className="primary-button" type="submit" disabled={disabled || !valid || (Number(organize) === supervisor && Number(parallel) === concurrency)}>{t('timeouts.save')}</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
function HeartbeatSections({
|
||||
onOpenActivity,
|
||||
pageTab,
|
||||
configs,
|
||||
runs,
|
||||
entries,
|
||||
memories,
|
||||
projects,
|
||||
tasks,
|
||||
onCreate,
|
||||
@@ -247,25 +229,24 @@ function HeartbeatSections({
|
||||
onRemove,
|
||||
onRunNow,
|
||||
onRefresh,
|
||||
onSetMemoryStatus,
|
||||
onSetTaskStatus,
|
||||
onUseFollowUpTask,
|
||||
onOpenConversation,
|
||||
onOpenResult,
|
||||
loading = false,
|
||||
loadError,
|
||||
onRetryLoad
|
||||
}: HeartbeatCenterProps & {
|
||||
pageTab: 'overview' | 'graph' | 'plans' | 'activity' | 'settings'
|
||||
onOpenActivity: (id: string) => void
|
||||
onOpenResult: (resultId: string) => void
|
||||
}): React.JSX.Element | null {
|
||||
const { t, i18n } = useTranslation('heartbeat')
|
||||
const { t: tWorkspace } = useTranslation('workspace')
|
||||
const [pendingAction, setPendingAction] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [expandedEntryId, setExpandedEntryId] = useState<string>()
|
||||
const [expandedSuggestionId, setExpandedSuggestionId] =
|
||||
useState<string>()
|
||||
const [visibleEntryCount, setVisibleEntryCount] = useState(20)
|
||||
const [visibleRunCount, setVisibleRunCount] = useState(20)
|
||||
const [suggestionReload, setSuggestionReload] = useState(0)
|
||||
const dateTimeFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(i18n.resolvedLanguage || 'zh-CN', {
|
||||
@@ -280,45 +261,8 @@ function HeartbeatSections({
|
||||
() => new Intl.NumberFormat(i18n.resolvedLanguage || 'zh-CN'),
|
||||
[i18n.resolvedLanguage]
|
||||
)
|
||||
const percentFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(i18n.resolvedLanguage || 'zh-CN', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 0
|
||||
}),
|
||||
[i18n.resolvedLanguage]
|
||||
)
|
||||
const formatCount = (value: number): string =>
|
||||
countFormatter.format(value)
|
||||
const formatPercent = (value: number): string =>
|
||||
percentFormatter.format(value / 100)
|
||||
const runStatusLabels: Record<
|
||||
AssistantHeartbeatRun['status'],
|
||||
string
|
||||
> = {
|
||||
claimed: t('statuses.run.claimed'),
|
||||
no_change: t('statuses.run.no_change'),
|
||||
completed: t('statuses.run.completed'),
|
||||
failed: t('statuses.run.failed'),
|
||||
skipped: t('statuses.run.skipped')
|
||||
}
|
||||
const taskStatusLabels: Record<AssistantTask['status'], string> = {
|
||||
queued: t('statuses.task.queued'),
|
||||
idle: t('statuses.task.idle'),
|
||||
running: t('statuses.task.running'),
|
||||
waiting_approval: t('statuses.task.waitingApproval'),
|
||||
paused: t('statuses.task.paused'),
|
||||
completed: t('statuses.task.completed'),
|
||||
failed: t('statuses.task.failed'),
|
||||
cancelled: t('statuses.task.cancelled'),
|
||||
interrupted: t('statuses.task.interrupted')
|
||||
}
|
||||
const memoryTypeLabels: Record<AssistantMemory['type'], string> = {
|
||||
preference: t('statuses.memory.preference'),
|
||||
fact: t('statuses.memory.fact'),
|
||||
summary: t('statuses.memory.summary'),
|
||||
procedure: t('statuses.memory.procedure')
|
||||
}
|
||||
const formatDateTime = (value?: string): string => {
|
||||
if (!value) {
|
||||
return t('common.unavailable')
|
||||
@@ -329,77 +273,15 @@ function HeartbeatSections({
|
||||
: dateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
// Earlier heartbeat versions wrote their own reports. They stay readable;
|
||||
// new heartbeats only trigger the shared review and add suggestions.
|
||||
const orderedEntries = useMemo(
|
||||
() => [...entries].sort(byNewest),
|
||||
[entries]
|
||||
)
|
||||
const orderedRuns = useMemo(() => [...runs].sort(byNewest), [runs])
|
||||
const proposedMemoryIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
orderedEntries.flatMap((entry) => entry.proposedMemoryIds)
|
||||
),
|
||||
[orderedEntries]
|
||||
)
|
||||
const followUpTaskIds = useMemo(
|
||||
() =>
|
||||
new Set(orderedEntries.flatMap((entry) => entry.followUpTaskIds)),
|
||||
[orderedEntries]
|
||||
)
|
||||
const heartbeatMemories = useMemo(
|
||||
() =>
|
||||
memories.filter((memory) => proposedMemoryIds.has(memory.id)),
|
||||
[memories, proposedMemoryIds]
|
||||
)
|
||||
const pendingMemories = heartbeatMemories.filter(
|
||||
(memory) => memory.status === 'proposed'
|
||||
)
|
||||
const confirmedMemories = heartbeatMemories.filter(
|
||||
(memory) => memory.status === 'confirmed'
|
||||
)
|
||||
const followUpTasks = useMemo(
|
||||
() => tasks.filter((task) => followUpTaskIds.has(task.id)),
|
||||
[followUpTaskIds, tasks]
|
||||
)
|
||||
const completedTasks = followUpTasks.filter(
|
||||
(task) => task.status === 'completed'
|
||||
)
|
||||
const terminalRuns = orderedRuns.filter(
|
||||
(run) => run.status !== 'claimed'
|
||||
)
|
||||
const completedRuns = terminalRuns.filter(
|
||||
(run) => run.status === 'completed'
|
||||
)
|
||||
const highlightCount = orderedEntries.reduce(
|
||||
(total, entry) => total + entry.highlights.length,
|
||||
0
|
||||
)
|
||||
const activeConfigs = configs.filter((config) => config.enabled)
|
||||
const latestEntry = orderedEntries[0]
|
||||
const healthPercent = percentage(completedRuns.length, terminalRuns.length)
|
||||
const memoryPercent = percentage(
|
||||
confirmedMemories.length,
|
||||
proposedMemoryIds.size
|
||||
)
|
||||
const actionPercent = percentage(
|
||||
completedTasks.length,
|
||||
followUpTaskIds.size
|
||||
)
|
||||
const recentTrend = orderedEntries.slice(0, 7).reverse()
|
||||
const trendMaximum = Math.max(
|
||||
1,
|
||||
...recentTrend.map(
|
||||
(entry) =>
|
||||
entry.highlights.length +
|
||||
entry.proposedMemoryIds.length +
|
||||
entry.followUpTaskIds.length
|
||||
)
|
||||
)
|
||||
const hasHeartbeatData =
|
||||
configs.length > 0 || runs.length > 0 || entries.length > 0
|
||||
const hasHeartbeatActivity =
|
||||
runs.length > 0 || entries.length > 0 ||
|
||||
heartbeatMemories.length > 0 || followUpTasks.length > 0
|
||||
const heartbeatScope = useMemo<WorkspaceScope>(() => {
|
||||
if (
|
||||
configs.length === 0 ||
|
||||
@@ -484,7 +366,10 @@ function HeartbeatSections({
|
||||
aria-label={t('settings.refreshPlans')}
|
||||
className="secondary-button"
|
||||
disabled={loading || pendingAction !== undefined}
|
||||
onClick={() => void runAction('refresh', onRefresh)}
|
||||
onClick={() => void runAction('refresh', async () => {
|
||||
setSuggestionReload(value => value + 1)
|
||||
await onRefresh()
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
@@ -589,437 +474,13 @@ function HeartbeatSections({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{hasHeartbeatActivity && <>
|
||||
<dl
|
||||
aria-label={t('center.metrics.ariaLabel')}
|
||||
className="heartbeat-center__metrics"
|
||||
>
|
||||
<div>
|
||||
<dt>
|
||||
<HeartPulse aria-hidden="true" size={15} />
|
||||
{t('center.metrics.health')}
|
||||
</dt>
|
||||
<dd>
|
||||
{terminalRuns.length
|
||||
? formatPercent(healthPercent)
|
||||
: t('common.unavailable')}
|
||||
</dd>
|
||||
<small>
|
||||
{t('center.metrics.successfulRuns', {
|
||||
completed: formatCount(completedRuns.length),
|
||||
total: formatCount(terminalRuns.length)
|
||||
})}
|
||||
</small>
|
||||
<span
|
||||
aria-label={t('center.metrics.healthRateAriaLabel', {
|
||||
percent: formatPercent(healthPercent)
|
||||
})}
|
||||
className="heartbeat-center__meter"
|
||||
role="progressbar"
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={healthPercent}
|
||||
>
|
||||
<span style={{ width: `${healthPercent}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<Sparkles aria-hidden="true" size={15} />
|
||||
{t('center.metrics.memory')}
|
||||
</dt>
|
||||
<dd>
|
||||
{formatCount(confirmedMemories.length)}/
|
||||
{formatCount(proposedMemoryIds.size)}
|
||||
</dd>
|
||||
<small>{t('center.metrics.memoryDescription')}</small>
|
||||
<span
|
||||
aria-label={t('center.metrics.memoryRateAriaLabel', {
|
||||
percent: formatPercent(memoryPercent)
|
||||
})}
|
||||
className="heartbeat-center__meter"
|
||||
role="progressbar"
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={memoryPercent}
|
||||
>
|
||||
<span style={{ width: `${memoryPercent}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<Lightbulb aria-hidden="true" size={15} />
|
||||
{t('center.metrics.insights')}
|
||||
</dt>
|
||||
<dd>{formatCount(highlightCount)}</dd>
|
||||
<small>
|
||||
{t('center.metrics.insightReports', {
|
||||
count: orderedEntries.length,
|
||||
formattedCount: formatCount(orderedEntries.length)
|
||||
})}
|
||||
</small>
|
||||
<span className="heartbeat-center__metric-note">
|
||||
{latestEntry
|
||||
? t('center.metrics.latestInsights', {
|
||||
count: latestEntry.highlights.length,
|
||||
formattedCount: formatCount(
|
||||
latestEntry.highlights.length
|
||||
)
|
||||
})
|
||||
: t('center.metrics.awaitingFirstRun')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
<ListChecks aria-hidden="true" size={15} />
|
||||
{t('center.metrics.action')}
|
||||
</dt>
|
||||
<dd>
|
||||
{formatCount(completedTasks.length)}/
|
||||
{formatCount(followUpTaskIds.size)}
|
||||
</dd>
|
||||
<small>{t('center.metrics.actionDescription')}</small>
|
||||
<span
|
||||
aria-label={t('center.metrics.actionRateAriaLabel', {
|
||||
percent: formatPercent(actionPercent)
|
||||
})}
|
||||
className="heartbeat-center__meter"
|
||||
role="progressbar"
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={actionPercent}
|
||||
>
|
||||
<span style={{ width: `${actionPercent}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<section
|
||||
aria-labelledby="heartbeat-trend-title"
|
||||
className="heartbeat-center__section"
|
||||
>
|
||||
<div className="heartbeat-center__section-heading">
|
||||
<div>
|
||||
<h2 id="heartbeat-trend-title">
|
||||
{t('center.trend.title')}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
{recentTrend.length === 0 ? (
|
||||
<p className="heartbeat-center__section-empty">
|
||||
{t('center.trend.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="heartbeat-center__legend">
|
||||
<span className="heartbeat-center__legend--insight">
|
||||
{t('center.trend.insight')}
|
||||
</span>
|
||||
<span className="heartbeat-center__legend--memory">
|
||||
{t('center.trend.memory')}
|
||||
</span>
|
||||
<span className="heartbeat-center__legend--task">
|
||||
{t('center.trend.action')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="heartbeat-center__trend">
|
||||
{recentTrend.map((entry) => {
|
||||
const total =
|
||||
entry.highlights.length +
|
||||
entry.proposedMemoryIds.length +
|
||||
entry.followUpTaskIds.length
|
||||
return (
|
||||
<div
|
||||
aria-label={t('center.trend.rowAriaLabel', {
|
||||
date: formatDateTime(entry.createdAt),
|
||||
insights: formatCount(
|
||||
entry.highlights.length
|
||||
),
|
||||
memories: formatCount(
|
||||
entry.proposedMemoryIds.length
|
||||
),
|
||||
actions: formatCount(
|
||||
entry.followUpTaskIds.length
|
||||
)
|
||||
})}
|
||||
className="heartbeat-center__trend-row"
|
||||
key={entry.id}
|
||||
role="img"
|
||||
>
|
||||
<time dateTime={entry.createdAt}>
|
||||
{formatDateTime(entry.createdAt)}
|
||||
</time>
|
||||
<span className="heartbeat-center__trend-track">
|
||||
<span
|
||||
className="heartbeat-center__trend-total"
|
||||
style={{
|
||||
width: `${Math.max(6, (total / trendMaximum) * 100)}%`
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="heartbeat-center__trend-insight"
|
||||
style={{
|
||||
flex: entry.highlights.length
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="heartbeat-center__trend-memory"
|
||||
style={{
|
||||
flex: entry.proposedMemoryIds.length
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="heartbeat-center__trend-task"
|
||||
style={{
|
||||
flex: entry.followUpTaskIds.length
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<small>{formatCount(total)}</small>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasHeartbeatActivity && (
|
||||
<div
|
||||
className="heartbeat-center__panel heartbeat-center__suggestions"
|
||||
id="heartbeat-panel-suggestions"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="heartbeat-memory-title"
|
||||
className="heartbeat-center__section"
|
||||
>
|
||||
<div className="heartbeat-center__section-heading">
|
||||
<div>
|
||||
<h2 id="heartbeat-memory-title" tabIndex={-1}>
|
||||
{t('center.suggestions.memoryTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<span>
|
||||
{t('center.suggestions.memoryCount', {
|
||||
count: pendingMemories.length,
|
||||
formattedCount: formatCount(pendingMemories.length)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{pendingMemories.length === 0 ? (
|
||||
<p className="heartbeat-center__section-empty">
|
||||
{t('center.suggestions.memoryEmpty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="heartbeat-center__suggestion-list">
|
||||
{pendingMemories.map((memory) => (
|
||||
<article
|
||||
className="heartbeat-center__suggestion"
|
||||
key={memory.id}
|
||||
>
|
||||
<header>
|
||||
<span>{memoryTypeLabels[memory.type]}</span>
|
||||
<small>
|
||||
{t(
|
||||
'center.suggestions.confidenceAndSalience',
|
||||
{
|
||||
confidence: percentFormatter.format(
|
||||
memory.confidence
|
||||
),
|
||||
salience: percentFormatter.format(
|
||||
memory.salience
|
||||
)
|
||||
}
|
||||
)}
|
||||
</small>
|
||||
</header>
|
||||
<p
|
||||
className={
|
||||
expandedSuggestionId === memory.id
|
||||
? 'heartbeat-center__suggestion-content--expanded'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{memory.content}
|
||||
</p>
|
||||
{memory.content.length > 180 && (
|
||||
<button
|
||||
aria-expanded={
|
||||
expandedSuggestionId === memory.id
|
||||
}
|
||||
className="heartbeat-center__link-button"
|
||||
onClick={() =>
|
||||
setExpandedSuggestionId(
|
||||
expandedSuggestionId === memory.id
|
||||
? undefined
|
||||
: memory.id
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{expandedSuggestionId === memory.id
|
||||
? t('center.suggestions.collapseContent')
|
||||
: t('center.suggestions.expandContent')}
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={pendingAction !== undefined}
|
||||
onClick={() =>
|
||||
void runAction(
|
||||
`memory:${memory.id}:confirmed`,
|
||||
() =>
|
||||
onSetMemoryStatus(memory.id, 'confirmed')
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<CheckCircle2 aria-hidden="true" size={14} />
|
||||
{t('center.suggestions.confirmMemory')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={pendingAction !== undefined}
|
||||
onClick={() =>
|
||||
void runAction(
|
||||
`memory:${memory.id}:rejected`,
|
||||
() =>
|
||||
onSetMemoryStatus(memory.id, 'rejected')
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<XCircle aria-hidden="true" size={14} />
|
||||
{t('center.suggestions.ignore')}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<SupervisionSuggestionsPanel onUseFollowUpTask={onUseFollowUpTask} tasks={tasks} reloadKey={suggestionReload}
|
||||
onOpenResult={onOpenResult} onOpenConversation={onOpenConversation} />
|
||||
|
||||
<section
|
||||
aria-labelledby="heartbeat-task-title"
|
||||
className="heartbeat-center__section"
|
||||
>
|
||||
<div className="heartbeat-center__section-heading">
|
||||
<div>
|
||||
<h2 id="heartbeat-task-title">
|
||||
{t('center.suggestions.taskTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<span>
|
||||
{t('center.suggestions.taskCount', {
|
||||
count: followUpTasks.length,
|
||||
formattedCount: formatCount(followUpTasks.length)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{followUpTasks.length === 0 ? (
|
||||
<p className="heartbeat-center__section-empty">
|
||||
{t('center.suggestions.taskEmpty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="heartbeat-center__suggestion-list">
|
||||
{followUpTasks.map((task) => (
|
||||
<article
|
||||
className="heartbeat-center__suggestion"
|
||||
key={task.id}
|
||||
>
|
||||
<header>
|
||||
<span>{taskStatusLabels[task.status]}</span>
|
||||
<small>{formatDateTime(task.createdAt)}</small>
|
||||
</header>
|
||||
<strong>{task.title}</strong>
|
||||
<p
|
||||
className={
|
||||
expandedSuggestionId === task.id
|
||||
? 'heartbeat-center__suggestion-content--expanded'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{task.instructions}
|
||||
</p>
|
||||
{task.instructions.length > 180 && (
|
||||
<button
|
||||
aria-expanded={
|
||||
expandedSuggestionId === task.id
|
||||
}
|
||||
className="heartbeat-center__link-button"
|
||||
onClick={() =>
|
||||
setExpandedSuggestionId(
|
||||
expandedSuggestionId === task.id
|
||||
? undefined
|
||||
: task.id
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{expandedSuggestionId === task.id
|
||||
? t('center.suggestions.collapseContent')
|
||||
: t('center.suggestions.expandContent')}
|
||||
</button>
|
||||
)}
|
||||
{task.status !== 'completed' &&
|
||||
task.status !== 'cancelled' ? (
|
||||
<div>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => onUseFollowUpTask(task)}
|
||||
type="button"
|
||||
>
|
||||
{t('center.suggestions.useInConversation')}
|
||||
<ChevronRight aria-hidden="true" size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={pendingAction !== undefined}
|
||||
onClick={() =>
|
||||
void runAction(
|
||||
`task:${task.id}:completed`,
|
||||
() =>
|
||||
onSetTaskStatus(task.id, 'completed')
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<CheckCircle2 aria-hidden="true" size={14} />
|
||||
{t('center.suggestions.markCompleted')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={pendingAction !== undefined}
|
||||
onClick={() =>
|
||||
void runAction(
|
||||
`task:${task.id}:cancelled`,
|
||||
() =>
|
||||
onSetTaskStatus(task.id, 'cancelled')
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('center.suggestions.ignoreSuggestion')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasHeartbeatActivity && (
|
||||
{orderedEntries.length > 0 && (
|
||||
<div
|
||||
className="heartbeat-center__panel"
|
||||
id="heartbeat-panel-history"
|
||||
@@ -1127,76 +588,6 @@ function HeartbeatSections({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasHeartbeatActivity && <section
|
||||
aria-labelledby="heartbeat-runs-title"
|
||||
className="heartbeat-center__section"
|
||||
>
|
||||
<div className="heartbeat-center__section-heading">
|
||||
<div>
|
||||
<h2 id="heartbeat-runs-title">
|
||||
{t('center.history.auditTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<span>
|
||||
{t('center.history.runCount', {
|
||||
count: orderedRuns.length,
|
||||
formattedCount: formatCount(orderedRuns.length)
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{orderedRuns.length === 0 ? (
|
||||
<p className="heartbeat-center__section-empty">
|
||||
{t('center.history.emptyRuns')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="heartbeat-center__run-list">
|
||||
{orderedRuns.slice(0, visibleRunCount).map((run) => (
|
||||
<li
|
||||
className={`heartbeat-center__run heartbeat-center__run--${run.status}`}
|
||||
key={run.id}
|
||||
>
|
||||
{run.status === 'completed' ? (
|
||||
<CheckCircle2 aria-hidden="true" size={16} />
|
||||
) : run.status === 'failed' ? (
|
||||
<XCircle aria-hidden="true" size={16} />
|
||||
) : (
|
||||
<Clock3 aria-hidden="true" size={16} />
|
||||
)}
|
||||
<span>
|
||||
<strong>{runStatusLabels[run.status]}</strong>
|
||||
<small>
|
||||
{run.trigger === 'manual'
|
||||
? t('center.history.manualRun')
|
||||
: t('center.history.scheduledRun')}{' '}
|
||||
·{' '}
|
||||
{formatDateTime(run.scheduledFor)}
|
||||
{run.attemptCount > 1
|
||||
? ` · ${t('center.history.attempt', {
|
||||
count: run.attemptCount,
|
||||
formattedCount: formatCount(
|
||||
run.attemptCount
|
||||
)
|
||||
})}`
|
||||
: ''}
|
||||
</small>
|
||||
{run.error && <em>{run.error}</em>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{orderedRuns.length > visibleRunCount && (
|
||||
<button
|
||||
className="secondary-button heartbeat-center__load-more"
|
||||
onClick={() =>
|
||||
setVisibleRunCount((count) => count + 20)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('center.history.loadMoreRuns')}
|
||||
</button>
|
||||
)}
|
||||
</section>}
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AssistantHeartbeatConfig,
|
||||
type AssistantProject,
|
||||
type HeartbeatCreateInput,
|
||||
type HeartbeatIntervention,
|
||||
type HeartbeatUpdateInput
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
@@ -71,6 +72,7 @@ export function HeartbeatSettings({
|
||||
>([])
|
||||
const [lookbackHours, setLookbackHours] = useState(48)
|
||||
const [retentionDays, setRetentionDays] = useState(90)
|
||||
const [intervention, setIntervention] = useState<HeartbeatIntervention>('suggest')
|
||||
const [pendingAction, setPendingAction] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [confirmingRemoveId, setConfirmingRemoveId] =
|
||||
@@ -108,6 +110,7 @@ export function HeartbeatSettings({
|
||||
setSelectedProjectIds([])
|
||||
setLookbackHours(48)
|
||||
setRetentionDays(90)
|
||||
setIntervention('suggest')
|
||||
}
|
||||
|
||||
const editHeartbeat = (heartbeat: AssistantHeartbeatConfig): void => {
|
||||
@@ -132,6 +135,7 @@ export function HeartbeatSettings({
|
||||
)
|
||||
setLookbackHours(heartbeat.lookbackHours)
|
||||
setRetentionDays(heartbeat.retentionDays)
|
||||
setIntervention(heartbeat.intervention ?? 'suggest')
|
||||
}
|
||||
|
||||
const runAction = async (
|
||||
@@ -173,7 +177,8 @@ export function HeartbeatSettings({
|
||||
: heartbeats.find((heartbeat) => heartbeat.id === editingId)
|
||||
?.enabled ?? true,
|
||||
lookbackHours,
|
||||
retentionDays
|
||||
retentionDays,
|
||||
intervention
|
||||
})
|
||||
|
||||
const hasUnavailableProject = selectedProjectIds.some(
|
||||
@@ -211,17 +216,17 @@ export function HeartbeatSettings({
|
||||
return (
|
||||
<div className="heartbeat-settings">
|
||||
<div className="heartbeat-settings__intro">
|
||||
{heartbeats.length === 0
|
||||
? <p role="status">{t('settings.empty')}</p>
|
||||
: heartbeats.every((heartbeat) => !heartbeat.enabled)
|
||||
? <p role="status">{t('settings.allPaused')}</p>
|
||||
: <p>{t('settings.scheduleHelp')}</p>}
|
||||
<button className="primary-button" type="button" disabled={pendingAction !== undefined} onClick={() => {
|
||||
resetForm()
|
||||
setDirty(false)
|
||||
setError(undefined)
|
||||
setOpen(true)
|
||||
}}>{t('settings.createTitle')}</button>
|
||||
<p>{t('settings.scheduleHelp')}</p>
|
||||
{heartbeats.length === 0 && <p role="status">{t('settings.empty')}</p>}
|
||||
{heartbeats.length > 0 && heartbeats.every((heartbeat) => !heartbeat.enabled) && (
|
||||
<p role="status">{t('settings.allPaused')}</p>
|
||||
)}
|
||||
</div>
|
||||
{open && createPortal(<div className="custom-task-dialog" onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !confirmDiscard) close()
|
||||
@@ -323,6 +328,27 @@ export function HeartbeatSettings({
|
||||
</small>
|
||||
)}
|
||||
</fieldset>
|
||||
<fieldset className="heartbeat-settings__scope">
|
||||
<legend>
|
||||
<span className="inline-help-label">
|
||||
{t('settings.intervention.legend')}
|
||||
<InlineHelp label={t('settings.intervention.legend')}>
|
||||
{t('settings.intervention.help')}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
</legend>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('settings.intervention.legend')}
|
||||
disabled={pendingAction !== undefined}
|
||||
onChange={(value) => { setIntervention(value); setDirty(true) }}
|
||||
options={[
|
||||
{ label: t('settings.intervention.suggest'), value: 'suggest' },
|
||||
{ label: t('settings.intervention.memory'), value: 'memory' }
|
||||
]}
|
||||
value={intervention}
|
||||
/>
|
||||
<small>{t(`settings.intervention.${intervention}Hint`)}</small>
|
||||
</fieldset>
|
||||
<div
|
||||
className={`heartbeat-settings__form${
|
||||
recurrence === 'weekly'
|
||||
@@ -480,7 +506,7 @@ export function HeartbeatSettings({
|
||||
</small>
|
||||
<small>{t('settings.windowSummary', {
|
||||
hours: heartbeat.lookbackHours, days: heartbeat.retentionDays
|
||||
})}</small>
|
||||
})} · {t(`settings.intervention.${heartbeat.intervention ?? 'suggest'}`)}</small>
|
||||
<small>
|
||||
{heartbeat.enabled
|
||||
? t('settings.running')
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Lightbulb } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AssistantTask } from '../../shared/assistant-contracts'
|
||||
import type { SupervisionSuggestion } from '../../shared/supervision-contracts'
|
||||
|
||||
/**
|
||||
* Suggestions the heartbeat derived from published graph changes. Each keeps
|
||||
* its evidence; accepting an open item creates a paused task, accepting a
|
||||
* convention makes it long-term background.
|
||||
*/
|
||||
export function SupervisionSuggestionsPanel({ tasks, onUseFollowUpTask, onOpenResult, onOpenConversation, reloadKey = 0 }: {
|
||||
/** Changes when the page-level refresh runs; the panel has no refresh control of its own. */
|
||||
reloadKey?: number
|
||||
tasks: AssistantTask[]
|
||||
onUseFollowUpTask: (task: AssistantTask) => void
|
||||
onOpenResult?: (resultId: string) => void
|
||||
onOpenConversation?: (conversationId: string) => void
|
||||
}): React.JSX.Element | null {
|
||||
const { t, i18n } = useTranslation('heartbeat')
|
||||
const api = window.goodbuddy?.supervision
|
||||
const [items, setItems] = useState<SupervisionSuggestion[]>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [pending, setPending] = useState<string>()
|
||||
const [evidence, setEvidence] = useState<{ suggestionId: string; sources: Array<{ id: string; title: string; content: string; conversationId?: string }> }>()
|
||||
const generation = useRef(0)
|
||||
const load = useCallback(async () => {
|
||||
if (!api?.suggestions) return
|
||||
const current = ++generation.current
|
||||
try {
|
||||
const next = await api.suggestions({ status: 'pending', limit: 50 })
|
||||
if (current === generation.current) { setItems(next); setError(undefined) }
|
||||
} catch (reason) {
|
||||
if (current === generation.current) setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
|
||||
}
|
||||
}, [api, t])
|
||||
useEffect(() => {
|
||||
const counter = generation
|
||||
// Defer the first read so state is only set from an async callback.
|
||||
const timer = setTimeout(() => void load(), 0)
|
||||
return () => { clearTimeout(timer); counter.current++ }
|
||||
}, [load, reloadKey])
|
||||
if (!api?.suggestions || (!items?.length && !error)) return null
|
||||
const act = async (item: SupervisionSuggestion, action: 'accept' | 'dismiss') => {
|
||||
setPending(item.id)
|
||||
setError(undefined)
|
||||
try {
|
||||
const resolved = await api.suggestionAction({ id: item.id, action })
|
||||
setItems(current => current?.filter(candidate => candidate.id !== item.id))
|
||||
const task = resolved.taskId ? tasks.find(candidate => candidate.id === resolved.taskId) : undefined
|
||||
if (task && action === 'accept') onUseFollowUpTask(task)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
|
||||
} finally { setPending(undefined) }
|
||||
}
|
||||
const showEvidence = async (item: SupervisionSuggestion) => {
|
||||
if (evidence?.suggestionId === item.id) { setEvidence(undefined); return }
|
||||
setError(undefined)
|
||||
try {
|
||||
const rows = await Promise.all(item.sourceIds.slice(0, 5).map(id => api.source(id)))
|
||||
setEvidence({ suggestionId: item.id, sources: rows.flatMap((row, index) => row ? [{ id: item.sourceIds[index]!, title: String(row.title),
|
||||
content: String(row.content), conversationId: row.sourceType === 'conversation' && typeof row.sourceId === 'string' ? row.sourceId : undefined }] : []) })
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
|
||||
}
|
||||
}
|
||||
const date = (value: string) => new Date(value).toLocaleString(i18n.resolvedLanguage || 'zh-CN',
|
||||
{ month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
return <section aria-labelledby="supervision-suggestions-title" className="heartbeat-center__section supervision-suggestions">
|
||||
<div className="heartbeat-center__section-heading">
|
||||
<div><h2 id="supervision-suggestions-title"><Lightbulb aria-hidden="true" size={16} />{t('suggestions.title')}</h2></div>
|
||||
</div>
|
||||
{error && <p className="heartbeat-center__error" role="alert">{error}</p>}
|
||||
<ul className="supervision-suggestions__list">
|
||||
{items?.map(item => <li key={item.id}>
|
||||
<div>
|
||||
<span className="supervision-suggestions__kind">{t(`suggestions.kind.${item.kind}`)}</span>
|
||||
<time dateTime={item.createdAt}>{date(item.createdAt)}</time>
|
||||
</div>
|
||||
<strong>{item.title}</strong>
|
||||
{item.detail && item.detail !== item.title && <p>{item.detail}</p>}
|
||||
<div className="supervision-suggestions__evidence">
|
||||
<button type="button" className="link-button" aria-expanded={evidence?.suggestionId === item.id}
|
||||
disabled={!item.sourceIds.length} onClick={() => void showEvidence(item)}>{t('suggestions.evidence', { count: item.sourceIds.length })}</button>
|
||||
{item.resultId && onOpenResult && <button type="button" className="link-button" onClick={() => onOpenResult(item.resultId!)}>{t('supervisor.viewInGraph')}</button>}
|
||||
</div>
|
||||
{evidence?.suggestionId === item.id && <ul className="supervision-suggestions__sources">
|
||||
{evidence.sources.map(source => <li key={source.id}>
|
||||
<strong>{source.title}</strong>
|
||||
<p>{source.content}</p>
|
||||
{source.conversationId && onOpenConversation && <button type="button" className="link-button"
|
||||
onClick={() => onOpenConversation(source.conversationId!)}>{t('supervisor.openConversation')}</button>}
|
||||
</li>)}
|
||||
{item.sourceIds.length > evidence.sources.length && <li><small>{t('suggestions.moreEvidence', { count: item.sourceIds.length - evidence.sources.length })}</small></li>}
|
||||
</ul>}
|
||||
<div className="supervision-suggestions__actions">
|
||||
<button type="button" className="primary-button" disabled={pending !== undefined}
|
||||
onClick={() => void act(item, 'accept')}>{t(`suggestions.accept.${item.kind}`)}</button>
|
||||
<button type="button" className="secondary-button" disabled={pending !== undefined}
|
||||
onClick={() => void act(item, 'dismiss')}>{t('suggestions.dismiss')}</button>
|
||||
</div>
|
||||
</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
@@ -94,6 +94,18 @@ it('allows cancelling a resumed review before the resume request settles', async
|
||||
expect(screen.queryByRole('button', { name: '继续已保存回顾' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a failed suggestion step as an updated review and retries only suggestions', async () => {
|
||||
const activity = vi.fn().mockResolvedValue([{ ...row, status: 'completed', supervisionStatus: 'completed', resultId: 'r1',
|
||||
suggestionStatus: 'failed', suggestionError: 'Phrase failed', suggestionCount: 0, completedAt: '2026-09-23T10:00:01Z' }])
|
||||
const retrySuggestions = vi.fn().mockResolvedValue(2)
|
||||
vi.stubGlobal('goodbuddy', { supervision: { activity, execution: vi.fn().mockResolvedValue({ active: false }), retrySuggestions } })
|
||||
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('回顾已更新,建议生成失败。')
|
||||
expect(screen.getByRole('button', { name: '查看回顾' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新生成建议' }))
|
||||
await waitFor(() => expect(retrySuggestions).toHaveBeenCalledWith({ heartbeatRunId: row.id }))
|
||||
})
|
||||
|
||||
it('shows no-change execution without a new result action', async () => {
|
||||
vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{
|
||||
...row, status: 'no_change', heartbeatStatus: 'no_change', supervisionStatus: 'no_change',
|
||||
@@ -101,7 +113,9 @@ it('shows no-change execution without a new result action', async () => {
|
||||
}]) } })
|
||||
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
|
||||
expect(await screen.findByText('无变化(未调用模型)')).toBeVisible()
|
||||
expect(screen.getByText('心跳报告: 无变化(未调用模型)')).toBeVisible()
|
||||
// The heartbeat has no report stage of its own; only the review stage is shown.
|
||||
expect(screen.queryByText(/心跳报告/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('监督回顾: 无变化(未调用模型)')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: '查看回顾' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -176,7 +190,7 @@ it('polls sequentially only while active and ignores late responses after leavin
|
||||
const view = render(<SupervisorActivity {...props} />)
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(screen.getByText('运行中')).toBeVisible()
|
||||
expect(screen.getByText('心跳报告: 已完成')).toBeVisible()
|
||||
expect(screen.queryByText(/心跳报告/)).not.toBeInTheDocument()
|
||||
await act(() => vi.advanceTimersByTimeAsync(2000))
|
||||
expect(screen.getByRole('button', { name: '刷新' })).toBeDisabled()
|
||||
await act(() => vi.advanceTimersByTimeAsync(20000))
|
||||
|
||||
@@ -76,6 +76,20 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
setRefresh((value) => value + 1)
|
||||
}
|
||||
const date = (value: string) => new Date(value).toLocaleString(i18n.resolvedLanguage)
|
||||
// The review stays published; only the suggestion step is repeated.
|
||||
const retrySuggestions = async (heartbeatRunId: string) => {
|
||||
if (actionPending.current) return
|
||||
actionPending.current = true
|
||||
setPending(heartbeatRunId)
|
||||
setError(undefined)
|
||||
try { await api.retrySuggestions({ heartbeatRunId }) }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) }
|
||||
finally {
|
||||
actionPending.current = false
|
||||
setPending(undefined)
|
||||
setRefresh(value => value + 1)
|
||||
}
|
||||
}
|
||||
const control = async (row: SupervisionActivity, action: 'pause' | 'cancel' | 'resume') => {
|
||||
if (actionPending.current || !row.reviewProgress) return
|
||||
const runId = row.reviewProgress.runId
|
||||
@@ -147,7 +161,6 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
<p className="supervisor-activity__scope">{!row.scope ? t('activity.unknownScope') : row.scope.kind === 'global' ? t('center.scope.global') : t('supervisor.projectScope', { names: row.scope.projectIds.map((id) => projects.find((project) => project.id === id)?.name ?? t('settings.scope.unavailableProject')).join(', ') })} · {t(`activity.triggers.${row.trigger}`)}</p>
|
||||
{!row.reviewProgress && <>
|
||||
<p className="supervisor-activity__stages">
|
||||
{row.heartbeatStatus && <span>{t('activity.heartbeatStage')}: {t(`statuses.run.${row.heartbeatStatus}`)}</span>}
|
||||
<span>{t('activity.supervisionStage')}: {row.supervisionStatus ? t(`activity.status.${row.supervisionStatus}`) : t('activity.notRecorded')}</span>
|
||||
</p>
|
||||
</>}
|
||||
@@ -170,6 +183,12 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
<pre tabIndex={0} aria-label={t('reviewSettings.diagnostics')} style={{ userSelect: 'text' }}>{row.error}</pre>
|
||||
</details>
|
||||
</div>}
|
||||
{row.suggestionStatus && <p className="supervisor-activity__suggestion" role={row.suggestionStatus === 'failed' ? 'alert' : undefined}>
|
||||
{row.suggestionStatus === 'failed' ? t('activity.updatedSuggestionFailed')
|
||||
: `${t('activity.suggestionStage')}: ${t(`activity.suggestionStates.${row.suggestionStatus}`, { count: row.suggestionCount ?? 0 })}`}
|
||||
{row.suggestionStatus === 'failed' && typeof api?.retrySuggestions === 'function' && <button type="button" className="secondary-button" disabled={!!pending}
|
||||
onClick={() => void retrySuggestions(row.id)}>{t('activity.retrySuggestions')}</button>}
|
||||
</p>}
|
||||
{row.resultId && <div className="supervisor-workspace__actions">
|
||||
<button className="secondary-button" type="button" onClick={() => onOpenResult(row.resultId!, 'overview')}>{t('activity.openReview')}</button>
|
||||
<button className="secondary-button" type="button" onClick={() => onOpenResult(row.resultId!, 'graph')}>{t('supervisor.viewInGraph')}</button>
|
||||
@@ -183,7 +202,6 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
</dl>
|
||||
{row.reviewProgress && <p>{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}</p>}
|
||||
{row.reviewProgress?.navigationNodes !== undefined && <p>{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}</p>}
|
||||
{row.reviewProgress && row.heartbeatStatus && <p>{t('activity.heartbeatStage')}: {t(`statuses.run.${row.heartbeatStatus}`)}</p>}
|
||||
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
|
||||
{row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary>
|
||||
<p>{t('reviewSettings.configurationValues', row.reviewProgress.settings)}</p>
|
||||
|
||||
@@ -60,12 +60,12 @@ describe('SupervisorWorkspace', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '故事线图谱' }))
|
||||
expect(onTabChange).toHaveBeenCalledWith('graph')
|
||||
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||
expect(recap.textContent).toBe(frozenText)
|
||||
expect(screen.getByText('Important final conclusion.')).toBeVisible()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('新回顾正在整理')
|
||||
expect(screen.getByRole('button', { name: '回顾当前进展' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '回顾' })).toBeEnabled()
|
||||
expect(screen.getByLabelText('关注范围')).toBeEnabled()
|
||||
expect(screen.getByLabelText('时间范围')).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()
|
||||
@@ -75,7 +75,7 @@ describe('SupervisorWorkspace', () => {
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
expect(screen.getByRole('status')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: '活动记录' }))
|
||||
expect(onOpenActivity).toHaveBeenCalledOnce()
|
||||
@@ -97,11 +97,11 @@ describe('SupervisorWorkspace', () => {
|
||||
await act(() => vi.advanceTimersByTimeAsync(2000))
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' })))
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾' })))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾')
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
execution.mockResolvedValue({ active: false })
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' })))
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: '回顾' })))
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
view.unmount()
|
||||
@@ -116,10 +116,10 @@ describe('SupervisorWorkspace', () => {
|
||||
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
|
||||
render(<SupervisorWorkspace onOpenActivity={vi.fn()} />)
|
||||
await screen.findByText('还没有成功回顾')
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('新回顾正在整理'))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '回顾当前进展' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '回顾' })).toBeEnabled()
|
||||
})
|
||||
it('graph navigation discards a late overview before it can request the old graph', async () => {
|
||||
let resolveOverview!: (value: unknown) => void
|
||||
@@ -246,9 +246,32 @@ describe('SupervisorWorkspace', () => {
|
||||
} } as never
|
||||
render(<SupervisorWorkspace />)
|
||||
await waitFor(() => expect(screen.getByText('还没有成功回顾')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
await waitFor(() => expect(window.goodbuddy.supervision.run).toHaveBeenCalledOnce())
|
||||
expect(window.goodbuddy.supervision.run).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'manual', scope: { kind: 'global' } }))
|
||||
// The default review is incremental; it never asks to re-read the interval.
|
||||
expect(vi.mocked(window.goodbuddy.supervision.run).mock.calls[0]![0]).not.toHaveProperty('reanalyze')
|
||||
})
|
||||
|
||||
it('reanalyzes only after an explicit confirmation from the more menu', async () => {
|
||||
const run = vi.fn(async () => undefined)
|
||||
window.goodbuddy = { supervision: {
|
||||
overview: vi.fn(async () => []), graph: vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })),
|
||||
run, source: vi.fn()
|
||||
} } as never
|
||||
render(<SupervisorWorkspace />)
|
||||
await screen.findByText('还没有成功回顾')
|
||||
fireEvent.click(screen.getByRole('button', { name: '更多回顾操作' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: '重新整理…' }))
|
||||
const dialog = screen.getByRole('alertdialog', { name: '重新整理这段时间?' })
|
||||
expect(dialog).toHaveTextContent('可能产生较多模型用量')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }))
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '更多回顾操作' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: '重新整理…' }))
|
||||
fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: '重新整理' }))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledWith(expect.objectContaining({ trigger: 'manual', reanalyze: true })))
|
||||
})
|
||||
|
||||
it('keeps review failures retryable even with a selected graph event', async () => {
|
||||
@@ -260,7 +283,7 @@ describe('SupervisorWorkspace', () => {
|
||||
} } as never
|
||||
render(<SupervisorWorkspace />)
|
||||
await screen.findByText('还没有成功回顾')
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾当前进展' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Review provider unavailable')
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试回顾' }))
|
||||
await waitFor(() => expect(run).toHaveBeenCalledTimes(2))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BookOpen, Network, RefreshCw, X } from 'lucide-react'
|
||||
import { BookOpen, Ellipsis, Network, RefreshCw, X } from 'lucide-react'
|
||||
import { AnchoredMenu } from './AnchoredMenu'
|
||||
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
|
||||
import { EmptyState, PageTabs } from './WorkspacePrimitives'
|
||||
import { SupervisionDiscussion } from './SupervisionDiscussion'
|
||||
@@ -80,6 +81,10 @@ export function SupervisorWorkspace({
|
||||
const [running, setRunning] = useState(false)
|
||||
const runPending = useRef(false)
|
||||
const [dismissedNotice, setDismissedNotice] = useState(false)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [confirmReanalyze, setConfirmReanalyze] = useState(false)
|
||||
const moreRef = useRef<HTMLButtonElement>(null)
|
||||
const moreId = useId()
|
||||
const [resultId, setResultId] = useState<string | undefined>(graphNavigation?.resultId)
|
||||
const [appliedNavigation, setAppliedNavigation] = useState(graphNavigation)
|
||||
if (appliedNavigation !== graphNavigation) {
|
||||
@@ -207,8 +212,10 @@ export function SupervisorWorkspace({
|
||||
return () => { disposed = true; clearTimeout(timer) }
|
||||
}, [api, t])
|
||||
|
||||
const run = async () => {
|
||||
// A review is incremental by default. Only an explicit re-analysis reads the whole interval again.
|
||||
const run = async (reanalyze = false) => {
|
||||
if (!api) return
|
||||
setConfirmReanalyze(false)
|
||||
setDismissedNotice(false)
|
||||
if (runPending.current) return
|
||||
runPending.current = true
|
||||
@@ -216,6 +223,7 @@ export function SupervisorWorkspace({
|
||||
const to = new Date()
|
||||
const request: SupervisionRunRequest = {
|
||||
trigger: 'manual',
|
||||
...(reanalyze ? { reanalyze: true } : {}),
|
||||
scope:
|
||||
projectId === 'global'
|
||||
? { kind: 'global' }
|
||||
@@ -517,7 +525,7 @@ export function SupervisorWorkspace({
|
||||
className="secondary-button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
errorAction === 'run' ? void run() : setError(undefined)
|
||||
errorAction === 'run' ? void run(lastRequest?.reanalyze === true) : setError(undefined)
|
||||
}
|
||||
>
|
||||
{errorAction === 'run'
|
||||
@@ -571,7 +579,27 @@ export function SupervisorWorkspace({
|
||||
>
|
||||
{t('center.actions.refresh')}
|
||||
</button>
|
||||
<button ref={moreRef} type="button" className="icon-button" aria-label={t('supervisor.more')} title={t('supervisor.more')}
|
||||
aria-haspopup="menu" aria-expanded={moreOpen} aria-controls={moreOpen ? moreId : undefined}
|
||||
onClick={() => setMoreOpen(!moreOpen)}><Ellipsis size={16} aria-hidden="true" /></button>
|
||||
{moreOpen && <AnchoredMenu anchorRef={moreRef} id={moreId} label={t('supervisor.more')} width={220} onClose={() => setMoreOpen(false)}>
|
||||
<button type="button" role="menuitem" onClick={() => {
|
||||
setMoreOpen(false)
|
||||
moreRef.current?.focus()
|
||||
setConfirmReanalyze(true)
|
||||
}}>{t('supervisor.reanalyze')}</button>
|
||||
</AnchoredMenu>}
|
||||
</div>
|
||||
{confirmReanalyze && <div className="supervisor-workspace__confirm" role="alertdialog" aria-labelledby={`${moreId}-title`}
|
||||
aria-describedby={`${moreId}-description`} onKeyDown={(event) => { if (event.key === 'Escape') setConfirmReanalyze(false) }}>
|
||||
<strong id={`${moreId}-title`}>{t('supervisor.reanalyzeTitle')}</strong>
|
||||
<p id={`${moreId}-description`}>{t('supervisor.reanalyzeHint', { scope: projectId === 'global' ? t('center.scope.global')
|
||||
: projects.find(project => project.id === projectId)?.name ?? '', period: t('supervisor.days', { count: days }) })}</p>
|
||||
<div>
|
||||
<button type="button" className="secondary-button" autoFocus onClick={() => setConfirmReanalyze(false)}>{t('supervisor.cancel')}</button>
|
||||
<button type="button" className="primary-button" onClick={() => void run(true)}>{t('supervisor.reanalyzeConfirm')}</button>
|
||||
</div>
|
||||
</div>}
|
||||
{results.length > 0 && <div className="supervisor-workspace__result-navigation">
|
||||
<label>
|
||||
{t('supervisor.history')}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const activity = {
|
||||
source: 'Source'
|
||||
},
|
||||
sources: {
|
||||
heartbeat: 'Auto-supervision',
|
||||
heartbeat: 'Smart heartbeat',
|
||||
supervision: 'Supervisor',
|
||||
magicNotes: 'Magic notes',
|
||||
knowledge: 'Knowledge base',
|
||||
|
||||
@@ -23,7 +23,7 @@ export const app = {
|
||||
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
|
||||
descriptions: {
|
||||
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
|
||||
heartbeat: 'Review work evolution, explore the story graph, and configure automatic supervision.', 'local-inference': 'Inspect local inference tasks and execution services.'
|
||||
heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.'
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
|
||||
@@ -26,15 +26,22 @@ export const heartbeat = {
|
||||
runError: 'This run failed. Review the error details before continuing; saved batches remain available.',
|
||||
diagnostics: 'Error details'
|
||||
},
|
||||
suggestions: {
|
||||
title: 'Supervisor suggestions',
|
||||
kind: { open_item: 'Open item', conflict: 'Disagreement', convention: 'Possible convention', revision: 'Important revision' },
|
||||
evidence: 'Evidence ({{count}})', moreEvidence: '{{count}} more in the story graph',
|
||||
accept: { open_item: 'Create task', conflict: 'Reviewed', convention: 'Keep as convention', revision: 'Noted' },
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
timeouts: {
|
||||
title: 'Model timeouts and concurrency',
|
||||
report: 'Heartbeat report timeout (seconds)',
|
||||
organize: 'Supervisor organize timeout (seconds)',
|
||||
help: 'Each model timeout defaults to 240 seconds, adjustable from 30 to 600. Reports freeze the timeout when requested. Reviews save the organize timeout at creation and retain it when continued.',
|
||||
help: 'The organize timeout defaults to 240 seconds, adjustable from 30 to 600. Reviews save it at creation and retain it when continued; suggestions use the same limit.',
|
||||
transport: 'These limits cover model execution only, not collection or saving. Direct model transport has an independent 600-second timeout; these settings do not extend it. Provider or Runtime limits may stop a request earlier.',
|
||||
concurrency: 'Supervision model concurrency',
|
||||
concurrencyHelp: 'Heartbeat reports and supervisor organizing share this limit: 1 by default, adjustable from 1 to 4. Queue time does not count toward model timeouts. Lowering the limit affects new dispatches without interrupting active requests. This limits supervision only; ordinary chat is unaffected but still shares upstream model capacity.',
|
||||
invalid: 'Enter whole numbers from 30 to 600 for each timeout and from 1 to 4 for concurrency.',
|
||||
concurrencyHelp: 'Review organizing and suggestions share this limit: 1 by default, adjustable from 1 to 4. Queue time does not count toward model timeouts. Lowering the limit affects new dispatches without interrupting active requests. This limits supervision only; ordinary chat is unaffected but still shares upstream model capacity.',
|
||||
invalid: 'Enter a whole number from 30 to 600 for the timeout and from 1 to 4 for concurrency.',
|
||||
save: 'Save model settings'
|
||||
},
|
||||
activity: {
|
||||
@@ -54,14 +61,16 @@ export const heartbeat = {
|
||||
unavailablePlan: 'Unavailable plan',
|
||||
clearFilter: 'Clear filter',
|
||||
filteredEmpty: 'No executions for this plan',
|
||||
filteredEmptyHint: 'Run the plan from Automatic supervision, or clear the filter to view other activity.',
|
||||
title: 'Activity records', description: 'Saved manual reviews and automatic supervision runs. Updates while this tab is open.',
|
||||
loading: 'Loading activity', loadingHint: 'Reading saved execution records.', empty: 'No activity recorded', emptyHint: 'Run a manual review or create a plan in Automatic supervision.',
|
||||
kind: { supervision: 'Work review', heartbeat: 'Automatic supervision' },
|
||||
filteredEmptyHint: 'Run the plan from Smart heartbeat, or clear the filter to view other activity.',
|
||||
title: 'Activity records', description: 'Saved manual reviews and heartbeat runs. Updates while this tab is open.',
|
||||
loading: 'Loading activity', loadingHint: 'Reading saved execution records.', empty: 'No activity recorded', emptyHint: 'Run a manual review or create a heartbeat plan in Smart heartbeat.',
|
||||
kind: { supervision: 'Work review', heartbeat: 'Heartbeat' },
|
||||
status: { running: 'Running', completed: 'Completed', failed: 'Failed', skipped: 'Skipped', no_change: 'No changes (no model call)', paused: 'Incomplete, ready to continue', cancelled: 'Cancelled' },
|
||||
triggers: { manual: 'User', scheduled: 'Schedule', heartbeat: 'Heartbeat' },
|
||||
trigger: 'Triggered by', started: 'Started', finished: 'Finished', unknownScope: 'Execution scope not recorded',
|
||||
heartbeatStage: 'Heartbeat report', supervisionStage: 'Supervision review', notRecorded: 'No recorded execution',
|
||||
heartbeatStage: 'Heartbeat trigger', supervisionStage: 'Supervision review', notRecorded: 'No recorded execution',
|
||||
suggestionStage: 'Suggestions', suggestionStates: { running: 'Generating', completed: '{{count}} generated', skipped: 'Nothing to suggest', failed: 'Failed' },
|
||||
retrySuggestions: 'Regenerate suggestions', updatedSuggestionFailed: 'Review updated; suggestions could not be generated.',
|
||||
openReview: 'Open review', pagination: 'Activity pages', previous: 'Newer', next: 'Older', page: 'Page {{page}}'
|
||||
},
|
||||
common: {
|
||||
@@ -70,7 +79,7 @@ export const heartbeat = {
|
||||
unknownTime: 'Unknown time'
|
||||
},
|
||||
supervisor: {
|
||||
automatic: 'Automatic supervision',
|
||||
automatic: 'Smart heartbeat',
|
||||
projectScope: 'Project: {{names}}',
|
||||
canvasTitle: 'How the work took shape', canvasNote: 'Read counter-clockwise · Select a node to explore knowledge and sources', readingGuide: 'Reading the graph',
|
||||
visibleCounts: '{{events}} events / {{entities}} entities on canvas', showAll: 'Show all connections', connections: 'Graph connections',
|
||||
@@ -83,7 +92,9 @@ export const heartbeat = {
|
||||
unavailable: 'Supervisor service is unavailable', loading: 'Loading Supervisor review', scope: 'Review scope', period: 'Time range', days: '{{count}} days',
|
||||
sourcesHint: 'Choose a scope and period for a new review. The selected result and its covered period appear below.', latest: 'Supervision result', openItems: 'Open items', history: 'Review history',
|
||||
newReview: 'New review', summary: 'Work summary', changes: 'Changes', runningHint: 'A new review is running. The existing result remains available to read.',
|
||||
empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an automatic supervision plan.', run: 'Review current progress', running: 'Reviewing…', retryRun: 'Retry review', dismiss: 'Dismiss',
|
||||
empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an heartbeat plan.', run: 'Review', running: 'Reviewing…',
|
||||
more: 'More review actions', reanalyze: 'Reanalyze…', reanalyzeTitle: 'Reanalyze this period?', reanalyzeConfirm: 'Reanalyze',
|
||||
reanalyzeHint: 'All content in {{scope}} from the last {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss',
|
||||
graphScope: 'Graph scope', graphEmpty: 'No story events in this scope', legend: 'Solid lines show event impact on entities; dashed lines show entity relations. Time runs counter-clockwise with a visible gap.', start: 'Start', end: 'End',
|
||||
listTabs: { event: 'Events', entity: 'Entities', relation: 'Relations' },
|
||||
listEmpty: 'No records of this type in this review.',
|
||||
@@ -100,7 +111,7 @@ export const heartbeat = {
|
||||
center: {
|
||||
title: 'Supervisor',
|
||||
description:
|
||||
'Review work progress and knowledge evolution manually or configure automatic supervision.',
|
||||
'Review work progress and knowledge evolution manually or configure Smart heartbeat.',
|
||||
scope: {
|
||||
currentProject: 'Current project',
|
||||
global: 'Global'
|
||||
@@ -110,30 +121,30 @@ export const heartbeat = {
|
||||
refresh: 'Refresh',
|
||||
running: 'Reviewing…',
|
||||
runOnce: 'Review now',
|
||||
configure: 'Configure automatic supervision',
|
||||
configure: 'Configure Smart heartbeat',
|
||||
retry: 'Retry'
|
||||
},
|
||||
loading: {
|
||||
description: 'Loading automatic supervision plans, runs, and reports.',
|
||||
description: 'Loading heartbeat plans and suggestions.',
|
||||
title: 'Loading Supervisor',
|
||||
failedTitle: 'Could not load Supervisor',
|
||||
refreshFailedTitle: 'Could not refresh Supervisor'
|
||||
},
|
||||
tabs: {
|
||||
ariaLabel: 'Automatic supervision settings and history',
|
||||
ariaLabel: 'Smart heartbeat settings and history',
|
||||
overview: 'Run overview',
|
||||
suggestions: 'Pending suggestions',
|
||||
history: 'Automatic reports and suggestions',
|
||||
plans: 'Automatic supervision'
|
||||
history: 'Earlier heartbeat reports',
|
||||
plans: 'Smart heartbeat'
|
||||
},
|
||||
currentStatus: {
|
||||
title: 'Current status',
|
||||
title: 'Heartbeat plans',
|
||||
activePlans: 'Active plans: {{formattedCount}}',
|
||||
disabled: 'Not enabled',
|
||||
emptyTitle: 'Automatic supervision is not configured. Only manual reviews are available.',
|
||||
emptyTitle: 'No heartbeat plan is configured. Only manual reviews are available.',
|
||||
emptyDescription:
|
||||
'Create a daily or weekly plan to review conversations and tasks in the selected scope and generate suggestions.',
|
||||
createPlan: 'Create automatic supervision plan'
|
||||
createPlan: 'Create heartbeat plan'
|
||||
},
|
||||
recurrence: {
|
||||
daily: 'Every day at {{time}}',
|
||||
@@ -157,7 +168,7 @@ export const heartbeat = {
|
||||
resume: 'Resume'
|
||||
},
|
||||
metrics: {
|
||||
ariaLabel: 'Automatic supervision run metrics',
|
||||
ariaLabel: 'Smart heartbeat run metrics',
|
||||
health: 'Run success rate',
|
||||
successfulRuns: '{{completed}}/{{total}} completed successfully',
|
||||
healthRateAriaLabel: 'Review success rate {{percent}}',
|
||||
@@ -201,16 +212,16 @@ export const heartbeat = {
|
||||
ignore: 'Ignore',
|
||||
taskTitle: 'Suggested actions',
|
||||
taskCount: 'Items: {{formattedCount}}',
|
||||
taskEmpty: 'Automatic supervision has not suggested any actions.',
|
||||
taskEmpty: 'Smart heartbeat has not suggested any actions.',
|
||||
useInConversation: 'Handle in conversation',
|
||||
markCompleted: 'Mark completed',
|
||||
ignoreSuggestion: 'Ignore suggestion'
|
||||
},
|
||||
history: {
|
||||
timelineTitle: 'Automatic supervision reports',
|
||||
timelineTitle: 'Earlier heartbeat reports',
|
||||
reportCount: 'Reports: {{formattedCount}}',
|
||||
emptyTimeline:
|
||||
'No automatic supervision reports yet. Reports appear here after a plan runs.',
|
||||
'No earlier heartbeat reports.',
|
||||
reportSummary:
|
||||
'{{insights}} insights · {{memories}} memories · {{actions}} actions',
|
||||
collapseReport: 'Collapse report',
|
||||
@@ -218,7 +229,7 @@ export const heartbeat = {
|
||||
loadMoreReports: 'Load more review reports',
|
||||
auditTitle: 'Run history',
|
||||
runCount: 'Runs: {{formattedCount}}',
|
||||
emptyRuns: 'There are no automatic supervision runs yet.',
|
||||
emptyRuns: 'There are no heartbeat runs yet.',
|
||||
manualRun: 'Manual run',
|
||||
scheduledRun: 'Scheduled run',
|
||||
attempt: 'Attempt {{formattedCount}}',
|
||||
@@ -252,20 +263,20 @@ export const heartbeat = {
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
refreshPlans: 'Refresh automatic supervision',
|
||||
refreshPlans: 'Refresh Smart heartbeat',
|
||||
refreshReports: 'Refresh reports and suggestions',
|
||||
executionHistory: 'Execution history',
|
||||
discardTitle: 'Discard unsaved plan changes?',
|
||||
discardHint: 'Closing will lose your unsaved changes.',
|
||||
keepEditing: 'Keep editing',
|
||||
discard: 'Discard changes',
|
||||
title: 'Automatic supervision',
|
||||
title: 'Smart heartbeat',
|
||||
description:
|
||||
'Review the selected scope on schedule in read-only mode without tools. You confirm and handle suggestions.',
|
||||
scheduleHelp: 'Choose a daily or weekly review time, then save and enable the plan to run automatically.',
|
||||
timezone: 'Plan time zone: {{timezone}}. New plans use this device\'s time zone; edits keep the saved time zone.',
|
||||
windowSummary: 'Review the last {{hours}} hours · Keep run history for {{days}} days',
|
||||
allPaused: 'All automatic supervision plans are paused. Only manual reviews are available.',
|
||||
allPaused: 'All heartbeat plans are paused. Only manual reviews are available.',
|
||||
recurrenceAriaLabel: 'Supervision frequency',
|
||||
recurrenceLabel: 'Frequency',
|
||||
daily: 'Daily',
|
||||
@@ -275,21 +286,27 @@ export const heartbeat = {
|
||||
timeAriaLabel: 'Supervision time',
|
||||
timeLabel: 'Time',
|
||||
nameLabel: 'Plan name',
|
||||
createTitle: 'Create automatic supervision plan',
|
||||
close: 'Close automatic supervision plan',
|
||||
editTitle: 'Edit automatic supervision plan',
|
||||
createTitle: 'Create heartbeat plan',
|
||||
close: 'Close heartbeat plan',
|
||||
editTitle: 'Edit heartbeat plan',
|
||||
cancelEdit: 'Cancel editing',
|
||||
editAriaLabel: 'Edit {{name}}',
|
||||
edit: 'Edit',
|
||||
saveAriaLabel: 'Save automatic supervision plan',
|
||||
saveAriaLabel: 'Save heartbeat plan',
|
||||
save: 'Save changes',
|
||||
lookbackLabel: 'Review window (hours)',
|
||||
lookbackAriaLabel: 'Review window (hours)',
|
||||
retentionLabel: 'History retention (days)',
|
||||
intervention: {
|
||||
legend: 'Heartbeat intervention', suggest: 'Suggest', memory: 'Update memory only',
|
||||
help: 'Each check only organizes what changed. With suggestions on, the supervisor suggests from those changes without re-reading the original conversations.',
|
||||
suggestHint: 'Suggests when there are new open items, disagreements or important revisions, each with its evidence. Stays quiet otherwise.',
|
||||
memoryHint: 'Only updates work memory and the story graph. No suggestions.'
|
||||
},
|
||||
retentionAriaLabel: 'History retention (days)',
|
||||
scope: {
|
||||
legend: 'Project scope',
|
||||
ariaLabel: 'Choose automatic supervision project scope',
|
||||
ariaLabel: 'Choose Smart heartbeat project scope',
|
||||
global: 'Global',
|
||||
projects: 'Selected projects',
|
||||
globalHelp:
|
||||
@@ -308,7 +325,7 @@ export const heartbeat = {
|
||||
enabling: 'Saving…',
|
||||
enable: 'Save and enable plan',
|
||||
defaultName: 'Scheduled review',
|
||||
empty: 'Automatic supervision is not configured. Only manual reviews are available.',
|
||||
empty: 'No heartbeat plan is configured. Only manual reviews are available.',
|
||||
running: 'Enabled',
|
||||
paused: 'Paused',
|
||||
next: 'Next: {{date}}',
|
||||
|
||||
@@ -93,7 +93,7 @@ export const activity = {
|
||||
source: '来源'
|
||||
},
|
||||
sources: {
|
||||
heartbeat: '自动监督',
|
||||
heartbeat: '智能心跳',
|
||||
supervision: '监督者',
|
||||
magicNotes: '魔法笔记',
|
||||
knowledge: '知识库',
|
||||
|
||||
@@ -20,7 +20,7 @@ export const app = {
|
||||
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
|
||||
descriptions: {
|
||||
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
|
||||
heartbeat: '回顾工作演变、查看故事线图谱,并配置自动监督。', 'local-inference': '查看本机推理任务与执行服务。'
|
||||
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
|
||||
@@ -23,15 +23,22 @@ export const heartbeat = {
|
||||
runError: '本次运行失败,请查看错误详情后继续。已保存的批次仍保留。',
|
||||
diagnostics: '错误详情'
|
||||
},
|
||||
suggestions: {
|
||||
title: '监督建议',
|
||||
kind: { open_item: '未决事项', conflict: '分歧', convention: '候选约定', revision: '重要修订' },
|
||||
evidence: '查看依据({{count}} 处)', moreEvidence: '另有 {{count}} 处依据,可在图谱中查看',
|
||||
accept: { open_item: '转为任务', conflict: '已核对', convention: '确认为长期约定', revision: '已知悉' },
|
||||
dismiss: '忽略'
|
||||
},
|
||||
timeouts: {
|
||||
title: '模型超时与并发',
|
||||
report: '心跳报告超时(秒)',
|
||||
organize: '监督者整理超时(秒)',
|
||||
help: '两个模型超时默认均为 240 秒,可分别设置为 30 至 600 的整数秒。报告在发起时固定超时;回顾在创建时保存整理超时,继续时沿用。',
|
||||
help: '整理超时默认 240 秒,可设置为 30 至 600 的整数秒。回顾在创建时保存整理超时,继续时沿用;生成建议使用同一上限。',
|
||||
transport: '仅限制模型执行阶段,不包含数据收集和保存。直连模型传输另有独立的 600 秒超时,此处设置不会延长该限制;服务商或 Runtime 的限制也可能使请求提前结束。',
|
||||
concurrency: '监督模型并发数',
|
||||
concurrencyHelp: '心跳报告与监督者整理共用此上限,默认 1,可设为 1 至 4。排队时间不计入模型超时;降低上限只影响后续调度,不中断在途请求。仅限制监督请求,普通聊天不受此设置限制,但仍与监督共用上游模型额度。',
|
||||
invalid: '请为两个超时分别输入 30 至 600 的整数秒,并发数请输入 1 至 4 的整数。',
|
||||
concurrencyHelp: '回顾整理与生成建议共用此上限,默认 1,可设为 1 至 4。排队时间不计入模型超时;降低上限只影响后续调度,不中断在途请求。仅限制监督请求,普通聊天不受此设置限制,但仍与监督共用上游模型额度。',
|
||||
invalid: '超时请输入 30 至 600 的整数秒,并发数请输入 1 至 4 的整数。',
|
||||
save: '保存模型设置'
|
||||
},
|
||||
activity: {
|
||||
@@ -51,14 +58,16 @@ export const heartbeat = {
|
||||
unavailablePlan: '计划不可用',
|
||||
clearFilter: '清除筛选',
|
||||
filteredEmpty: '此计划暂无执行记录',
|
||||
filteredEmptyHint: '可以返回自动监督手动运行,或清除筛选查看其他活动。',
|
||||
title: '活动记录', description: '记录手动回顾和自动监督的实际执行,停留在此页签时自动刷新。',
|
||||
loading: '正在读取活动', loadingHint: '读取已保存的执行记录。', empty: '暂无活动记录', emptyHint: '可手动回顾当前进展,或在自动监督中创建计划。',
|
||||
kind: { supervision: '工作回顾', heartbeat: '自动监督' },
|
||||
filteredEmptyHint: '可以返回智能心跳手动运行,或清除筛选查看其他活动。',
|
||||
title: '活动记录', description: '记录手动回顾和智能心跳的实际执行,停留在此页签时自动刷新。',
|
||||
loading: '正在读取活动', loadingHint: '读取已保存的执行记录。', empty: '暂无活动记录', emptyHint: '可手动回顾当前进展,或在智能心跳中创建心跳计划。',
|
||||
kind: { supervision: '工作回顾', heartbeat: '心跳' },
|
||||
status: { running: '运行中', completed: '已完成', failed: '失败', skipped: '已跳过', no_change: '无变化(未调用模型)', paused: '未完成,可继续', cancelled: '已取消' },
|
||||
triggers: { manual: '用户触发', scheduled: '定时计划', heartbeat: '心跳触发' },
|
||||
trigger: '触发方式', started: '开始时间', finished: '结束时间', unknownScope: '未保存执行范围',
|
||||
heartbeatStage: '心跳报告', supervisionStage: '监督回顾', notRecorded: '无执行记录',
|
||||
heartbeatStage: '心跳触发', supervisionStage: '监督回顾', notRecorded: '无执行记录',
|
||||
suggestionStage: '建议', suggestionStates: { running: '生成中', completed: '已生成 {{count}} 条', skipped: '无需建议', failed: '生成失败' },
|
||||
retrySuggestions: '重新生成建议', updatedSuggestionFailed: '回顾已更新,建议生成失败。',
|
||||
openReview: '查看回顾', pagination: '活动分页', previous: '较新记录', next: '更早记录', page: '第 {{page}} 页'
|
||||
},
|
||||
common: {
|
||||
@@ -67,7 +76,7 @@ export const heartbeat = {
|
||||
unknownTime: '时间未知'
|
||||
},
|
||||
supervisor: {
|
||||
automatic: '自动监督',
|
||||
automatic: '智能心跳',
|
||||
projectScope: '项目:{{names}}',
|
||||
canvasTitle: '工作的来路', canvasNote: '沿外圈逆时针阅读 · 选择节点,查看知识与来源', readingGuide: '如何读图',
|
||||
visibleCounts: '画布中 {{events}} 个事件 / {{entities}} 个实体', showAll: '显示全部关联', connections: '图谱关联',
|
||||
@@ -80,7 +89,9 @@ export const heartbeat = {
|
||||
unavailable: '监督者服务暂不可用', loading: '正在读取监督回顾', scope: '关注范围', period: '时间范围', days: '{{count}} 天',
|
||||
sourcesHint: '选择范围和时间发起新回顾;下方显示所选结果及其覆盖区间。', latest: '监督结果', openItems: '未解决事项', history: '历史结果',
|
||||
newReview: '新回顾', summary: '工作总结', changes: '本次变化', runningHint: '新回顾正在整理,已有结果仍可阅读。',
|
||||
empty: '还没有成功回顾', emptyHint: '即使没有自动监督计划,也可以手动回顾当前进展。', run: '回顾当前进展', running: '回顾整理中…', retryRun: '重试回顾', dismiss: '关闭提示',
|
||||
empty: '还没有成功回顾', emptyHint: '即使没有心跳计划,也可以手动回顾当前进展。', run: '回顾', running: '回顾整理中…',
|
||||
more: '更多回顾操作', reanalyze: '重新整理…', reanalyzeTitle: '重新整理这段时间?', reanalyzeConfirm: '重新整理',
|
||||
reanalyzeHint: '将重新分析{{scope}}最近 {{period}}的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示',
|
||||
graphScope: '图谱范围', graphEmpty: '当前范围没有故事线事件', legend: '实线表示事件影响实体,虚线表示实体关系。时间轴逆时针排列,起止之间保留缺口。', start: '起点', end: '终点',
|
||||
listTabs: { event: '事件', entity: '实体', relation: '关系' },
|
||||
listEmpty: '本次回顾暂无此类记录。',
|
||||
@@ -96,7 +107,7 @@ export const heartbeat = {
|
||||
},
|
||||
center: {
|
||||
title: '监督者',
|
||||
description: '回顾工作进展、追踪知识演变;可手动回顾,也可配置自动监督计划。',
|
||||
description: '回顾工作进展、追踪知识演变;可手动回顾,也可配置智能心跳定期回顾。',
|
||||
scope: {
|
||||
currentProject: '当前项目',
|
||||
global: '全局'
|
||||
@@ -106,30 +117,30 @@ export const heartbeat = {
|
||||
refresh: '刷新',
|
||||
running: '回顾中…',
|
||||
runOnce: '立即回顾',
|
||||
configure: '配置自动监督',
|
||||
configure: '配置智能心跳',
|
||||
retry: '重试'
|
||||
},
|
||||
loading: {
|
||||
description: '正在读取自动监督计划、运行记录和回顾报告。',
|
||||
description: '正在读取心跳计划与监督建议。',
|
||||
title: '正在加载监督者',
|
||||
failedTitle: '监督者加载失败',
|
||||
refreshFailedTitle: '监督者刷新失败'
|
||||
},
|
||||
tabs: {
|
||||
ariaLabel: '自动监督设置与记录',
|
||||
ariaLabel: '智能心跳设置与记录',
|
||||
overview: '运行概览',
|
||||
suggestions: '待处理建议',
|
||||
history: '自动监督报告与建议',
|
||||
plans: '自动监督'
|
||||
history: '历史心跳报告',
|
||||
plans: '智能心跳'
|
||||
},
|
||||
currentStatus: {
|
||||
title: '当前状态',
|
||||
title: '心跳计划',
|
||||
activePlans: '{{formattedCount}} 个计划运行中',
|
||||
disabled: '尚未启用',
|
||||
emptyTitle: '尚未配置自动监督,目前仅支持手动回顾',
|
||||
emptyTitle: '尚未配置心跳计划,目前仅支持手动回顾',
|
||||
emptyDescription:
|
||||
'创建每日或每周计划,GoodBuddy 将按范围回顾对话和任务并生成建议。',
|
||||
createPlan: '创建自动监督计划'
|
||||
createPlan: '创建心跳计划'
|
||||
},
|
||||
recurrence: {
|
||||
daily: '每天 {{time}}',
|
||||
@@ -153,7 +164,7 @@ export const heartbeat = {
|
||||
resume: '恢复'
|
||||
},
|
||||
metrics: {
|
||||
ariaLabel: '自动监督运行统计',
|
||||
ariaLabel: '智能心跳运行统计',
|
||||
health: '运行成功率',
|
||||
successfulRuns: '{{completed}}/{{total}} 次成功完成',
|
||||
healthRateAriaLabel: '回顾成功率 {{percent}}',
|
||||
@@ -197,15 +208,15 @@ export const heartbeat = {
|
||||
ignore: '忽略',
|
||||
taskTitle: '行动建议',
|
||||
taskCount: '{{formattedCount}} 个',
|
||||
taskEmpty: '当前没有由自动监督产生的行动建议。',
|
||||
taskEmpty: '当前没有由智能心跳产生的行动建议。',
|
||||
useInConversation: '带入对话处理',
|
||||
markCompleted: '标记完成',
|
||||
ignoreSuggestion: '忽略建议'
|
||||
},
|
||||
history: {
|
||||
timelineTitle: '自动监督报告',
|
||||
timelineTitle: '历史心跳报告',
|
||||
reportCount: '{{formattedCount}} 份报告',
|
||||
emptyTimeline: '暂无自动监督报告。计划运行后,报告会显示在这里。',
|
||||
emptyTimeline: '暂无历史心跳报告。',
|
||||
reportSummary:
|
||||
'{{insights}} 条洞察 · {{memories}} 条记忆 · {{actions}} 个行动',
|
||||
collapseReport: '收起报告',
|
||||
@@ -213,7 +224,7 @@ export const heartbeat = {
|
||||
loadMoreReports: '加载更多回顾报告',
|
||||
auditTitle: '运行记录',
|
||||
runCount: '{{formattedCount}} 次',
|
||||
emptyRuns: '尚无自动监督运行记录。',
|
||||
emptyRuns: '尚无心跳运行记录。',
|
||||
manualRun: '手动运行',
|
||||
scheduledRun: '周期运行',
|
||||
attempt: '第 {{formattedCount}} 次尝试',
|
||||
@@ -247,19 +258,19 @@ export const heartbeat = {
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
refreshPlans: '刷新自动监督',
|
||||
refreshPlans: '刷新智能心跳',
|
||||
refreshReports: '刷新报告与建议',
|
||||
executionHistory: '执行记录',
|
||||
discardTitle: '放弃未保存的计划修改?',
|
||||
discardHint: '关闭后,本次未保存的修改将丢失。',
|
||||
keepEditing: '继续编辑',
|
||||
discard: '放弃修改',
|
||||
title: '自动监督',
|
||||
title: '智能心跳',
|
||||
description: '按计划只读回顾所选范围,不调用工具;建议由你确认和处理。',
|
||||
scheduleHelp: '选择每天或每周的回顾时间,保存并启用计划后自动运行。',
|
||||
timezone: '计划时区:{{timezone}}。新计划使用本机时区,编辑时保留原时区。',
|
||||
windowSummary: '回顾最近 {{hours}} 小时 · 运行历史保留 {{days}} 天',
|
||||
allPaused: '所有自动监督计划已暂停,目前仅支持手动回顾。',
|
||||
allPaused: '所有心跳计划已暂停,目前仅支持手动回顾。',
|
||||
recurrenceAriaLabel: '监督频率',
|
||||
recurrenceLabel: '频率',
|
||||
daily: '每天',
|
||||
@@ -269,21 +280,27 @@ export const heartbeat = {
|
||||
timeAriaLabel: '监督时间',
|
||||
timeLabel: '时间',
|
||||
nameLabel: '计划名称',
|
||||
createTitle: '创建自动监督计划',
|
||||
close: '关闭自动监督计划',
|
||||
editTitle: '编辑自动监督计划',
|
||||
createTitle: '创建心跳计划',
|
||||
close: '关闭心跳计划',
|
||||
editTitle: '编辑心跳计划',
|
||||
cancelEdit: '取消编辑',
|
||||
editAriaLabel: '编辑 {{name}}',
|
||||
edit: '编辑',
|
||||
saveAriaLabel: '保存自动监督计划',
|
||||
saveAriaLabel: '保存心跳计划',
|
||||
save: '保存修改',
|
||||
lookbackLabel: '回顾窗口(小时)',
|
||||
lookbackAriaLabel: '回顾窗口(小时)',
|
||||
retentionLabel: '历史保留(天)',
|
||||
intervention: {
|
||||
legend: '心跳介入方式', suggest: '生成建议', memory: '仅更新记忆',
|
||||
help: '每次检查只整理有变化的内容。生成建议时,监督者根据本次整理出的变化提出建议,不会重新读取原始会话。',
|
||||
suggestHint: '有新的未决事项、分歧或重要修订时给出建议,每条附依据;没有值得提的内容就不打扰。',
|
||||
memoryHint: '只更新工作记忆和故事线图谱,不产生建议。'
|
||||
},
|
||||
retentionAriaLabel: '历史保留(天)',
|
||||
scope: {
|
||||
legend: '项目范围',
|
||||
ariaLabel: '选择自动监督项目范围',
|
||||
ariaLabel: '选择智能心跳项目范围',
|
||||
global: '全局',
|
||||
projects: '指定项目',
|
||||
globalHelp:
|
||||
@@ -301,7 +318,7 @@ export const heartbeat = {
|
||||
enabling: '保存中…',
|
||||
enable: '保存并启用计划',
|
||||
defaultName: '定期回顾',
|
||||
empty: '尚未配置自动监督,目前仅支持手动回顾',
|
||||
empty: '尚未配置心跳计划,目前仅支持手动回顾',
|
||||
running: '已启用',
|
||||
paused: '已暂停',
|
||||
next: '下次 {{date}}',
|
||||
|
||||
@@ -16457,14 +16457,27 @@ textarea.field-control {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Plans sit directly in the section card; no second framed box. */
|
||||
.heartbeat-center__plans .heartbeat-settings {
|
||||
max-width: none;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-raised);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.heartbeat-settings__intro {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.heartbeat-settings__intro p {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.appearance-settings {
|
||||
gap: 14px;
|
||||
}
|
||||
@@ -16723,7 +16736,6 @@ textarea.field-control {
|
||||
.activity-item,
|
||||
.heartbeat-center__section,
|
||||
.heartbeat-center__metrics > div,
|
||||
.heartbeat-center__plans .heartbeat-settings,
|
||||
.heartbeat-settings__item,
|
||||
.capability-card,
|
||||
.quick-actions button
|
||||
|
||||
@@ -31,6 +31,110 @@
|
||||
.supervisor-workspace__run-status > .icon-button {
|
||||
grid-column: 3;
|
||||
}
|
||||
.supervisor-workspace__confirm {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
.supervisor-workspace__confirm p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.supervisor-workspace__confirm > div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.supervision-suggestions__list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.supervision-suggestions__list > li {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.supervision-suggestions__list > li > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
.supervision-suggestions__kind {
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
.supervision-suggestions__list > li > strong {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.supervision-suggestions__list > li > p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.supervision-suggestions__evidence,
|
||||
.supervision-suggestions__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.supervision-suggestions .link-button {
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.supervision-suggestions .link-button:hover {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
.supervision-suggestions .link-button:disabled {
|
||||
color: var(--text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
.supervision-suggestions__sources {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
list-style: none;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
.supervision-suggestions__sources p {
|
||||
max-height: 8em;
|
||||
margin: var(--space-1) 0;
|
||||
overflow: auto;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.supervisor-activity__suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.supervisor-workspace p,
|
||||
.supervisor-workspace time {
|
||||
overflow-wrap: anywhere;
|
||||
@@ -626,11 +730,18 @@
|
||||
}
|
||||
}
|
||||
@container supervisor (max-width: 420px) {
|
||||
/* Selects share the first row; review, refresh and the more menu share the second. */
|
||||
.supervisor-workspace__toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.supervisor-workspace__toolbar > label:first-of-type {
|
||||
grid-column: 1;
|
||||
}
|
||||
.supervisor-workspace__toolbar > label:nth-of-type(2) {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
.supervisor-workspace__recap,
|
||||
.supervisor-workspace__detail,
|
||||
.supervisor-workspace__source,
|
||||
|
||||
Reference in New Issue
Block a user