feat: let agents read supervisor story graphs
Expose saved decisions, timelines and source evidence through read-only Story Graph tools across Model, Harness and local/remote OpenCode and Continue. Supervisor enablement and runtime assignments govern discovery and calls; reads do not invoke models or modify stored evidence. Remote use requires matching Desktop and Agent changes. Make Continue discussion show the complete editable message with a separated destination and shared action layout. Sending uses the visible draft without reattaching removed context. Validation: existing focused MCP, runtime, controlled Linux Host, discussion component and Electron layout checks plus typechecks; staged diff check passed. Cross-scope fact reuse, historical reconstruction and full package acceptance remain deferred. Release note: Agents can consult saved supervisor decisions and their sources when continuing work. Continue discussion now lets you review and edit the complete message before sending.
This commit is contained in:
@@ -255,8 +255,7 @@ function SupervisionCardContent({
|
||||
const [source, setSource] = useState<Record<string, unknown>>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [contextPrompt, setContextPrompt] = useState<string>()
|
||||
const [question, setQuestion] = useState('')
|
||||
const [draft, setDraft] = useState<string>()
|
||||
const busy = useRef(false)
|
||||
const targetKey = target ? JSON.stringify(target) : undefined
|
||||
|
||||
@@ -296,20 +295,19 @@ function SupervisionCardContent({
|
||||
const preview = await window.goodbuddy.supervision.continueContext({ sourceId, resultId: result.id })
|
||||
if (!mounted.current) return
|
||||
if (typeof preview.prompt !== 'string' || !preview.prompt.trim()) throw new Error(t('supervisor.discussion.empty'))
|
||||
setContextPrompt(preview.prompt)
|
||||
setQuestion(t('supervisor.discussion.defaultQuestion'))
|
||||
setDraft(preview.prompt)
|
||||
} catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '继续讨论失败') }
|
||||
finally { busy.current = false; if (mounted.current) setLoading(false) }
|
||||
}
|
||||
|
||||
const sendDiscussion = async (): Promise<void> => {
|
||||
if (!contextPrompt || !question.trim() || !activeConversationId || !onContinue || busy.current) return
|
||||
if (!draft?.trim() || !activeConversationId || !onContinue || busy.current) return
|
||||
busy.current = true
|
||||
setLoading(true); setMessage('')
|
||||
try {
|
||||
await onContinue(`${contextPrompt}\n\n${question}`, activeConversationId)
|
||||
await onContinue(draft, activeConversationId)
|
||||
if (!mounted.current) return
|
||||
setContextPrompt(undefined); setQuestion('')
|
||||
setDraft(undefined)
|
||||
} catch (error) { if (mounted.current) setMessage(error instanceof Error ? error.message : '继续讨论失败') }
|
||||
finally { busy.current = false; if (mounted.current) setLoading(false) }
|
||||
}
|
||||
@@ -319,7 +317,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); setContextPrompt(undefined); setQuestion(''); 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); setDraft(undefined); 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>}
|
||||
@@ -330,19 +328,18 @@ 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 || !onContinue || contextPrompt !== undefined} title={!activeConversationId ? '没有当前会话,无法继续讨论' : undefined} onClick={() => void continueDiscussion()}>{t('supervisor.discussion.start')}</button>
|
||||
<button type="button" className="primary-button" disabled={loading || !activeConversationId || !onContinue || draft !== 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>}
|
||||
{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>
|
||||
{draft !== undefined && <div className="supervision-discussion-editor">
|
||||
<p className="supervision-discussion-editor__target" title={activeConversationId}>{t('supervisor.discussion.target', { title: conversationTitle?.trim() || '未命名会话' })}</p>
|
||||
<label className="field"><span>{t('supervisor.discussion.message')}</span><textarea rows={10} value={draft} disabled={loading} onChange={(event) => setDraft(event.target.value)} /></label>
|
||||
<footer className="custom-task-dialog__actions">
|
||||
<button type="button" className="secondary-button" disabled={loading} onClick={() => { setDraft(undefined); setMessage('') }}>{t('supervisor.cancel')}</button>
|
||||
<button type="button" className="primary-button" disabled={loading || !draft.trim() || !activeConversationId || !onContinue} onClick={() => void sendDiscussion()}>{t(loading ? 'supervisor.discussion.sending' : 'supervisor.discussion.send')}</button>
|
||||
</footer>
|
||||
</div>}
|
||||
{loading && <p role="status">{t('supervisor.discussion.loading')}</p>}
|
||||
{message && <p role="status">{message}</p>}
|
||||
|
||||
@@ -55,7 +55,7 @@ it('supervision ignores old target responses and never falls back to a global re
|
||||
expect(screen.getByText('暂无监督回顾')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prepares context without viewing source and sends the edited question only on Send', async () => {
|
||||
it('shows actual context in the editable message and sends exactly that message only on Send', async () => {
|
||||
const send = vi.fn(async () => undefined)
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const sourceContext = vi.fn()
|
||||
@@ -63,18 +63,19 @@ it('prepares context without viewing source and sends the edited question only o
|
||||
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: '继续讨论的问题' })
|
||||
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(question).toHaveValue('Actual prompt\n Exact context')
|
||||
expect(screen.queryByText('发送时会附带相关回顾与来源。')).not.toBeInTheDocument()
|
||||
const edited = 'Actual prompt\n Edited context\n\nWhat next?\nKeep this detail. '
|
||||
fireEvent.change(question, { target: { value: edited } })
|
||||
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(send).toHaveBeenCalledExactlyOnceWith(edited, 'fixed-A')
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -91,7 +92,7 @@ it('preserves the question on send failure, blocks duplicate requests and suppor
|
||||
expect(start).toBeDisabled()
|
||||
expect(continueContext).toHaveBeenCalledTimes(1)
|
||||
await act(async () => resolveContext({ prompt: 'Context' }))
|
||||
const question = screen.getByRole('textbox', { name: '继续讨论的问题' })
|
||||
const question = screen.getByRole('textbox', { name: '发送内容' })
|
||||
fireEvent.change(question, { target: { value: ' ' } })
|
||||
expect(screen.getByRole('button', { name: '发送' })).toBeDisabled()
|
||||
fireEvent.change(question, { target: { value: 'My question' } })
|
||||
@@ -105,7 +106,7 @@ it('preserves the question on send failure, blocks duplicate requests and suppor
|
||||
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')
|
||||
expect(send).toHaveBeenNthCalledWith(2, 'My question', 'A')
|
||||
fireEvent.click(start)
|
||||
await screen.findByRole('textbox')
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
|
||||
@@ -4,14 +4,29 @@ import { SupervisionDiscussion } from './SupervisionDiscussion'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('previews before sending and preserves the question on failure', async () => {
|
||||
it('sends the visible reference message once without a hidden attachment', async () => {
|
||||
const prompt = '请基于以下监督回顾继续讨论。\n\n事件:核对验收\n\n来源:会议记录\n 保留原文缩进'
|
||||
const send = vi.fn().mockResolvedValue(undefined)
|
||||
const open = vi.fn()
|
||||
window.goodbuddy = { supervision: { continueContext: async () => ({ prompt }), continue: send } } as never
|
||||
render(<SupervisionDiscussion resultId="result" sourceId="source" conversationId="original" title="Original" onOpenConversation={open} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续讨论' }))
|
||||
expect(await screen.findByRole('textbox', { name: '发送内容' })).toHaveValue(prompt)
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
await waitFor(() => expect(open).toHaveBeenCalledWith('original'))
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith({ conversationId: 'original', prompt })
|
||||
})
|
||||
|
||||
it('previews the full editable message and preserves it on failure without reattaching deleted context', 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: '继续讨论的问题' })
|
||||
const question = await screen.findByRole('textbox', { name: '发送内容' })
|
||||
expect(question).toHaveValue('Related evidence only')
|
||||
expect(continueContext).toHaveBeenCalledWith({ resultId: 'result', sourceId: 'source' })
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('发送到会话:Original conversation')).toBeVisible()
|
||||
@@ -22,7 +37,8 @@ it('previews before sending and preserves the question on failure', async () =>
|
||||
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(send).toHaveBeenNthCalledWith(1, { conversationId: 'original', prompt: 'What changed?' })
|
||||
expect(send).toHaveBeenLastCalledWith({ conversationId: 'original', prompt: 'What changed?' })
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ export function SupervisionDiscussion({ resultId, sourceId, conversationId, titl
|
||||
onOpenConversation: (id: string) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('heartbeat')
|
||||
const [context, setContext] = useState<string>()
|
||||
const [question, setQuestion] = useState('')
|
||||
const [draft, setDraft] = useState<string>()
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const busy = useRef(false)
|
||||
@@ -26,18 +25,16 @@ export function SupervisionDiscussion({ resultId, sourceId, conversationId, titl
|
||||
setError('')
|
||||
try {
|
||||
if (send) {
|
||||
if (!context || !question.trim()) return
|
||||
await window.goodbuddy.supervision.continue({ conversationId, prompt: `${context}\n\n${question}` })
|
||||
if (!draft?.trim()) return
|
||||
await window.goodbuddy.supervision.continue({ conversationId, prompt: draft })
|
||||
if (!mounted.current) return
|
||||
setContext(undefined)
|
||||
setQuestion('')
|
||||
setDraft(undefined)
|
||||
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'))
|
||||
setDraft(preview.prompt)
|
||||
}
|
||||
} catch (reason) {
|
||||
if (mounted.current) setError(reason instanceof Error ? reason.message : t('common.operationFailed'))
|
||||
@@ -47,15 +44,14 @@ export function SupervisionDiscussion({ resultId, sourceId, conversationId, titl
|
||||
}
|
||||
}
|
||||
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>
|
||||
</>}
|
||||
{draft === undefined ? <button className="primary-button" disabled={pending} onClick={() => void perform(false)}>{t('supervisor.discussion.start')}</button> : <div className="supervision-discussion-editor">
|
||||
<p className="supervision-discussion-editor__target">{t('supervisor.discussion.target', { title })}</p>
|
||||
<label className="field"><span>{t('supervisor.discussion.message')}</span><textarea rows={10} value={draft} disabled={pending} onChange={event => setDraft(event.target.value)} /></label>
|
||||
<footer className="custom-task-dialog__actions">
|
||||
<button className="secondary-button" disabled={pending} onClick={() => { setDraft(undefined); setError('') }}>{t('supervisor.cancel')}</button>
|
||||
<button className="primary-button" disabled={pending || !draft.trim()} onClick={() => void perform(true)}>{t(pending ? 'supervisor.discussion.sending' : 'supervisor.discussion.send')}</button>
|
||||
</footer>
|
||||
</div>}
|
||||
{pending && <p role="status">{t('supervisor.discussion.loading')}</p>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -89,8 +89,8 @@ export const heartbeat = {
|
||||
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.'
|
||||
start: 'Continue discussion', message: 'Message to send',
|
||||
target: 'Send to conversation: {{title}}', 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.',
|
||||
|
||||
@@ -86,8 +86,8 @@ export const heartbeat = {
|
||||
listEmpty: '本次回顾暂无此类记录。',
|
||||
openConversation: '打开会话',
|
||||
discussion: {
|
||||
start: '继续讨论', question: '继续讨论的问题', defaultQuestion: '请根据以上监督上下文,继续分析下一步行动。',
|
||||
target: '发送到会话:{{title}}', contextHint: '发送时会附带相关回顾与来源。', send: '发送', sending: '正在发送...', loading: '正在处理...', empty: '监督上下文为空,请重试'
|
||||
start: '继续讨论', message: '发送内容',
|
||||
target: '发送到会话:{{title}}', send: '发送', sending: '正在发送...', loading: '正在处理...', empty: '监督上下文为空,请重试'
|
||||
},
|
||||
events: '时间事件', entities: '知识实体', relations: '实体关系', eventSources: '事件来源', sources: '关联来源', noSources: '没有可用的关联来源。', selectHint: '选择事件、实体或关系查看详情。', sourceSnapshot: '来源详情', sourceMissing: '来源不存在',
|
||||
confirm: '确认', revise: '修订', remove: '移除关系', label: '实体名称', save: '保存修订', cancel: '取消', removeHint: '这只改变图谱中的关系组织,原始来源仍然保留。',
|
||||
|
||||
@@ -11298,6 +11298,38 @@ textarea.field-control {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.supervision-discussion-editor {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.supervision-discussion-editor > .supervision-discussion-editor__target {
|
||||
margin: 0;
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.supervision-discussion-editor > .field {
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.supervision-discussion-editor textarea {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.supervision-discussion-editor > .custom-task-dialog__actions {
|
||||
padding: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.field small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
|
||||
Reference in New Issue
Block a user