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:
@@ -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: '相关' },
|
||||
|
||||
Reference in New Issue
Block a user