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:
@@ -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`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '相关' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user