From d47f967781abab17a43a3f928a57bb77cd428f4e Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 28 Sep 2026 01:51:23 +0800 Subject: [PATCH] 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. --- .../conversation-supervision/ui-design.md | 6 +- src/main/ipc.test.ts | 52 ++++++++++ src/main/ipc.ts | 16 ++- src/renderer/src/App.tsx | 4 +- src/renderer/src/HeartbeatCenter.test.tsx | 50 ++++++++-- src/renderer/src/HeartbeatCenter.tsx | 13 ++- .../src/RightAssistantSidebar.resize.test.tsx | 43 ++++---- src/renderer/src/RightAssistantSidebar.tsx | 77 ++++++++------- src/renderer/src/SupervisionCard.test.tsx | 99 ++++++++++++++++--- .../src/SupervisionDiscussion.test.tsx | 39 ++++++++ src/renderer/src/SupervisionDiscussion.tsx | 62 ++++++++++++ src/renderer/src/SupervisorWorkspace.test.tsx | 23 +++++ src/renderer/src/SupervisorWorkspace.tsx | 27 ++++- .../src/i18n/locales/en-US/heartbeat.ts | 5 + .../src/i18n/locales/zh-CN/heartbeat.ts | 5 + tests/support/supervisor-layout-driver.mjs | 69 ++++++++++--- .../support/supervisor-layout-regression.tsx | 8 +- 17 files changed, 494 insertions(+), 104 deletions(-) create mode 100644 src/renderer/src/SupervisionDiscussion.test.tsx create mode 100644 src/renderer/src/SupervisionDiscussion.tsx diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index 1d53bde0..7489afe5 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -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`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。 diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 278e8e15..c9d7bb62 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -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')) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 653b3c8a..a9efff4a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 41a7bd83..3aeedab2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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} diff --git a/src/renderer/src/HeartbeatCenter.test.tsx b/src/renderer/src/HeartbeatCenter.test.tsx index 68a33609..6707b15d 100644 --- a/src/renderer/src/HeartbeatCenter.test.tsx +++ b/src/renderer/src/HeartbeatCenter.test.tsx @@ -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() + 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() + expect(screen.getByLabelText(t('center.metrics.ariaLabel'))).toBeVisible() + expect(screen.getByText(runs[1]!.error!)).toBeVisible() + view.rerender() + 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(, 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() 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) diff --git a/src/renderer/src/HeartbeatCenter.tsx b/src/renderer/src/HeartbeatCenter.tsx index 6b71e475..703f1d27 100644 --- a/src/renderer/src/HeartbeatCenter.tsx +++ b/src/renderer/src/HeartbeatCenter.tsx @@ -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(() => { if ( configs.length === 0 || @@ -584,6 +589,7 @@ function HeartbeatSections({ + {hasHeartbeatActivity && <>
+ } )} - {pageTab === 'plans' && ( + {hasHeartbeatActivity && (
)} - {pageTab === 'plans' && ( + {hasHeartbeatActivity && (
)} - {pageTab === 'plans' &&
diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index deaa7d7d..a9bd550a 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -154,6 +154,7 @@ function sidebarElement({ conversationTitles = new Map(), supervisionEnabled, supervisionLibraries, + onOpenSupervisionConversation, conversationStats, taskDurations, selectedTaskId, @@ -195,6 +196,7 @@ function sidebarElement({ conversationTitles?: ReadonlyMap supervisionEnabled?: boolean supervisionLibraries?: React.ComponentProps['supervisionLibraries'] + onOpenSupervisionConversation?: React.ComponentProps['onOpenSupervisionConversation'] conversationStats?: React.ComponentProps['conversationStats'] taskDurations?: React.ComponentProps['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', () => { diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index 5324da37..e8fae5da 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -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>() const [loading, setLoading] = useState(false) const [message, setMessage] = useState('') - const [libraryId, setLibraryId] = useState(libraries?.[0]?.id ?? '') + const [contextPrompt, setContextPrompt] = useState() + 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 => { + 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 => { - 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 => { - if (!result || !source || !libraryId) return + const sendDiscussion = async (): Promise => { + 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 - const evidence = preview.source as Record - 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
@@ -329,7 +319,7 @@ function SupervisionCardContent({

监督反馈

{onTogglePinned && } - +
{target &&

{pinned ? '已固定 · ' : ''}{target.type === 'conversation' ? `会话:${conversationTitle?.trim() || '未命名会话'}` : `任务:${taskTitle?.trim() || '未命名任务'}`}

} @@ -340,10 +330,21 @@ function SupervisionCardContent({
{onOpenSupervisionGraph && } - +
- {source &&
{String(source.title ?? '来源')}

{String(source.content ?? source.error ?? '')}

{source.error !== undefined && {String(source.error)}}
} - {libraries && libraries.length > 0 && source &&
} + {source &&
{String(source.title ?? '来源')}

{String(source.content ?? source.error ?? '')}

{source.error !== undefined && {String(source.error)}} + {source.contextType === 'conversation' && Boolean(source.conversationId) && onOpenConversation && } +
} + {contextPrompt !== undefined &&
+

{t('supervisor.discussion.target', { title: conversationTitle?.trim() || '未命名会话' })}

+