fix: simplify continuing work from supervisor reviews

Let users continue discussions without opening sources first, edit a question, and send source-linked review context to the target conversation. Add explicit conversation navigation and remove redundant context disclosure and entity-writeback controls. Hide empty automatic supervision metrics until activity exists.

Release note: Continue work directly from supervision feedback or graph sources with relevant context, fewer controls, and clearer automatic supervision empty states.
This commit is contained in:
mesalogo
2026-09-28 01:51:23 +08:00
parent f35198df01
commit d47f967781
17 changed files with 494 additions and 104 deletions
@@ -34,6 +34,8 @@
## 自动监督设置
没有自动运行、报告或关联建议时,只展示计划配置和已有计划,不显示零值统计、空趋势、建议、报告或审计区。手动回顾不计入自动监督统计;产生自动运行或报告后展示对应结果区。
2026-09-23 当前已实现“模型超时与并发”表单:心跳报告与监督者整理分别设置,均为 30 至 600 的整数秒,默认 240;监督模型并发数为 1 至 4 的整数,默认 1。保存真实字段 `heartbeatReportTimeoutSeconds`、`supervisorOrganizeTimeoutSeconds`、`supervisorModelConcurrency`;无效输入不能提交,设置加载/保存中或锁定时禁用,失败保留草稿并提供重试。超时以阶段读取设置的时点为准,不改变已冻结的值;并发数影响后续派发,降低上限不打断在途请求。
并发说明明确报告和监督整理共用上限,排队不计入模型超时;普通聊天不受此设置限制,但仍与监督竞争上游额度。表单不宣称提供商全局并发、RPM、TPM 或 Retry-After 限流已实现。
@@ -87,10 +89,12 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复
有匹配结果时提供“在图谱中查看”,打开现有监督者页面的图谱页签并读取该结果;无匹配结果不显示入口。按钮保留原生键盘操作,进入后焦点落在图谱页签,继续使用共享 PageTabs 的方向键导航。固定目标不因跳转改变;再次点击相同或不同结果均重新定位,包括页面缓存后再次进入。结果不可用时显示错误并重试原 ID,不展示默认最新结果。
当前“继续讨论”预览实际发送会话名称、ID 和上下文,确认后发送到该会话;固定任务使用其关联会话,无法确定发送会话时禁用。切换目标使旧来源请求和预览失效。编辑预览中的引用仍属后续工作。
“继续讨论”直接准备上下文,不要求先查看来源。就地显示目标会话、可编辑的问题和“发送时会附带相关回顾与来源”的短提示,不重复展示或折叠整段上下文;依据仍可通过查看来源核对。只有点击“发送”才提交,取消不发送,失败保留问题。固定任务使用其关联会话,无法确定发送会话时禁用。上下文仅包含所选来源关联的事件、实体、关系及原文快照,不带入整份全局回顾摘要;没有关联事件时只使用来源。切换目标使旧来源请求和预览失效。查看来源不自动跳转,用户点击“打开会话”才导航。侧栏不再显示实体写入或知识库选择器,现有知识库数据不受影响。
## 故事线图谱
关联来源详情属于会话时,显示“打开会话”按钮,使用来源保存的会话 ID 跳转并切换到所属项目;不发送消息或启动回顾。另提供“继续讨论”,先预览该来源关联的上下文、编辑问题,点击发送后提交到来源会话并打开它。详情继续展示整理时保存的原文快照。会话已删除或读取失败时,沿用应用会话导航的提示;其他来源类型不显示此按钮。
左侧使用共享 PageTabs 切换“事件 / 实体 / 关系”,显示各类数量,一次只展示一类列表,无记录时显示本次回顾的分类空态。页签固定在顶部,列表占满剩余高度并独立滚动。切换页签不清除当前图谱选择;从画布、事件浏览或详情选择对象时同步切到对应页签。支持方向键、Home/End 和面板键盘聚焦。
使用 `supervisor` 页面壳层,最大内容宽度为 `1440px`。当前生产图谱左侧为紧凑的事件、实体和关系列表,中间为 SVG 画布,右侧为详情与来源。桌面列宽为 `216px minmax(0, 1fr) 280px`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。
+52
View File
@@ -6188,6 +6188,58 @@ describe('registerIpcHandlers agent terminal state', () => {
} finally { await harness.dispose(); database.close() }
})
it('scopes supervision continuation context to source-linked graph evidence', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
const occurredAt = '2026-09-22T10:00:00.000Z'
database.saveSupervisionResult({
request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: occurredAt, to: occurredAt } },
evidence: ['selected', 'unrelated', 'unlinked'].map((id) => ({
id, sourceType: 'conversation', sourceId: `${id}-conversation`, title: `${id} source`, content: `${id} snapshot`, occurredAt
})),
output: {
summary: 'Unrelated whole-result summary', changeDigest: 'Unrelated global digest', openItems: ['Unrelated open item'],
entities: ['Atlas', 'Beacon', 'Other'].map((id) => ({
id, label: id, description: `${id} actual description`, sourceReferenceIds: [id === 'Other' ? 'unrelated' : 'selected']
})),
events: [
{ title: 'Linked decision', description: 'Linked event actual description', occurredAt, eventType: 'decision', entityIds: ['Atlas', 'Beacon'], sourceReferenceIds: ['selected'] },
{ title: 'Unrelated event', description: 'Unrelated event description', occurredAt, eventType: 'discussion', entityIds: ['Other'], sourceReferenceIds: ['unrelated'] }
],
entityChanges: [],
relations: [
{ fromEntityId: 'Atlas', toEntityId: 'Beacon', relationType: 'depends-on', reason: 'Linked relation actual reason', sourceReferenceIds: ['selected'] },
{ fromEntityId: 'Atlas', toEntityId: 'Other', relationType: 'related', reason: 'Unrelated relation reason', sourceReferenceIds: ['unrelated'] }
]
}
})
const harness = createHarness({}, undefined, 'always', undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
try {
const resultId = database.listSupervisionResults()[0]!.id
const graph = database.getSupervisionGraph({ resultId }) as import('../shared/supervision-contracts').SupervisionGraphView
const getGraph = vi.spyOn(database, 'getSupervisionGraph')
const handler = electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!
const sourceId = graph.sources.find((source) => source.title === 'selected source')!.id
const context = await handler(trustedEvent(harness.webContents), { resultId, sourceId }) as { prompt: string; summary: string; source: unknown }
expect(getGraph).toHaveBeenCalledWith({ resultId })
expect(context.source).toEqual({ title: 'selected source', sourceType: 'conversation', sourceId: 'selected-conversation', content: 'selected snapshot', occurredAt })
for (const evidence of ['Linked decision', 'Linked event actual description', 'Atlas actual description', 'Beacon actual description', 'depends-on', 'Linked relation actual reason']) {
expect(context.summary).toContain(evidence)
expect(context.prompt).toContain(evidence)
}
expect(context.prompt).toContain('selected snapshot')
for (const unrelated of ['Unrelated whole-result summary', 'Unrelated global digest', 'Unrelated open item', 'Unrelated event', 'Other actual description', 'Unrelated relation reason', 'unrelated snapshot', 'unlinked snapshot']) {
expect(context.summary).not.toContain(unrelated)
expect(context.prompt).not.toContain(unrelated)
}
const unlinkedSourceId = graph.sources.find((source) => source.title === 'unlinked source')!.id
const unlinked = await handler(trustedEvent(harness.webContents), { resultId, sourceId: unlinkedSourceId }) as { prompt: string; summary: string }
expect(unlinked.summary).toBe('')
expect(unlinked.prompt).toBe('请基于以下监督回顾继续讨论。\n\n来源:unlinked source\nunlinked snapshot')
} finally { await harness.dispose(); database.close() }
})
it.each(['global', 'projects'] as const)('collects supervision within the exact UI timeRange before limits (%s)', async (kind) => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-09-22T08:00:00.000Z'))
+14 -2
View File
@@ -7197,10 +7197,22 @@ export function registerIpcHandlers(
if (source.resultId !== request.resultId) throw new Error('监督来源与结果不匹配')
const result = assistantDatabase.getSupervisionResult(request.resultId)
if (!result) throw new Error('监督结果不存在')
const graph = assistantDatabase.getSupervisionGraph({ resultId: request.resultId }) as import('../shared/supervision-contracts').SupervisionGraphView
const eventIds = new Set(graph.eventSources.filter((link) => link.source_id === request.sourceId).map((link) => link.event_id))
const events = graph.events.filter((item) => eventIds.has(item.id))
const entityIds = new Set(graph.eventEntities.filter((link) => events.some((item) => item.id === link.event_id)).map((link) => link.entity_id))
const entities = graph.entities.filter((item) => entityIds.has(item.id))
const entityLabels = new Map(entities.map((item) => [item.id, item.canonical_label]))
const relations = graph.relations.filter((item) => entityLabels.has(item.from_entity_id) && entityLabels.has(item.to_entity_id))
const summary = [
...events.map((item) => `事件:${item.title}\n${item.description}`),
...entities.map((item) => `实体:${item.canonical_label}\n${item.description}`),
...relations.map((item) => `关系:${entityLabels.get(item.from_entity_id)} → ${entityLabels.get(item.to_entity_id)}(${item.relation_type})\n${item.reason}`)
].join('\n\n')
return {
source: { title: source.title, sourceType: source.sourceType, sourceId: source.sourceId, content: source.content, occurredAt: source.occurredAt },
summary: String(result.summary ?? ''),
prompt: `请基于以下监督回顾继续讨论。\n\n回顾摘要:\n${String(result.summary ?? '')}\n\n来源:${source.title}\n${source.content}`
summary,
prompt: `请基于以下监督回顾继续讨论。${summary ? `\n\n回顾摘要:\n${summary}` : ''}\n\n来源:${source.title}\n${source.content}`
}
})
registerHandler(ipcChannels.supervisionContinue, async (event, input: unknown) => {
+3 -1
View File
@@ -11205,6 +11205,7 @@ function App(): React.JSX.Element {
onRetryApplicationSettings={() => void reloadApplicationSettings()}
active={view === 'heartbeat'}
graphNavigation={supervisionGraphNavigation}
onOpenConversation={openActivityConversation}
configs={assistantHeartbeats}
entries={heartbeatEntries}
loadError={heartbeatLoadError}
@@ -11527,12 +11528,13 @@ function App(): React.JSX.Element {
commitView('heartbeat');
});
}}
onOpenSupervisionConversation={(conversationId) => setActiveId(conversationId)}
onOpenSupervisionConversation={openActivityConversation}
onContinueSupervision={async (prompt, conversationId) => {
await window.goodbuddy.supervision.continue({
conversationId,
prompt,
})
openActivityConversation(conversationId)
}}
onCreateCustomTask={() => openCustomTaskDialog("current")}
schedules={assistantSchedules}
+44 -6
View File
@@ -353,6 +353,41 @@ describe('HeartbeatCenter', () => {
expect(props.onRunNow).not.toHaveBeenCalled()
})
it.each(['unconfigured', 'enabled', 'paused'])('keeps %s plans usable without an empty activity dashboard', (state) => {
const props = createProps({
configs: state === 'unconfigured' ? [] : [{ ...config, enabled: state === 'enabled' }],
runs: [],
entries: []
})
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']) {
expect(screen.queryByRole('heading', { name: t(key) })).not.toBeInTheDocument()
}
expect(screen.getByText(t('settings.scheduleHelp'))).toBeVisible()
expect(screen.getByRole('button', { name: t('settings.refreshPlans') })).toBeEnabled()
expect(screen.getByRole('button', { name: t('settings.createTitle') })).toBeEnabled()
if (state !== 'unconfigured') {
for (const key of ['settings.editAriaLabel', 'settings.runNowAriaLabel', 'settings.deleteAriaLabel', state === 'enabled' ? 'settings.pauseAriaLabel' : 'settings.resumeAriaLabel']) {
expect(screen.getByRole('button', { name: i18n.t(key, { ns: 'heartbeat', name: config.name }) })).toBeEnabled()
}
}
fireEvent.click(screen.getByRole('button', { name: t('settings.createTitle') }))
expect(screen.getByLabelText(t('settings.recurrenceAriaLabel'))).toHaveValue('daily')
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]} />)
expect(screen.getByText(entry.summary)).toBeVisible()
expect(screen.getByText(memory.content)).toBeVisible()
expect(screen.getByText(task.title)).toBeVisible()
})
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)
@@ -709,9 +744,11 @@ describe('HeartbeatCenter', () => {
expect(within(recap).getAllByRole('button', { name: '刷新' })).toHaveLength(1)
expect(within(recap).queryByText(/暂无自动监督报告|还没有成功回顾/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '自动监督' }))
const reports = screen.getByRole('region', { name: '自动监督报告' })
expect(within(reports).queryByText(entry.summary) !== null).toBe(!empty)
expect(within(reports).queryByText(/暂无自动监督报告/) !== null).toBe(empty)
if (empty) {
expect(screen.queryByRole('region', { name: '自动监督报告' })).not.toBeInTheDocument()
} else {
expect(within(screen.getByRole('region', { name: '自动监督报告' })).getByText(entry.summary)).toBeVisible()
}
expect(screen.queryByText('Successful work review')).not.toBeInTheDocument()
})
@@ -1086,10 +1123,11 @@ describe('HeartbeatCenter', () => {
const props = createProps(empty ? { configs: [], entries: [], runs: [], memories: [], tasks: [] } : {})
render(<HeartbeatCenter {...props} />)
const panel = screen.getByRole('tabpanel', { name: '自动监督' })
for (const name of ['当前状态', '报告趋势', '运行记录']) {
expect(within(panel).getByRole('heading', { name })).toBeVisible()
expect(within(panel).getByRole('heading', { name: '当前状态' })).toBeVisible()
for (const name of ['报告趋势', '运行记录']) {
expect(within(panel).queryByRole('heading', { name }) !== null).toBe(!empty)
}
expect(panel.querySelector('.heartbeat-center__metrics')).toBeVisible()
expect(panel.querySelector('.heartbeat-center__metrics') !== null).toBe(!empty)
expect(panel.querySelector('.scope-badge')).toBeVisible()
expect(within(panel).getAllByRole('button', { name: '创建自动监督计划' })).toHaveLength(1)
expect(panel.querySelectorAll('#heartbeat-panel-overview .empty-state')).toHaveLength(0)
+10 -3
View File
@@ -46,6 +46,7 @@ export type HeartbeatCenterProps = {
onRetryApplicationSettings?: () => void
active?: boolean
graphNavigation?: SupervisionGraphNavigation
onOpenConversation?: (conversationId: string) => void
configs: AssistantHeartbeatConfig[]
runs: AssistantHeartbeatRun[]
entries: AssistantHeartbeatEntry[]
@@ -146,6 +147,7 @@ function UnifiedSupervisorCenter(props: HeartbeatCenterProps): React.JSX.Element
tab={pageTab}
onTabChange={setPageTab}
projects={props.projects}
onOpenConversation={props.onOpenConversation}
onOpenActivity={() => {
setActivityPlanId('')
setPageTab('activity')
@@ -395,6 +397,9 @@ function HeartbeatSections({
)
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 ||
@@ -584,6 +589,7 @@ function HeartbeatSections({
</div>
</section>
{hasHeartbeatActivity && <>
<dl
aria-label={t('center.metrics.ariaLabel')}
className="heartbeat-center__metrics"
@@ -781,10 +787,11 @@ function HeartbeatSections({
</section>
</div>
</>}
</div>
)}
{pageTab === 'plans' && (
{hasHeartbeatActivity && (
<div
className="heartbeat-center__panel heartbeat-center__suggestions"
id="heartbeat-panel-suggestions"
@@ -1012,7 +1019,7 @@ function HeartbeatSections({
</div>
)}
{pageTab === 'plans' && (
{hasHeartbeatActivity && (
<div
className="heartbeat-center__panel"
id="heartbeat-panel-history"
@@ -1120,7 +1127,7 @@ function HeartbeatSections({
</div>
)}
{pageTab === 'plans' && <section
{hasHeartbeatActivity && <section
aria-labelledby="heartbeat-runs-title"
className="heartbeat-center__section"
>
@@ -154,6 +154,7 @@ function sidebarElement({
conversationTitles = new Map(),
supervisionEnabled,
supervisionLibraries,
onOpenSupervisionConversation,
conversationStats,
taskDurations,
selectedTaskId,
@@ -195,6 +196,7 @@ function sidebarElement({
conversationTitles?: ReadonlyMap<string, string>
supervisionEnabled?: boolean
supervisionLibraries?: React.ComponentProps<typeof RightAssistantSidebar>['supervisionLibraries']
onOpenSupervisionConversation?: React.ComponentProps<typeof RightAssistantSidebar>['onOpenSupervisionConversation']
conversationStats?: React.ComponentProps<typeof RightAssistantSidebar>['conversationStats']
taskDurations?: React.ComponentProps<typeof RightAssistantSidebar>['taskDurations']
selectedTaskId?: string
@@ -216,6 +218,7 @@ function sidebarElement({
activeConversationId={activeConversationId}
supervisionEnabled={supervisionEnabled}
supervisionLibraries={supervisionLibraries}
onOpenSupervisionConversation={onOpenSupervisionConversation}
conversationStats={conversationStats}
taskDurations={taskDurations}
selectedTaskId={selectedTaskId}
@@ -321,33 +324,25 @@ it('shows the task name while following and pinned, without displaying its raw I
await waitFor(() => expect(overview).toHaveBeenLastCalledWith({ target: { type: 'task', taskId: 'task-uuid' } }))
})
it.each(['cancel', 'failure'] as const)('retries supervision knowledge preview after %s and shows returned content', async (outcome) => {
const knowledgePreview = vi.fn(async () => ({ previewId: 'preview', libraryId: 'library', operation: 'create-entity',
entity: { label: 'Returned label', type: 'Returned type', description: 'Returned description', aliases: ['Returned alias'] },
source: { title: 'Returned source', content: 'Returned evidence' } }))
const knowledgeCommit = vi.fn(async () => ({}))
if (outcome === 'failure') knowledgeCommit.mockRejectedValueOnce(new Error('Write failed'))
it('previews supervision sources without navigating and omits legacy entity writeback controls', async () => {
const knowledgePreview = vi.fn()
const knowledgeCommit = vi.fn()
const onOpenSupervisionConversation = vi.fn()
Object.assign(window.goodbuddy, { supervision: {
overview: vi.fn(async () => [{ id: 'result', storyLineId: 'story', sourceId: 'source', summary: 'Summary', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' } }]),
sourceContext: vi.fn(async () => ({ id: 'source', title: 'Source', content: 'Content' })), knowledgePreview, knowledgeCommit
sourceContext: vi.fn(async () => ({ id: 'source', title: 'Source', content: 'Content', contextType: 'conversation', conversationId: 'source-conversation' })), knowledgePreview, knowledgeCommit
} })
const confirm = vi.spyOn(window, 'confirm').mockReturnValueOnce(outcome !== 'cancel').mockReturnValue(true)
try {
renderSidebar({ activeConversationId: 'conversation', supervisionEnabled: true, supervisionLibraries: [{ id: 'library', name: 'Target library' } as never] })
fireEvent.click(await screen.findByRole('button', { name: '查看来源' }))
const button = await screen.findByRole('button', { name: '预览并写入实体' })
fireEvent.click(button)
await waitFor(() => expect(confirm).toHaveBeenCalledOnce())
for (const text of ['Target library', 'Returned label', 'Returned type', 'Returned description', 'Returned alias', 'Returned source', 'Returned evidence']) {
expect(confirm.mock.calls[0]![0]).toContain(text)
}
await waitFor(() => expect(button).toBeEnabled())
expect(knowledgeCommit).toHaveBeenCalledTimes(outcome === 'cancel' ? 0 : 1)
fireEvent.click(button)
expect(await screen.findByText('知识实体已写入')).toBeInTheDocument()
expect(knowledgePreview).toHaveBeenCalledTimes(2)
expect(button).toBeEnabled()
} finally { confirm.mockRestore() }
renderSidebar({ activeConversationId: 'conversation', supervisionEnabled: true, supervisionLibraries: [{ id: 'library', name: 'Target library' } as never], onOpenSupervisionConversation })
fireEvent.click(await screen.findByRole('button', { name: '查看来源' }))
expect(await screen.findByText('Content')).toBeInTheDocument()
expect(onOpenSupervisionConversation).not.toHaveBeenCalled()
const card = within(screen.getByRole('region', { name: '监督反馈' }))
expect(card.queryByRole('combobox')).not.toBeInTheDocument()
expect(card.queryByRole('button', { name: '预览并写入实体' })).not.toBeInTheDocument()
fireEvent.click(card.getByRole('button', { name: '打开会话' }))
expect(onOpenSupervisionConversation).toHaveBeenCalledExactlyOnceWith('source-conversation')
expect(knowledgePreview).not.toHaveBeenCalled()
expect(knowledgeCommit).not.toHaveBeenCalled()
})
describe('RightAssistantSidebar tab titles', () => {
+39 -38
View File
@@ -244,7 +244,6 @@ function SupervisionCardContent({
taskTitle,
pinned = false,
onTogglePinned,
supervisionLibraries: libraries,
onContinueSupervision: onContinue,
onOpenSupervisionGraph,
onOpenSupervisionConversation: onOpenConversation
@@ -256,7 +255,9 @@ function SupervisionCardContent({
const [source, setSource] = useState<Record<string, unknown>>()
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [libraryId, setLibraryId] = useState(libraries?.[0]?.id ?? '')
const [contextPrompt, setContextPrompt] = useState<string>()
const [question, setQuestion] = useState('')
const busy = useRef(false)
const targetKey = target ? JSON.stringify(target) : undefined
useEffect(() => {
@@ -272,56 +273,45 @@ function SupervisionCardContent({
}, [targetKey, refresh])
const openSource = async (sourceId: string): Promise<void> => {
if (busy.current) return
busy.current = true
setLoading(true); setMessage('')
try {
if (!sourceId) throw new Error('监督结果没有可定位来源')
const context = await window.goodbuddy.supervision.sourceContext(sourceId)
if (!mounted.current) return
setSource(context)
if (context.contextType === 'conversation' && context.conversationId) {
onOpenConversation?.(String(context.conversationId))
}
} catch (error) {
setMessage(error instanceof Error ? error.message : '来源不可用')
} finally { setLoading(false) }
if (mounted.current) setMessage(error instanceof Error ? error.message : '来源不可用')
} finally { busy.current = false; if (mounted.current) setLoading(false) }
}
const continueDiscussion = async (): Promise<void> => {
if (!result || !source || !activeConversationId || !onContinue) return
if (!result || !activeConversationId || !onContinue || busy.current) return
busy.current = true
setLoading(true); setMessage('')
try {
const preview = await window.goodbuddy.supervision.continueContext({ sourceId: String(source.id), resultId: result.id })
const sourceId = String(result.sourceId ?? '')
if (!sourceId) throw new Error('监督结果没有可定位来源')
const preview = await window.goodbuddy.supervision.continueContext({ sourceId, resultId: result.id })
if (!mounted.current) return
if (!window.confirm(`确认将以下监督上下文发送到会话「${conversationTitle ?? activeConversationId}」 (${activeConversationId})?\n\n` + String(preview.prompt ?? ''))) return
await onContinue(String(preview.prompt ?? ''), activeConversationId)
} catch (error) { setMessage(error instanceof Error ? error.message : '继续讨论失败') }
finally { setLoading(false) }
if (typeof preview.prompt !== 'string' || !preview.prompt.trim()) throw new Error(t('supervisor.discussion.empty'))
setContextPrompt(preview.prompt)
setQuestion(t('supervisor.discussion.defaultQuestion'))
} catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '继续讨论失败') }
finally { busy.current = false; if (mounted.current) setLoading(false) }
}
const commitEntity = async (): Promise<void> => {
if (!result || !source || !libraryId) return
const sendDiscussion = async (): Promise<void> => {
if (!contextPrompt || !question.trim() || !activeConversationId || !onContinue || busy.current) return
busy.current = true
setLoading(true); setMessage('')
try {
const preview = await window.goodbuddy.supervision.knowledgePreview({
operation: 'create-entity', libraryId, label: '监督回顾', type: '概念',
description: String(result.summary ?? ''), aliases: [], sourceId: String(source.id)
})
const nextPreviewId = String(preview.previewId ?? '')
await onContinue(`${contextPrompt}\n\n${question}`, activeConversationId)
if (!mounted.current) return
const entity = preview.entity as Record<string, unknown>
const evidence = preview.source as Record<string, unknown>
if (window.confirm([
'确认写入以下知识实体预览?',
`知识库:${libraries?.find((library) => library.id === preview.libraryId)?.name ?? preview.libraryId}`,
`名称:${entity.label}`, `类型:${entity.type}`,
`描述:${entity.description}`, `别名:${(entity.aliases as string[]).join('、') || '无'}`,
`来源:${evidence.title}`, String(evidence.content ?? '')
].join('\n\n'))) {
await window.goodbuddy.supervision.knowledgeCommit({ previewId: nextPreviewId })
setMessage('知识实体已写入')
}
} catch (error) { setMessage(error instanceof Error ? error.message : '知识库提交失败') }
finally { setLoading(false) }
setContextPrompt(undefined); setQuestion('')
} catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '继续讨论失败') }
finally { busy.current = false; if (mounted.current) setLoading(false) }
}
return <section className="assistant-sidebar__section supervision-card" aria-label="监督反馈">
@@ -329,7 +319,7 @@ function SupervisionCardContent({
<h3>监督反馈</h3>
<div className="supervision-card__actions">
{onTogglePinned && <button type="button" className={`icon-button${pinned ? ' icon-button--active' : ''}`} aria-label={pinned ? '取消固定监督目标' : '固定监督目标'} title={pinned ? '取消固定监督目标' : '固定监督目标'} aria-pressed={pinned} disabled={!target} onClick={onTogglePinned}><Pin aria-hidden="true" size={14} /></button>}
<button type="button" className="icon-button" aria-label="刷新监督回顾" title="刷新监督回顾" disabled={!target || loading} onClick={() => { setResult(undefined); setSource(undefined); setMessage(''); setRefresh((value) => value + 1) }}><RefreshCw aria-hidden="true" size={14} /></button>
<button type="button" className="icon-button" aria-label="刷新监督回顾" title="刷新监督回顾" disabled={!target || loading} onClick={() => { setResult(undefined); setSource(undefined); setContextPrompt(undefined); setQuestion(''); setMessage(''); setRefresh((value) => value + 1) }}><RefreshCw aria-hidden="true" size={14} /></button>
</div>
</div>
{target && <p className="supervision-card__target" title={target.type === 'conversation' ? target.conversationId : target.taskId}>{pinned ? '已固定 · ' : ''}{target.type === 'conversation' ? `会话:${conversationTitle?.trim() || '未命名会话'}` : `任务:${taskTitle?.trim() || '未命名任务'}`}</p>}
@@ -340,10 +330,21 @@ function SupervisionCardContent({
<div className="supervision-card__actions">
{onOpenSupervisionGraph && <button type="button" className="secondary-button" onClick={() => onOpenSupervisionGraph(result.id)}>{t('supervisor.viewInGraph')}</button>}
<button type="button" className="secondary-button" disabled={loading} onClick={() => void openSource(String((result as Record<string, unknown>).sourceId ?? ''))}>查看来源</button>
<button type="button" className="primary-button" disabled={loading || !activeConversationId || !source} title={!activeConversationId ? '没有当前会话,无法继续讨论' : undefined} onClick={() => void continueDiscussion()}>继续讨论</button>
<button type="button" className="primary-button" disabled={loading || !activeConversationId || !onContinue || contextPrompt !== undefined} title={!activeConversationId ? '没有当前会话,无法继续讨论' : undefined} onClick={() => void continueDiscussion()}>{t('supervisor.discussion.start')}</button>
</div>
{source && <div className="supervision-card__source"><strong>{String(source.title ?? '来源')}</strong><p>{String(source.content ?? source.error ?? '')}</p>{source.error !== undefined && <small>{String(source.error)}</small>}</div>}
{libraries && libraries.length > 0 && source && <div className="supervision-card__actions"><select aria-label="选择知识库" value={libraryId} onChange={(event) => setLibraryId(event.target.value)}>{libraries.map((library) => <option key={library.id} value={library.id}>{library.name}</option>)}</select><button type="button" className="secondary-button" disabled={loading} onClick={() => void commitEntity()}>预览并写入实体</button></div>}
{source && <div className="supervision-card__source"><strong>{String(source.title ?? '来源')}</strong><p>{String(source.content ?? source.error ?? '')}</p>{source.error !== undefined && <small>{String(source.error)}</small>}
{source.contextType === 'conversation' && Boolean(source.conversationId) && onOpenConversation && <button type="button" className="secondary-button" disabled={loading} onClick={() => onOpenConversation(String(source.conversationId))}>{t('supervisor.openConversation')}</button>}
</div>}
{contextPrompt !== undefined && <div className="supervision-card__source">
<p title={activeConversationId}>{t('supervisor.discussion.target', { title: conversationTitle?.trim() || '未命名会话' })}</p>
<label className="field"><span>{t('supervisor.discussion.question')}</span><textarea rows={3} value={question} disabled={loading} onChange={(event) => setQuestion(event.target.value)} /></label>
<p>{t('supervisor.discussion.contextHint')}</p>
<div className="supervision-card__actions">
<button type="button" className="secondary-button" disabled={loading} onClick={() => { setContextPrompt(undefined); setQuestion(''); setMessage('') }}>{t('supervisor.cancel')}</button>
<button type="button" className="primary-button" disabled={loading || !question.trim() || !activeConversationId || !onContinue} onClick={() => void sendDiscussion()}>{t(loading ? 'supervisor.discussion.sending' : 'supervisor.discussion.send')}</button>
</div>
</div>}
{loading && <p role="status">{t('supervisor.discussion.loading')}</p>}
{message && <p role="status">{message}</p>}
</>}
</section>
+86 -13
View File
@@ -55,23 +55,96 @@ it('supervision ignores old target responses and never falls back to a global re
expect(screen.getByText('暂无监督回顾')).toBeInTheDocument()
})
it('supervision previews and sends to the explicit conversation and discards stale previews', async () => {
it('prepares context without viewing source and sends the edited question only on Send', async () => {
const send = vi.fn(async () => undefined)
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
const sourceContext = vi.fn()
const continueContext = vi.fn().mockResolvedValue({ prompt: 'Actual prompt\n Exact context' })
window.goodbuddy = { supervision: { overview: async () => [result('A')], sourceContext, continueContext } } as never
render(<SupervisionCard target={{ type: 'conversation', conversationId: 'fixed-A' }} activeConversationId="fixed-A" conversationTitle="Pinned A" onContinueSupervision={send} />)
fireEvent.click(await screen.findByRole('button', { name: '继续讨论' }))
const question = await screen.findByRole('textbox', { name: '继续讨论的问题' })
expect(continueContext).toHaveBeenCalledWith({ sourceId: 'source-A', resultId: 'A' })
expect(sourceContext).not.toHaveBeenCalled()
expect(send).not.toHaveBeenCalled()
expect(screen.getByText('发送到会话:Pinned A')).toHaveAttribute('title', 'fixed-A')
expect(screen.getByText('发送时会附带相关回顾与来源。')).toBeVisible()
expect(screen.queryByText(/Actual prompt/)).not.toBeInTheDocument()
fireEvent.change(question, { target: { value: 'What next?\nKeep this detail.' } })
expect(send).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '发送' }))
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument())
expect(send).toHaveBeenCalledExactlyOnceWith('Actual prompt\n Exact context\n\nWhat next?\nKeep this detail.', 'fixed-A')
expect(confirm).not.toHaveBeenCalled()
})
it('preserves the question on send failure, blocks duplicate requests and supports retry and cancel', async () => {
let rejectSend!: (error: Error) => void
const send = vi.fn().mockImplementationOnce(() => new Promise((_, reject) => { rejectSend = reject })).mockResolvedValue(undefined)
let resolveContext!: (value: unknown) => void
const continueContext = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { resolveContext = resolve })).mockResolvedValue({ prompt: 'Context' })
window.goodbuddy = { supervision: { overview: async () => [result('A')], continueContext } } as never
render(<SupervisionCard target={{ type: 'conversation', conversationId: 'A' }} activeConversationId="A" onContinueSupervision={send} />)
const start = await screen.findByRole('button', { name: '继续讨论' })
fireEvent.click(start)
fireEvent.click(start)
expect(start).toBeDisabled()
expect(continueContext).toHaveBeenCalledTimes(1)
await act(async () => resolveContext({ prompt: 'Context' }))
const question = screen.getByRole('textbox', { name: '继续讨论的问题' })
fireEvent.change(question, { target: { value: ' ' } })
expect(screen.getByRole('button', { name: '发送' })).toBeDisabled()
fireEvent.change(question, { target: { value: 'My question' } })
fireEvent.click(screen.getByRole('button', { name: '发送' }))
fireEvent.click(screen.getByRole('button', { name: '正在发送...' }))
expect(send).toHaveBeenCalledTimes(1)
expect(question).toBeDisabled()
expect(screen.getByRole('button', { name: '取消' })).toBeDisabled()
await act(async () => rejectSend(new Error('Send failed')))
expect(screen.getByRole('status')).toHaveTextContent('Send failed')
expect(question).toHaveValue('My question')
fireEvent.click(screen.getByRole('button', { name: '发送' }))
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument())
expect(send).toHaveBeenNthCalledWith(2, 'Context\n\nMy question', 'A')
fireEvent.click(start)
await screen.findByRole('textbox')
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(send).toHaveBeenCalledTimes(2)
})
it('discards pending context when the target changes and clears previews on refresh', async () => {
const send = vi.fn()
let resolvePreview!: (value: unknown) => void
const continueContext = vi.fn().mockResolvedValueOnce({ prompt: 'Actual prompt' }).mockImplementationOnce(() => new Promise((resolve) => { resolvePreview = resolve }))
window.goodbuddy = { supervision: { overview: async () => [result('A')], sourceContext: async () => ({ id: 'source-A', title: 'Source', content: 'Content' }), continueContext } } as never
const view = render(<SupervisionCard target={{ type: 'conversation', conversationId: 'fixed-A' }} activeConversationId="fixed-A" conversationTitle="Pinned A" onContinueSupervision={send} />)
fireEvent.click(await screen.findByRole('button', { name: '查看来源' }))
const continueContext = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { resolvePreview = resolve })).mockResolvedValue({ prompt: 'Fresh context' })
const overview = vi.fn().mockResolvedValueOnce([result('A')]).mockResolvedValueOnce([result('B')]).mockResolvedValueOnce([result('C')])
window.goodbuddy = { supervision: { overview, sourceContext: async () => ({ title: 'Source', content: 'Content' }), continueContext } } as never
const view = render(<SupervisionCard target={{ type: 'conversation', conversationId: 'A' }} activeConversationId="A" onContinueSupervision={send} />)
fireEvent.click(await screen.findByRole('button', { name: '继续讨论' }))
view.rerender(<SupervisionCard target={{ type: 'conversation', conversationId: 'B' }} activeConversationId="B" onContinueSupervision={send} />)
await screen.findByText('B recap')
await act(async () => resolvePreview({ prompt: 'Stale prompt' }))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '查看来源' }))
await screen.findByText('Content')
fireEvent.click(screen.getByRole('button', { name: '继续讨论' }))
await waitFor(() => expect(send).toHaveBeenCalledWith('Actual prompt', 'fixed-A'))
expect(confirm.mock.calls[0]![0]).toContain('Pinned A')
expect(confirm.mock.calls[0]![0]).toContain('fixed-A')
await screen.findByRole('textbox')
fireEvent.click(screen.getByRole('button', { name: '刷新监督回顾' }))
await screen.findByText('C recap')
expect(screen.queryByText('Content')).not.toBeInTheDocument()
expect(screen.queryByText('Fresh context')).not.toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(send).not.toHaveBeenCalled()
})
it('allows retry after context loading fails without sending', async () => {
const send = vi.fn()
const continueContext = vi.fn().mockRejectedValueOnce(new Error('Context failed')).mockResolvedValue({ prompt: 'Context' })
window.goodbuddy = { supervision: { overview: async () => [result('A')], continueContext } } as never
render(<SupervisionCard target={{ type: 'conversation', conversationId: 'A' }} activeConversationId="A" onContinueSupervision={send} />)
fireEvent.click(await screen.findByRole('button', { name: '继续讨论' }))
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Context failed'))
fireEvent.click(screen.getByRole('button', { name: '继续讨论' }))
await waitFor(() => expect(continueContext).toHaveBeenCalledTimes(2))
view.rerender(<SupervisionCard target={{ type: 'conversation', conversationId: 'B' }} activeConversationId="B" onContinueSupervision={send} />)
await act(async () => resolvePreview({ prompt: 'Stale prompt' }))
expect(send).toHaveBeenCalledTimes(1)
expect(confirm).toHaveBeenCalledTimes(1)
await screen.findByRole('textbox')
expect(send).not.toHaveBeenCalled()
})
@@ -0,0 +1,39 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { SupervisionDiscussion } from './SupervisionDiscussion'
afterEach(cleanup)
it('previews before sending and preserves the question on failure', async () => {
const continueContext = vi.fn().mockResolvedValue({ prompt: 'Related evidence only' })
const send = vi.fn().mockRejectedValueOnce(new Error('Send failed')).mockResolvedValue(undefined)
const onOpenConversation = vi.fn()
window.goodbuddy = { supervision: { continueContext, continue: send } } as never
render(<SupervisionDiscussion resultId="result" sourceId="source" conversationId="original" title="Original conversation" onOpenConversation={onOpenConversation} />)
fireEvent.click(screen.getByRole('button', { name: '继续讨论' }))
const question = await screen.findByRole('textbox', { name: '继续讨论的问题' })
expect(continueContext).toHaveBeenCalledWith({ resultId: 'result', sourceId: 'source' })
expect(send).not.toHaveBeenCalled()
expect(screen.getByText('发送到会话:Original conversation')).toBeVisible()
fireEvent.change(question, { target: { value: 'What changed?' } })
fireEvent.click(screen.getByRole('button', { name: '发送' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Send failed')
expect(question).toHaveValue('What changed?')
expect(onOpenConversation).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '发送' }))
await waitFor(() => expect(onOpenConversation).toHaveBeenCalledWith('original'))
expect(send).toHaveBeenLastCalledWith({ conversationId: 'original', prompt: 'Related evidence only\n\nWhat changed?' })
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('cancels a preview without sending or navigating', async () => {
const send = vi.fn()
const open = vi.fn()
window.goodbuddy = { supervision: { continueContext: async () => ({ prompt: 'Evidence' }), continue: send } } as never
render(<SupervisionDiscussion resultId="result" sourceId="source" conversationId="original" title="Original" onOpenConversation={open} />)
fireEvent.click(screen.getByRole('button', { name: '继续讨论' }))
fireEvent.click(await screen.findByRole('button', { name: '取消' }))
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(send).not.toHaveBeenCalled()
expect(open).not.toHaveBeenCalled()
})
@@ -0,0 +1,62 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export function SupervisionDiscussion({ resultId, sourceId, conversationId, title, onOpenConversation }: {
resultId: string
sourceId: string
conversationId: string
title: string
onOpenConversation: (id: string) => void
}): React.JSX.Element {
const { t } = useTranslation('heartbeat')
const [context, setContext] = useState<string>()
const [question, setQuestion] = useState('')
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const busy = useRef(false)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
const perform = async (send: boolean) => {
if (busy.current) return
busy.current = true
setPending(true)
setError('')
try {
if (send) {
if (!context || !question.trim()) return
await window.goodbuddy.supervision.continue({ conversationId, prompt: `${context}\n\n${question}` })
if (!mounted.current) return
setContext(undefined)
setQuestion('')
onOpenConversation(conversationId)
} else {
const preview = await window.goodbuddy.supervision.continueContext({ resultId, sourceId })
if (!mounted.current) return
if (typeof preview.prompt !== 'string' || !preview.prompt.trim()) throw new Error(t('supervisor.discussion.empty'))
setContext(preview.prompt)
setQuestion(t('supervisor.discussion.defaultQuestion'))
}
} catch (reason) {
if (mounted.current) setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
} finally {
busy.current = false
if (mounted.current) setPending(false)
}
}
return <div className="supervisor-discussion">
{context === undefined ? <button className="primary-button" disabled={pending} onClick={() => void perform(false)}>{t('supervisor.discussion.start')}</button> : <>
<p>{t('supervisor.discussion.target', { title })}</p>
<label className="field"><span>{t('supervisor.discussion.question')}</span><textarea rows={3} value={question} disabled={pending} onChange={event => setQuestion(event.target.value)} /></label>
<p>{t('supervisor.discussion.contextHint')}</p>
<div className="supervisor-workspace__actions">
<button className="secondary-button" disabled={pending} onClick={() => { setContext(undefined); setQuestion(''); setError('') }}>{t('supervisor.cancel')}</button>
<button className="primary-button" disabled={pending || !question.trim()} onClick={() => void perform(true)}>{t('supervisor.discussion.send')}</button>
</div>
</>}
{pending && <p role="status">{t('supervisor.discussion.loading')}</p>}
{error && <p role="alert">{error}</p>}
</div>
}
@@ -10,6 +10,29 @@ const render = (ui: React.ReactNode) => {
}
describe('SupervisorWorkspace', () => {
it.each(['conversation', 'knowledge'])('opens only conversation sources without replacing the saved %s snapshot', async (sourceType) => {
const onOpenConversation = vi.fn()
const source = vi.fn(async () => ({ sourceType, sourceId: 'original-conversation', title: 'Original source', content: 'Saved snapshot', occurredAt: result.createdAt }))
window.goodbuddy = { supervision: {
overview: async () => [result], source,
graph: async () => ({ storyLine: null,
events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
entities: [], relations: [], eventEntities: [],
sources: [{ id: 'source', title: 'Original source', occurred_at: result.createdAt }],
eventSources: [{ event_id: 'event', source_id: 'source' }] })
} } as never
render(<SupervisorWorkspace tab="graph" onOpenConversation={onOpenConversation} />)
fireEvent.click(await screen.findByRole('button', { name: /Original source/ }))
expect(await screen.findByText('Saved snapshot')).toBeVisible()
expect(onOpenConversation).not.toHaveBeenCalled()
if (sourceType === 'conversation') {
fireEvent.click(screen.getByRole('button', { name: '打开会话' }))
expect(onOpenConversation).toHaveBeenCalledWith('original-conversation')
} else {
expect(screen.queryByRole('button', { name: '打开会话' })).not.toBeInTheDocument()
}
expect(source).toHaveBeenCalledOnce()
})
it('keeps dated historical content and exact graph selection when configuring and running a new review', async () => {
const old = { ...result, id: 'old', summary: 'Earlier conclusion.\n\nImportant final conclusion.', changeDigest: 'Confirmed change', openItems: ['Pending decision'], createdAt: '2026-08-22T00:00:00.000Z' }
let finish!: () => void
+25 -2
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { BookOpen, Network, RefreshCw, X } from 'lucide-react'
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
import { EmptyState, PageTabs } from './WorkspacePrimitives'
import { SupervisionDiscussion } from './SupervisionDiscussion'
import type { AssistantProject } from '../../shared/assistant-contracts'
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
import {
@@ -20,6 +21,7 @@ type Props = {
tab?: 'overview' | 'graph' | 'plans' | 'activity' | 'settings'
projects?: AssistantProject[]
onOpenActivity?: () => void
onOpenConversation?: (conversationId: string) => void
onTabChange?: (tab: 'overview' | 'graph' | 'plans' | 'activity' | 'settings') => void
}
const emptyGraph: SupervisionGraphView = {
@@ -48,7 +50,8 @@ export function SupervisorWorkspace({
tab = 'overview',
projects = [],
onTabChange,
onOpenActivity
onOpenActivity,
onOpenConversation
}: Props) {
const { t, i18n } = useTranslation('heartbeat')
const graphId = useId()
@@ -67,6 +70,7 @@ export function SupervisorWorkspace({
title: string
content: string
occurredAt: string
conversationId?: string
}>()
const [projectId, setProjectId] = useState('global')
const [days, setDays] = useState(7)
@@ -442,7 +446,9 @@ export function SupervisorWorkspace({
id,
title: String(item.title),
content: String(item.content),
occurredAt: String(item.occurredAt)
occurredAt: String(item.occurredAt),
conversationId: item.sourceType === 'conversation' && typeof item.sourceId === 'string'
? item.sourceId : undefined
})
} catch (reason) {
if (generation !== loadGeneration.current) return
@@ -1294,7 +1300,24 @@ export function SupervisorWorkspace({
<h4>{t('supervisor.sourceSnapshot')}</h4>
<p>{source.title}</p>
<time>{date(source.occurredAt)}</time>
{source.conversationId && onOpenConversation && (
<button
type="button"
className="secondary-button"
onClick={() => onOpenConversation(source.conversationId!)}
>
{t('supervisor.openConversation')}
</button>
)}
<pre>{source.content}</pre>
{source.conversationId && resultId && onOpenConversation && <SupervisionDiscussion
key={`${resultId}:${source.id}:${source.conversationId}`}
resultId={resultId}
sourceId={source.id}
conversationId={source.conversationId}
title={source.title}
onOpenConversation={onOpenConversation}
/>}
</section>
)}
</aside>
@@ -87,6 +87,11 @@ export const heartbeat = {
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.',
openConversation: 'Open conversation',
discussion: {
start: 'Continue discussion', question: 'What would you like to discuss?', defaultQuestion: 'Using the review context above, help me work out the next steps.',
target: 'Send to conversation: {{title}}', contextHint: 'Relevant review context and sources will be included.', send: 'Send', sending: 'Sending...', loading: 'Working...', empty: 'The review context is empty. Please retry.'
},
events: 'Time events', entities: 'Knowledge entities', relations: 'Entity relations', eventSources: 'Event sources', sources: 'Related sources', noSources: 'No related sources are available.', selectHint: 'Select an event, entity, or relation to inspect it.', sourceSnapshot: 'Source details', sourceMissing: 'Source not found',
confirm: 'Confirm', revise: 'Revise', remove: 'Remove relation', label: 'Entity name', save: 'Save revision', cancel: 'Cancel', removeHint: 'This changes graph organization only; the original source remains available.',
relationTypes: { supports: 'Supports', 'depends-on': 'Depends on', contrasts: 'Contrasts', related: 'Related' },
@@ -84,6 +84,11 @@ export const heartbeat = {
graphScope: '图谱范围', graphEmpty: '当前范围没有故事线事件', legend: '实线表示事件影响实体,虚线表示实体关系。时间轴逆时针排列,起止之间保留缺口。', start: '起点', end: '终点',
listTabs: { event: '事件', entity: '实体', relation: '关系' },
listEmpty: '本次回顾暂无此类记录。',
openConversation: '打开会话',
discussion: {
start: '继续讨论', question: '继续讨论的问题', defaultQuestion: '请根据以上监督上下文,继续分析下一步行动。',
target: '发送到会话:{{title}}', contextHint: '发送时会附带相关回顾与来源。', send: '发送', sending: '正在发送...', loading: '正在处理...', empty: '监督上下文为空,请重试'
},
events: '时间事件', entities: '知识实体', relations: '实体关系', eventSources: '事件来源', sources: '关联来源', noSources: '没有可用的关联来源。', selectHint: '选择事件、实体或关系查看详情。', sourceSnapshot: '来源详情', sourceMissing: '来源不存在',
confirm: '确认', revise: '修订', remove: '移除关系', label: '实体名称', save: '保存修订', cancel: '取消', removeHint: '这只改变图谱中的关系组织,原始来源仍然保留。',
relationTypes: { supports: '支持', 'depends-on': '依赖', contrasts: '对比', related: '相关' },
+57 -12
View File
@@ -229,8 +229,8 @@ app
})()`)
assert(report.pageWidth <= width && report.scrollWidth <= report.clientWidth, 'Recap/automatic overflow')
assert.equal(report.refresh, 1, 'One refresh owner per panel')
assert.equal(report.reports, tab === 'plans')
assert.equal(report.suggestions, tab === 'plans')
assert.equal(report.reports, tab === 'plans' && scenario === 'populated')
assert.equal(report.suggestions, tab === 'plans' && scenario === 'populated')
assert.equal(report.recap, tab === 'overview' && scenario !== 'empty')
if (tab === 'overview') {
assert(!report.text.includes('暂无自动监督报告') && !report.text.includes('待确认记忆'))
@@ -240,13 +240,13 @@ app
assert(report.clientWidth - report.proseWidth <= 50, 'Prose fills the panel within card padding')
} else assert(report.text.includes('还没有成功回顾'))
} else {
assert.equal(report.text.includes('暂无自动监督报告'), scenario !== 'populated')
assert(report.text.includes('自动监督报告'))
assert(!report.text.includes('暂无自动监督报告'))
assert.equal(report.text.includes('自动监督报告'), scenario === 'populated')
}
reports.push({ scenario, tab, ...report })
const prefix = `recap-${scenario}-${tab}-${width}`
await writeFile(join(artifacts, `${prefix}.png`), (await win.webContents.capturePage()).toPNG())
if (tab === 'plans') {
if (tab === 'plans' && scenario === 'populated') {
for (const anchor of ['heartbeat-memory-title', 'heartbeat-reports-title', 'heartbeat-runs-title']) {
await js(`document.getElementById('${anchor}').scrollIntoView({block:'start'})`)
if (anchor === 'heartbeat-reports-title' && scenario === 'populated') await js(`document.querySelector('#heartbeat-panel-history button[aria-expanded=false]')?.click()`)
@@ -277,8 +277,9 @@ app
return
}
if (process.env.GOODBUDDY_SUPERVISOR_AUTOMATIC_OVERVIEW) {
for (const empty of [false, true]) {
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + (empty ? '' : '?menu=1'))
for (const scenario of ['populated', 'empty', 'plan-only']) {
const empty = scenario !== 'populated'
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + (scenario === 'populated' ? '?menu=1' : scenario === 'plan-only' ? '?plan-only=1' : ''))
await wait('!!document.querySelector("#supervisor-tab-plans")')
await js('document.fonts.ready')
await js('document.documentElement.dataset.theme = "light"')
@@ -295,22 +296,27 @@ app
overview: !!panel.querySelector('#heartbeat-panel-overview'),
metrics: !!panel.querySelector('.heartbeat-center__metrics'),
trend: !!panel.querySelector('#heartbeat-trend-title'),
audit: !!panel.querySelector('#heartbeat-runs-title'),
audit: !!panel.querySelector('#heartbeat-runs-title'),
reports: !!panel.querySelector('#heartbeat-reports-title'),
suggestions: !!panel.querySelector('#heartbeat-panel-suggestions'),
emptyReports: panel.textContent.includes('暂无自动监督报告'),
plans: !!panel.querySelector('#heartbeat-panel-plans'),
create: [...panel.querySelectorAll('button')].filter(b => b.textContent === '创建自动监督计划').length,
emptyStates: panel.querySelectorAll('#heartbeat-panel-overview .empty-state').length,
activity: panel.querySelectorAll('.supervisor-activity__item').length };
})()`)
assert(report.pageWidth <= width && report.scrollWidth <= report.panelWidth, 'Automatic panel overflow')
for (const key of ['overview', 'metrics', 'trend', 'audit', 'plans']) assert.equal(report[key], tab === 'plans', key)
for (const key of ['overview', 'plans']) assert.equal(report[key], tab === 'plans', key)
for (const key of ['metrics', 'trend', 'audit', 'reports', 'suggestions']) assert.equal(report[key], tab === 'plans' && !empty, key)
assert.equal(report.emptyReports, false)
assert.equal(report.create, tab === 'plans' ? 1 : 0)
assert.equal(report.emptyStates, 0)
if (tab === 'activity') assert.equal(report.activity, 3)
reports.push({ empty, tab, ...report })
const prefix = `automatic-${empty ? 'empty' : 'populated'}-${tab}-light-${width}`
reports.push({ scenario, empty, tab, ...report })
const prefix = `automatic-${scenario}-${tab}-light-${width}`
await writeFile(join(artifacts, `${prefix}.png`), (await win.webContents.capturePage()).toPNG())
if (tab === 'plans') {
for (const anchor of ['heartbeat-trend-title', 'heartbeat-runs-title']) {
for (const anchor of empty ? [] : ['heartbeat-trend-title', 'heartbeat-runs-title']) {
await js(`document.getElementById('${anchor}').scrollIntoView({block:'start'})`)
await settle()
await writeFile(join(artifacts, `${prefix}-${anchor}.png`), (await win.webContents.capturePage()).toPNG())
@@ -335,6 +341,45 @@ app
}
assert.deepEqual(errors, [])
await writeFile(join(artifacts, 'automatic-overview-measurements.json'), JSON.stringify(reports, null, 2))
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?preview=1')
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
await js('document.querySelector("#supervisor-tab-graph").click()')
await wait('!!document.querySelector(".supervisor-workspace__map")')
await js(`document.querySelector('.supervisor-workspace__map [aria-label="观察工作过程"]').dispatchEvent(new MouseEvent('click', {bubbles:true}))`)
await settle()
await js('document.querySelector(".supervisor-workspace__detail > .link-button").click()')
await wait('!!document.querySelector(".supervisor-discussion > button")')
await js('document.querySelector(".supervisor-discussion > button").click()')
await wait('!!document.querySelector(".supervisor-discussion textarea")')
await js('document.fonts.ready')
const previews = []
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme = '${theme}'`)
for (const width of [1440, 390]) {
win.setContentSize(width, 1100)
await js(`document.querySelector('.supervisor-discussion').scrollIntoView({block:'center'})`)
await settle()
const report = await js(`(() => {
const preview = document.querySelector('.supervisor-discussion');
const box = e => { const r = e.getBoundingClientRect(); return {left:r.left, right:r.right, top:r.top, bottom:r.bottom}; };
return { width:innerWidth, pageWidth:document.documentElement.scrollWidth,
clientWidth:preview.clientWidth, scrollWidth:preview.scrollWidth,
question:preview.querySelector('textarea').value, text:preview.textContent,
controls:[...preview.querySelectorAll('textarea, button')].map(box) };
})()`)
assert(report.pageWidth <= width && report.scrollWidth <= report.clientWidth, 'Discussion preview overflow')
assert(report.question.trim() && report.text.includes('模拟会议记录'), 'Discussion question and destination visible')
assert(report.controls.every(box => box.left >= 0 && box.right <= width && box.top >= 0 && box.bottom <= 1100), 'Preview controls fit viewport')
assert(await js('!document.querySelector(".supervisor-discussion [role=alert]")'), 'Preview loads without error')
assert(await js('!document.querySelector(".supervisor-discussion details")'), 'No redundant context disclosure')
previews.push({ theme, ...report })
await writeFile(join(artifacts, `discussion-preview-${theme}-${width}.png`), (await win.webContents.capturePage()).toPNG())
}
}
await js('document.querySelector(".supervisor-discussion .secondary-button").click()')
await wait('!document.querySelector(".supervisor-discussion textarea")')
assert.deepEqual(errors, [])
await writeFile(join(artifacts, 'discussion-preview-measurements.json'), JSON.stringify(previews, null, 2))
console.log(JSON.stringify({ artifacts, cases: reports.length, errors }))
win.destroy()
app.quit()
@@ -146,6 +146,8 @@ Object.defineProperty(window, 'goodbuddy', {
? {}
: {
supervision: {
continueContext: async () => ({ prompt: '模拟讨论上下文:本周已核对交付清单,负责人已确认。\n\n原始依据:模拟会议记录。外部评审时间仍待确认,下一步需要核对验收条件。' }),
continue: async () => { throw new Error('Preview fixture must not send a message') },
execution: async () => ({ active: params.has('activity'), ...(params.has('activity') ? { runId: 'activity-1' } : {}) }),
batches: async ({ runId, offset, limit }: { runId: string; offset: number; limit: number }) => Array.from({ length: runId === 'activity-0' ? 6 : 12 }, (_, index) => ({
id: `leaf-${index}`, projectId: index < 3 ? 'Atlas' : 'Beacon', conversationId: `conversation-${Math.floor(index / 2)}`,
@@ -204,6 +206,7 @@ Object.defineProperty(window, 'goodbuddy', {
entityAction: async () => {},
relationAction: async () => {},
source: async () => ({
...(params.has('preview') ? { sourceType: 'conversation', sourceId: 'simulated-conversation-uuid' } : {}),
title: '模拟会议记录',
content: '视觉 fixture,不是真实用户数据。',
occurredAt: '2026-09-21T08:00:00.000Z'
@@ -252,7 +255,7 @@ createRoot(document.getElementById('root')!).render(
<HeartbeatCenter
applicationSettings={applicationSettingsSchema.parse({ checkUpdatesOnStartup: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })}
onUpdateApplicationSettings={async () => true}
configs={menu ? [{ id: 'plan', name: '模拟每日回顾', scope: { kind: 'global' }, timezone: 'Asia/Shanghai', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 48, retentionDays: 90, nextRunAt: '2026-09-24T01:00:00.000Z', createdAt, updatedAt: createdAt }] : []}
configs={menu || params.has('plan-only') ? [{ id: 'plan', name: '模拟每日回顾', scope: { kind: 'global' }, timezone: 'Asia/Shanghai', recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 48, retentionDays: 90, nextRunAt: '2026-09-24T01:00:00.000Z', createdAt, updatedAt: createdAt }] : []}
runs={menu ? [{ id: 'run', configId: 'plan', trigger: 'scheduled', scheduledFor: createdAt, status: 'completed', attemptCount: 1, createdAt, updatedAt: createdAt }] : []}
entries={menu ? [{ id: 'report', configId: 'plan', runId: 'run', scheduledFor: createdAt, summary: '模拟自动监督报告:交付计划已更新,待核对负责人和验收日期。', highlights: ['保留原始依据,核对交付时间。'], proposedMemoryIds: ['memory'], followUpTaskIds: ['task'], createdAt }] : []}
memories={menu ? [{ id: 'memory', scope: 'global', type: 'preference', content: '模拟建议:周会总结保留负责人和下一次检查日期。', confidence: 0.9, salience: 0.8, status: 'proposed', createdAt, updatedAt: createdAt }] : []}
@@ -267,7 +270,8 @@ createRoot(document.getElementById('root')!).render(
onSetMemoryStatus={noop}
onSetTaskStatus={noop}
onUseFollowUpTask={() => {}}
onRetryLoad={noop}
onRetryLoad={noop}
onOpenConversation={params.has('preview') ? () => { throw new Error('Preview fixture must not navigate') } : undefined}
/>
</PageShell>}
</div>