feat: extract reusable experiences from supervisor stories

This commit is contained in:
mesalogo
2026-10-02 16:21:53 +08:00
parent bf6c4a91a2
commit 983cdd4e05
21 changed files with 798 additions and 29 deletions
+1 -1
View File
@@ -777,7 +777,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
suggestions: vi.fn(async () => []),
suggestionAction: vi.fn(async () => { throw new Error('unused') }),
retrySuggestions: vi.fn(async () => 0),
stories: vi.fn(async () => ({ stories: [], unassigned: 0, canUndo: false })),
stories: vi.fn(async () => ({ stories: [], experiences: [], unassigned: 0, canUndo: false })),
storyAction: vi.fn(async () => undefined),
retryStories: vi.fn(async () => { throw new Error('unused') })
},
@@ -31,7 +31,7 @@ it('saves validated algorithm controls and retains the draft until saved setting
const cross = screen.getByRole('switch', { name: '跨项目关联' })
expect(cross).not.toBeChecked()
fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' }))
expect(save).toHaveBeenCalledWith({ supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 2048, crossProject: false, storyThreadEvents: 20 } })
expect(save).toHaveBeenCalledWith({ supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 2048, crossProject: false, storyThreadEvents: 20, experienceMinEvents: 5 } })
expect(screen.getByLabelText(/每次读取来源条数/)).toHaveValue(17)
})
@@ -1,10 +1,10 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ApplicationSettings, ApplicationSettingsUpdate } from '../../shared/application-settings-contracts'
import { defaultStoryThreadEvents, defaultSupervisionReviewSettings, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
import { defaultExperienceMinEvents, defaultStoryThreadEvents, defaultSupervisionReviewSettings, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
const fields = [
['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384], ['storyThreadEvents', 4, 500]
['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384], ['storyThreadEvents', 4, 500], ['experienceMinEvents', 2, 200]
] as const
export function SupervisionReviewSettings({ settings, disabled, onSave }: {
@@ -15,7 +15,8 @@ export function SupervisionReviewSettings({ settings, disabled, onSave }: {
const { t } = useTranslation('heartbeat')
const current = { ...defaultSupervisionReviewSettings, ...settings?.supervisionReview,
responseKiB: settings?.supervisionReview?.responseKiB ?? 1024, crossProject: settings?.supervisionReview?.crossProject ?? false,
storyThreadEvents: settings?.supervisionReview?.storyThreadEvents ?? defaultStoryThreadEvents }
storyThreadEvents: settings?.supervisionReview?.storyThreadEvents ?? defaultStoryThreadEvents,
experienceMinEvents: settings?.supervisionReview?.experienceMinEvents ?? defaultExperienceMinEvents }
const [saved, setSaved] = useState(current)
const [draft, setDraft] = useState(current)
if (fields.some(([key]) => saved[key] !== current[key]) || saved.crossProject !== current.crossProject) { setSaved(current); setDraft(current) }
+98 -2
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { SupervisionRunRequest } from '../../shared/supervision-contracts'
import type { SupervisionStory, SupervisionStoryAction, SupervisionStoryView } from '../../shared/supervision-story-contracts'
import type { SupervisionExperience, SupervisionStory, SupervisionStoryAction, SupervisionStoryView } from '../../shared/supervision-story-contracts'
const empty: SupervisionStoryView = { stories: [], unassigned: 0, canUndo: false }
const empty: SupervisionStoryView = { stories: [], experiences: [], unassigned: 0, canUndo: false }
/** Loads the stories of a scope and applies user adjustments. Late responses for an older scope are ignored. */
export function useSupervisionStories(scope: SupervisionRunRequest['scope'] | undefined, active: boolean, revision: unknown) {
@@ -168,3 +168,99 @@ export function SupervisionEventStory({ eventId, projectId, stories, pending, on
</select>
</label>
}
const storiesOf = (experience: SupervisionExperience) => new Set(experience.events.map(event => event.storyId).filter(Boolean)).size
/** Experiences (W) as a list: statement, origin stories and application count. */
export function SupervisionExperienceList({ experiences, selectedId, onSelect }: {
experiences: SupervisionExperience[]
selectedId?: string
onSelect: (id: string) => void
}) {
const { t } = useTranslation('heartbeat')
return <>{experiences.map(experience => <button key={experience.id} aria-pressed={selectedId === experience.id} onClick={() => onSelect(experience.id)}>
<span className="supervisor-workspace__list-copy">
{experience.statement}
<small>
{t('supervisor.experiences.fromStories', { count: storiesOf(experience) })}
{` · ${t('supervisor.experiences.applied', { count: experience.events.filter(event => event.role === 'applied').length })}`}
</small>
</span>
</button>)}</>
}
/** Inspector for one experience: conditions, limits, evidence and later applications, with edit, merge, delete. */
export function SupervisionExperienceDetail({ experience, experiences, pending, canUndo, onAct, onSelectEvent, date }: {
experience: SupervisionExperience
experiences: SupervisionExperience[]
pending: boolean
canUndo: boolean
onAct: (action: SupervisionStoryAction) => Promise<boolean>
onSelectEvent: (id: string) => void
date: (value: string) => string
}) {
const { t } = useTranslation('heartbeat')
const [draft, setDraft] = useState<{ statement: string; conditions: string; boundaries: string }>()
const [mergeInto, setMergeInto] = useState('')
const [confirmRemove, setConfirmRemove] = useState(false)
const formed = experience.events.filter(event => event.role === 'formed')
const applied = experience.events.filter(event => event.role === 'applied')
const targets = experiences.filter(other => other.id !== experience.id)
const eventButton = (event: SupervisionExperience['events'][number]) => <button key={`${event.role}-${event.id}`} className="link-button" onClick={() => onSelectEvent(event.id)}>
{event.title}<small>{[event.storyName, date(event.at), event.note].filter(Boolean).join(' · ')}</small>
</button>
return <>
<div className="supervisor-workspace__detail-kicker">
{t('supervisor.experiences.kicker')} · {experience.userEdited ? t('supervisor.experiences.edited') : t('supervisor.experiences.automatic')}
</div>
<h3>{experience.statement}</h3>
{experience.conditions && <p className="supervisor-workspace__detail-copy"><strong>{t('supervisor.experiences.conditions')}</strong> {experience.conditions}</p>}
{experience.boundaries && <p className="supervisor-workspace__detail-copy"><strong>{t('supervisor.experiences.boundaries')}</strong> {experience.boundaries}</p>}
<div className="supervisor-workspace__actions">
<button className="secondary-button" disabled={pending}
onClick={() => setDraft({ statement: experience.statement, conditions: experience.conditions, boundaries: experience.boundaries })}>{t('supervisor.experiences.edit')}</button>
<button className="danger-ghost" disabled={pending} onClick={() => setConfirmRemove(true)}>{t('supervisor.experiences.remove')}</button>
{canUndo && <button className="secondary-button" disabled={pending} onClick={() => void onAct({ action: 'undo' })}>{t('supervisor.stories.undo')}</button>}
</div>
{draft && <form onSubmit={event => {
event.preventDefault()
void onAct({ action: 'experience-edit', experienceId: experience.id, statement: draft.statement.trim(), conditions: draft.conditions.trim(), boundaries: draft.boundaries.trim() })
.then(done => { if (done) setDraft(undefined) })
}}>
<label>{t('supervisor.experiences.statement')}<input value={draft.statement} maxLength={200} onChange={event => setDraft({ ...draft, statement: event.target.value })} /></label>
<label>{t('supervisor.experiences.conditions')}<textarea value={draft.conditions} maxLength={400} rows={2} onChange={event => setDraft({ ...draft, conditions: event.target.value })} /></label>
<label>{t('supervisor.experiences.boundaries')}<textarea value={draft.boundaries} maxLength={400} rows={2} onChange={event => setDraft({ ...draft, boundaries: event.target.value })} /></label>
<div className="supervisor-workspace__actions">
<button className="secondary-button" disabled={pending || !draft.statement.trim()}>{t('supervisor.experiences.save')}</button>
<button type="button" className="secondary-button" disabled={pending} onClick={() => setDraft(undefined)}>{t('supervisor.cancel')}</button>
</div>
</form>}
{targets.length > 0 && <div className="supervisor-workspace__story-merge">
<label>{t('supervisor.experiences.mergeInto')}
<select value={mergeInto} disabled={pending} onChange={event => setMergeInto(event.target.value)}>
<option value="">{t('supervisor.experiences.choose')}</option>
{targets.map(other => <option key={other.id} value={other.id}>{other.statement}</option>)}
</select>
</label>
<button className="secondary-button" disabled={pending || !mergeInto}
onClick={() => void onAct({ action: 'experience-merge', experienceId: experience.id, intoId: mergeInto }).then(done => { if (done) setMergeInto('') })}>
{t('supervisor.experiences.merge')}
</button>
</div>}
{confirmRemove && <div role="alert">
<p>{t('supervisor.experiences.removeHint')}</p>
<div className="supervisor-workspace__actions">
<button className="secondary-button" disabled={pending} onClick={() => setConfirmRemove(false)}>{t('supervisor.cancel')}</button>
<button className="danger-solid" disabled={pending} onClick={() => void onAct({ action: 'experience-remove', experienceId: experience.id })}>{t('supervisor.experiences.remove')}</button>
</div>
</div>}
<div className="supervisor-workspace__related">
<h4>{t('supervisor.experiences.formed', { count: formed.length })}</h4>
{formed.map(eventButton)}
</div>
<div className="supervisor-workspace__related">
<h4>{t('supervisor.experiences.applied', { count: applied.length })}</h4>
{applied.length ? applied.map(eventButton) : <p className="supervisor-workspace__muted">{t('supervisor.experiences.noApplications')}</p>}
</div>
</>
}
+7
View File
@@ -164,6 +164,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
: !error && rows.length === 0 ? <EmptyState icon={<History size={24} />} title={t(configId ? 'activity.filteredEmpty' : 'activity.empty')} description={t(configId ? 'activity.filteredEmptyHint' : 'activity.emptyHint')} />
: <ol className="supervisor-activity__list">{rows.map((row) => {
const runId = row.reviewProgress?.runId
const experiences = row.reviewProgress?.stories?.experiences
const stoppingKind = runId && stopping?.runId === runId ? stopping.kind
: runId && execution.active && execution.runId === runId ? execution.stopping : undefined
return <li key={`${row.kind}:${row.id}`}>
@@ -203,6 +204,12 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
{row.reviewProgress.stories.status === 'failed' && typeof api?.retryStories === 'function' && <button type="button" className="secondary-button"
disabled={!!pending || execution.active} onClick={() => void retryStories(row)}>{t('activity.retryStories')}</button>}
</p>}
{experiences && <p className="supervisor-activity__suggestion" role={experiences.status === 'failed' ? 'alert' : undefined}>
{experiences.status === 'failed' ? t('activity.experienceFailed')
: `${t('activity.experienceStage')}: ${experiences.calls ? t('activity.experienceCompleted', { created: experiences.created ?? 0, applied: experiences.applied ?? 0 }) : t('activity.experienceNone')}`}
{experiences.status === 'failed' && typeof api?.retryStories === 'function' && <button type="button" className="secondary-button"
disabled={!!pending || execution.active} onClick={() => void retryStories(row)}>{t('activity.retryStories')}</button>}
</p>}
{row.suggestionStatus && <p className="supervisor-activity__suggestion" role={row.suggestionStatus === 'failed' ? 'alert' : undefined}>
{row.suggestionStatus === 'failed' ? t('activity.updatedSuggestionFailed')
: `${t('activity.suggestionStage')}: ${t(`activity.suggestionStates.${row.suggestionStatus}`, { count: row.suggestionCount ?? 0 })}`}
+33 -1
View File
@@ -328,7 +328,7 @@ describe('SupervisorWorkspace', () => {
const view = { stories: [story('11111111-1111-4111-8111-111111111111', '监督者', 'feature', ['event-1']),
story('22222222-2222-4222-8222-222222222222', '回顾算法', 'thread', [], '11111111-1111-4111-8111-111111111111'),
story('33333333-3333-4333-8333-333333333333', '笔记', 'feature', []),
story('44444444-4444-4444-8444-444444444444', '果蝇', 'cross', ['event-1'])], unassigned: 2, canUndo: true }
story('44444444-4444-4444-8444-444444444444', '果蝇', 'cross', ['event-1'])], experiences: [], unassigned: 2, canUndo: true }
const stories = vi.fn(async () => view)
const storyAction = vi.fn(async () => undefined)
window.goodbuddy = { supervision: {
@@ -359,6 +359,38 @@ describe('SupervisorWorkspace', () => {
await waitFor(() => expect(storyAction).toHaveBeenCalledWith({ action: 'move', eventId: 'event-1', storyId: null }))
})
it('lists experiences with evidence and applications and lets users edit or delete them', async () => {
const experience = { id: '55555555-5555-4555-8555-555555555555', statement: '先验证关键假设', conditions: '新方向', boundaries: '紧急修复', userEdited: false,
events: [{ id: 'event-1', role: 'formed', note: '', title: '决定', projectId: 'p', at: '2026-09-21T00:00:00.000Z', storyId: 's', storyName: '监督者' },
{ id: 'event-2', role: 'applied', note: '少走弯路', title: '复用', projectId: 'p', at: '2026-09-22T00:00:00.000Z', storyId: 't', storyName: '笔记' }] }
const storyAction = vi.fn(async () => undefined)
window.goodbuddy = { supervision: {
overview: vi.fn(async () => [result]), storyAction,
stories: vi.fn(async () => ({ stories: [], experiences: [experience], unassigned: 0, canUndo: false })),
graph: vi.fn(async () => ({ storyLine: { id: 'story', scope_json: '{"kind":"global"}' }, events: [{ id: 'event-1', title: '决定', description: 'd', occurred_at: '2026-09-21T00:00:00.000Z', project_id: 'p' }],
entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
} } as never
render(<SupervisorWorkspace tab="graph" />)
fireEvent.click(await screen.findByRole('tab', { name: '经验 1' }))
const list = screen.getByRole('complementary', { name: '图谱选择' })
fireEvent.click(within(list).getByRole('button', { name: /先验证关键假设/ }))
const inspector = screen.getByRole('complementary', { name: '详情与来源' })
expect(within(inspector).getByRole('heading', { name: '先验证关键假设' })).toBeInTheDocument()
expect(within(inspector).getByText(/自动归纳/)).toBeInTheDocument()
expect(within(inspector).getByRole('heading', { name: '形成依据(1)' })).toBeInTheDocument()
expect(within(inspector).getByRole('button', { name: /复用.*少走弯路/ })).toBeInTheDocument()
fireEvent.click(within(inspector).getByRole('button', { name: '编辑' }))
fireEvent.change(within(inspector).getByRole('textbox', { name: '经验' }), { target: { value: '先验证关键假设,再扩大投入' } })
fireEvent.click(within(inspector).getByRole('button', { name: '保存' }))
await waitFor(() => expect(storyAction).toHaveBeenCalledWith({ action: 'experience-edit', experienceId: experience.id, statement: '先验证关键假设,再扩大投入', conditions: '新方向', boundaries: '紧急修复' }))
fireEvent.click(within(inspector).getByRole('button', { name: '删除经验' }))
fireEvent.click(within(within(inspector).getByRole('alert')).getByRole('button', { name: '删除经验' }))
await waitFor(() => expect(storyAction).toHaveBeenCalledWith({ action: 'experience-remove', experienceId: experience.id }))
// The evidence event opens in the inspector.
fireEvent.click(within(inspector).getByRole('button', { name: /决定/ }))
expect(await within(inspector).findByText('d')).toBeInTheDocument()
})
it('steps through real events in time order and supports keyboard selection in the canvas', async () => {
window.goodbuddy = { supervision: {
overview: vi.fn(async () => [result]),
+20 -5
View File
@@ -5,7 +5,7 @@ import { AnchoredMenu } from './AnchoredMenu'
import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts'
import { EmptyState, PageTabs } from './WorkspacePrimitives'
import { SupervisionDiscussion } from './SupervisionDiscussion'
import { SupervisionEventStory, SupervisionStoryDetail, SupervisionStoryList, useSupervisionStories } from './SupervisionStories'
import { SupervisionEventStory, SupervisionExperienceDetail, SupervisionExperienceList, SupervisionStoryDetail, SupervisionStoryList, useSupervisionStories } from './SupervisionStories'
import type { AssistantProject } from '../../shared/assistant-contracts'
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
import {
@@ -18,7 +18,7 @@ import {
// Three.js loads only when the spiral view is opened.
const StoryGraph3D = lazy(() => import('./StoryGraph3D'))
type Selection = { kind: 'event' | 'entity' | 'relation' | 'story'; id: string }
type Selection = { kind: 'event' | 'entity' | 'relation' | 'story' | 'experience'; id: string }
export type SupervisionGraphNavigation = { resultId: string; tab?: 'overview' | 'graph' }
type Props = {
graphNavigation?: SupervisionGraphNavigation
@@ -166,7 +166,7 @@ export function SupervisorWorkspace({
: current?.kind === 'entity'
? parsedGraph.entities
: parsedGraph.relations
if (current?.kind === 'story') return current
if (current?.kind === 'story' || current?.kind === 'experience') return current
if (current && records.some((item) => item.id === current.id))
return current
const latestEvent = [...parsedGraph.events].sort(
@@ -476,6 +476,7 @@ export function SupervisorWorkspace({
: undefined
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
const selectedExperience = selection?.kind === 'experience' ? storyState.view.experiences.find((item) => item.id === selection.id) : undefined
const busy = !!api && (loading || pending !== undefined)
const showRunNotice = (execution.active || running || pausedReview) && !dismissedNotice
@@ -724,7 +725,8 @@ export function SupervisorWorkspace({
{ id: 'event', label: `${t('supervisor.listTabs.event')} ${layout.events.length}` },
{ id: 'entity', label: `${t('supervisor.listTabs.entity')} ${layout.entities.length}` },
{ id: 'relation', label: `${t('supervisor.listTabs.relation')} ${graph.relations.length}` },
...(storyState.available ? [{ id: 'story' as const, label: `${t('supervisor.listTabs.story')} ${storyState.view.stories.length}` }] : [])
...(storyState.available ? [{ id: 'story' as const, label: `${t('supervisor.listTabs.story')} ${storyState.view.stories.length}` },
{ id: 'experience' as const, label: `${t('supervisor.listTabs.experience')} ${storyState.view.experiences.length}` }] : [])
]}
/>
<div
@@ -747,6 +749,12 @@ export function SupervisorWorkspace({
{storyState.view.unassigned > 0 && <p className="supervisor-workspace__muted">
{t('supervisor.stories.unassigned', { count: storyState.view.unassigned })}</p>}
</>}
{listTab === 'experience' && <>
{storyState.error && <p role="alert">{storyState.error}</p>}
{!storyState.view.experiences.length && <p className="supervisor-workspace__muted">{t('supervisor.experiences.empty')}</p>}
<SupervisionExperienceList experiences={storyState.view.experiences} selectedId={selectedExperience?.id}
onSelect={(id) => select({ kind: 'experience', id })} />
</>}
{listTab === 'event' && layout.events.map((event, index) => (
<button
key={event.id}
@@ -1136,7 +1144,14 @@ export function SupervisorWorkspace({
aria-label={t('supervisor.inspector')}
>
<h2>{t('supervisor.inspector')}</h2>
{selection?.kind === 'story' ? (
{selection?.kind === 'experience' ? (
selectedExperience ? <>
{storyState.error && <p role="alert">{storyState.error}</p>}
<SupervisionExperienceDetail key={selectedExperience.id} experience={selectedExperience} experiences={storyState.view.experiences}
pending={storyState.pending} canUndo={storyState.view.canUndo} date={date} onAct={storyState.act}
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }} />
</> : <p>{t('supervisor.stories.missing')}</p>
) : selection?.kind === 'story' ? (
selectedStory ? <>
{storyState.error && <p role="alert">{storyState.error}</p>}
<SupervisionStoryDetail key={selectedStory.id} story={selectedStory} stories={storyState.view.stories}
@@ -6,6 +6,7 @@ export const heartbeat = {
title: 'Review algorithm',
responseKiB: 'Response capacity, KiB (default 1024)',
storyThreadEvents: 'Minimum events before a feature splits into sub-threads (default 20)',
experienceMinEvents: 'Minimum events before a story is considered for experiences (default 5)',
save: 'Save review algorithm',
help: 'Page size controls database reads, not total coverage. Batch limits split work without dropping remaining text. These settings and the organize timeout are frozen for each new review; continuing uses its saved configuration. Response capacity uses the current setting for each request; increase it before continuing a failed review.',
pageSize: 'Sources read at a time (default 50)', batchCharacters: 'Text size per batch (default 8000)',
@@ -74,6 +75,8 @@ export const heartbeat = {
heartbeatStage: 'Heartbeat trigger', supervisionStage: 'Supervision review', notRecorded: 'No recorded execution',
storyStage: 'Stories', storyStates: { running: 'Organizing', completed: '{{assigned}} events assigned', failed: 'Failed' }, retryStories: 'Organize stories again',
storyFailed: 'The review is saved, but stories could not be organized. Try again, or the next review will catch up.',
experienceStage: 'Experiences', experienceCompleted: '{{created}} new, {{applied}} applications recorded', experienceNone: 'No new progress to learn from',
experienceFailed: 'Stories are organized, but experiences could not be extracted. Try again, or the next review will catch up.',
suggestionStage: 'Suggestions', suggestionStates: { running: 'Generating', completed: '{{count}} generated', skipped: 'Nothing to suggest', failed: 'Failed' },
retrySuggestions: 'Regenerate suggestions', updatedSuggestionFailed: 'Review updated; suggestions could not be generated.',
openReview: 'Open review', pagination: 'Activity pages', previous: 'Newer', next: 'Older', page: 'Page {{page}}'
@@ -101,7 +104,15 @@ export const heartbeat = {
more: 'More review actions', reanalyze: 'Reanalyze…', reanalyzeTitle: 'Reanalyze this period?', reanalyzeConfirm: 'Reanalyze',
reanalyzeHint: 'All content in {{scope}} from the last {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss',
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', story: 'Stories' },
listTabs: { event: 'Events', entity: 'Entities', relation: 'Relations', story: 'Stories', experience: 'Experiences' },
experiences: {
empty: 'No experiences yet. Once stories have progressed, reviews distil reusable experience from them.',
kicker: 'Experience', automatic: 'Automatic', edited: 'Edited by you',
conditions: 'Applies when', boundaries: 'Limits', formed: 'Formed from ({{count}})', applied: 'Applied in ({{count}})', noApplications: 'No applications recorded yet.',
fromStories: 'From {{count}} stories', edit: 'Edit', statement: 'Experience', save: 'Save', remove: 'Delete experience',
removeHint: 'It will not be recreated from the same evidence. The events and sources it cites are not affected.',
mergeInto: 'Merge into', choose: 'Choose an experience', merge: 'Merge'
},
graph3d: {
mode: 'Graph view', modes: { flat: 'Flat', spiral: 'Time spiral' },
levels: 'Stave levels', all: 'All', views: 'Viewpoint', canvas: 'Time spiral. Keys 1, 2 and 3 switch the viewpoint, arrow keys rotate, Esc goes up a level; the list below offers the same staves.',
@@ -3,6 +3,7 @@ export const heartbeat = {
title: '回顾算法',
responseKiB: '单次响应容量,KiB(默认 1024)',
storyThreadEvents: '功能拆分子线索的最少事件数(默认 20)',
experienceMinEvents: '故事参与经验整理的最少事件数(默认 5)',
save: '保存回顾算法',
help: '每页条数只控制数据库读取量,不限制回顾总量。分批上限用于拆分工作,剩余正文会继续处理。这些设置及整理超时在新回顾开始时固定;继续回顾沿用已保存的配置。响应容量按每次请求的当前设置生效,调高后可继续失败的回顾。',
pageSize: '每次读取来源条数(默认 50)', batchCharacters: '每批文本量(默认 8000)',
@@ -71,6 +72,8 @@ export const heartbeat = {
heartbeatStage: '心跳触发', supervisionStage: '监督回顾', notRecorded: '无执行记录',
storyStage: '故事整理', storyStates: { running: '整理中', completed: '已归入 {{assigned}} 个事件', failed: '整理失败' }, retryStories: '重新整理故事',
storyFailed: '回顾已保存,故事整理失败。可以重新整理,或等下一次回顾自动补上。',
experienceStage: '经验', experienceCompleted: '新增 {{created}} 条,记录 {{applied}} 次应用', experienceNone: '没有可整理的新进展',
experienceFailed: '故事已整理,经验整理失败。可以重新整理,或等下一次回顾自动补上。',
suggestionStage: '建议', suggestionStates: { running: '生成中', completed: '已生成 {{count}} 条', skipped: '无需建议', failed: '生成失败' },
retrySuggestions: '重新生成建议', updatedSuggestionFailed: '回顾已更新,建议生成失败。',
openReview: '查看回顾', pagination: '活动分页', previous: '较新记录', next: '更早记录', page: '第 {{page}} 页'
@@ -98,7 +101,15 @@ export const heartbeat = {
more: '更多回顾操作', reanalyze: '重新整理…', reanalyzeTitle: '重新整理这段时间?', reanalyzeConfirm: '重新整理',
reanalyzeHint: '将重新分析{{scope}}最近 {{period}}的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示',
graphScope: '图谱范围', graphEmpty: '当前范围没有故事线事件', legend: '实线表示事件影响实体,虚线表示实体关系。时间轴逆时针排列,起止之间保留缺口。', start: '起点', end: '终点',
listTabs: { event: '事件', entity: '实体', relation: '关系', story: '故事' },
listTabs: { event: '事件', entity: '实体', relation: '关系', story: '故事', experience: '经验' },
experiences: {
empty: '还没有经验。故事积累到一定进展后,回顾会从中整理可复用的经验。',
kicker: '经验', automatic: '自动归纳', edited: '已手动调整',
conditions: '适用条件', boundaries: '边界', formed: '形成依据({{count}})', applied: '后续应用({{count}})', noApplications: '暂无应用记录。',
fromStories: '来自 {{count}} 个故事', edit: '编辑', statement: '经验', save: '保存', remove: '删除经验',
removeHint: '删除后不会因同样的依据再次生成;依据中的事件和来源不受影响。',
mergeInto: '合并到', choose: '选择经验', merge: '合并'
},
graph3d: {
mode: '图谱视图', modes: { flat: '平铺', spiral: '时间螺旋' },
levels: '木片层级', all: '全部', views: '视角', canvas: '时间螺旋。数字键 1、2、3 切换视角,方向键旋转,Esc 返回上一层;下方列表提供同样的木片。',