feat: add supervisor workspace and traceable story graph

Extend the existing heartbeat workspace with scoped work reviews, result-specific story graphs, source inspection, and pinned conversation or task feedback. Connect confirmed continuation and local knowledge entity previews through the desktop APIs, and preserve result history and user corrections in schema 43.

Include focused regression tests, an isolated visual fixture, and implementation evidence. Full event-by-event replay, Experiment targets, no-change handling, and cancellation remain incomplete; this commit does not claim complete feature acceptance. Existing automatic plans remain active when the application entry is disabled.

Release note: Review work progress in the Supervisor workspace, trace graph events to saved sources, and open a matching review from pinned conversation or task feedback.
This commit is contained in:
mesalogo
2026-09-23 13:49:13 +08:00
parent 785b1ee468
commit 7d487f9326
49 changed files with 5980 additions and 310 deletions
+88
View File
@@ -0,0 +1,88 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('renders the production supervisor at desktop, narrow, and mobile widths', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-supervisor-'))
const css = await readFile(
'src/renderer/src/supervisor-workspace.css',
'utf8'
)
const server = await createServer({
configFile: false,
root: resolve('.'),
cacheDir: join(directory, 'vite'),
plugins: [
react(),
{
name: 'supervisor-fixture',
configureServer(server) {
server.middlewares.use(
'/supervisor.html',
async (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.end(
await server.transformIndexHtml(
'/supervisor.html',
'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SIMULATED Supervisor validation</title><body><div id="root"></div><script type="module" src="/tests/support/supervisor-layout-regression.tsx"></script></body></html>'
)
)
}
)
}
}
],
server: { host: '127.0.0.1', port: 0, watch: null }
})
try {
await server.listen()
const env: NodeJS.ProcessEnv = {
...process.env,
GOODBUDDY_SUPERVISOR_URL:
server.resolvedUrls!.local[0] + 'supervisor.html',
GOODBUDDY_SUPERVISOR_DIRECTORY: directory,
GOODBUDDY_SUPERVISOR_TOKENS: JSON.stringify([
...new Set(
[...css.matchAll(/var\((--[\w-]+)\)/g)].map((match) => match[1])
)
])
}
delete env.ELECTRON_RUN_AS_NODE
const driver = join(directory, 'driver.mjs')
await copyFile(
resolve('tests/support/supervisor-layout-driver.mjs'),
driver
)
const child = spawn(createRequire(import.meta.url)('electron'), [driver], {
env,
stdio: ['ignore', 'pipe', 'pipe']
})
let output = ''
child.stdout.on('data', (data) => {
output += data
})
child.stderr.on('data', (data) => {
output += data
})
const timeout = setTimeout(() => child.kill(), 90000)
try {
const code = await new Promise((resolve, reject) => {
child.once('exit', resolve)
child.once('error', reject)
})
expect(code, output).toBe(0)
console.log(output)
} finally {
clearTimeout(timeout)
}
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 120000)
+471
View File
@@ -0,0 +1,471 @@
import { app, BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import process from 'node:process'
import console from 'node:console'
import { setTimeout } from 'node:timers/promises'
const directory = process.env.GOODBUDDY_SUPERVISOR_DIRECTORY
const artifacts = process.env.GOODBUDDY_SUPERVISOR_ARTIFACTS || directory
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app
.whenReady()
.then(async () => {
await mkdir(artifacts, { recursive: true })
const win = new BrowserWindow({
show: false,
width: 1440,
height: 1100,
useContentSize: true,
webPreferences: {
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false
}
})
const errors = []
win.webContents.on('console-message', (event) => {
if (event.level === 'error') errors.push(event.message)
})
const js = async (code) => {
try {
return await win.webContents.executeJavaScript(code)
} catch (error) {
throw new Error(`${code}\n${errors.join('\n')}`, { cause: error })
}
}
const settle = async () => {
await js(
'new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))'
)
await setTimeout(250)
}
const wait = async (code) => {
for (let i = 0; i < 600; i++) {
if (await js(code)) return
await setTimeout(25)
}
throw new Error(`Timeout: ${code}\n${errors.join('\n')}`)
}
const open = async (dense) => {
await win.loadURL(
process.env.GOODBUDDY_SUPERVISOR_URL + (dense ? '?dense=1' : '')
)
await wait('!!document.querySelector("#supervisor-tab-graph")')
await js('document.querySelector("#supervisor-tab-graph").click()')
await wait('!!document.querySelector(".supervisor-workspace__map")')
await js('document.fonts.ready')
await settle()
}
const reports = []
await open(false)
await js(
'document.querySelector(".supervisor-workspace__legend .link-button").click()'
)
await wait(
'!document.querySelector(".supervisor-workspace__map.has-selection")'
)
assert(await js(`(() => {
const ring = document.querySelector('.supervisor-workspace__timeline-ring');
const start = ring.getPointAtLength(0), next = ring.getPointAtLength(20), end = ring.getPointAtLength(ring.getTotalLength());
return next.x > start.x && next.y < start.y && end.x < start.x && end.y > 490;
})()`), 'Time must run counter-clockwise and leave a bottom gap')
for (const theme of ['light', 'dark']) {
await js(`document.documentElement.dataset.theme = '${theme}'`)
for (const width of [1440, 1024, 390]) {
win.setContentSize(width, 1100)
await settle()
const report = await js(`(() => {
const q = s => document.querySelector('.supervisor-workspace' + s);
const box = e => { const r = e.getBoundingClientRect(); return { width: r.width, height: r.height, top: r.top, bottom: r.bottom, clientWidth: e.clientWidth, scrollWidth: e.scrollWidth }; };
const svg = q('__map'), scroller = q('__map-scroll');
const texts = [...svg.querySelectorAll('text')].map(e => {
const b = e.getBBox(), c = getComputedStyle(e), r = e.getBoundingClientRect();
return { text: e.textContent, width: b.width, height: b.height, fontSize: c.fontSize,
screenFontSize: parseFloat(c.fontSize) * e.getScreenCTM().a, fill: c.fill,
inSvg: b.x >= 0 && b.y >= 0 && b.x + b.width <= 760 && b.y + b.height <= 620,
painted: c.display !== 'none' && c.visibility === 'visible' && c.fill !== 'none' && Number(c.opacity) > 0 && r.width > 0,
x: b.x, y: b.y };
});
const tokens = Object.fromEntries(${process.env.GOODBUDDY_SUPERVISOR_TOKENS}.map(t => [t, getComputedStyle(q('')).getPropertyValue(t).trim()]));
return { viewport: innerWidth, page: box(document.documentElement), shell: box(document.querySelector('.page-shell')),
container: box(q('')), grid: getComputedStyle(q('__graph-layout')).gridTemplateColumns,
list: box(q('__graph-list')), canvas: box(q('__graph-canvas')), detail: box(q('__detail')), svg: box(svg), scroller: box(scroller), texts, tokens };
})()`)
reports.push({ theme, ...report })
await writeFile(
join(artifacts, `${theme}-${width}.png`),
(await win.webContents.capturePage()).toPNG()
)
assert(report.page.scrollWidth <= width, JSON.stringify(report))
if (width >= 1024) {
assert(Math.abs(report.list.top - report.canvas.top) < 1, 'Sidebar top alignment')
assert(Math.abs(report.list.bottom - report.canvas.bottom) < 1,
`Sidebar must fill canvas height: ${JSON.stringify({ list: report.list, canvas: report.canvas })}`)
if (width === 1440)
assert(Math.abs(report.list.bottom - report.detail.bottom) < 1, 'Sidebar/detail height alignment')
}
assert(
report.shell.scrollWidth <= report.shell.clientWidth,
'Shell overflow'
)
assert(
report.container.scrollWidth <= report.container.clientWidth,
'Container overflow'
)
assert(
report.texts.every(
(text) => text.inSvg && text.painted && text.screenFontSize >= 11
),
'Clipped or invisible SVG text'
)
for (const [index, a] of report.texts.entries()) {
for (const b of report.texts.slice(index + 1)) {
assert(
!(
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
),
`Overlapping labels: ${a.text} / ${b.text}`
)
}
}
assert(
Object.values(report.tokens).every(Boolean),
'Unresolved CSS variable'
)
assert(
report.texts.some((text) => text.text === '工作过程'),
'Missing real entity label'
)
if (width === 1440)
assert(
report.canvas.width > report.list.width + report.detail.width,
'Canvas must take priority'
)
if (width === 1440) {
await js('document.querySelector(".supervisor-workspace__graph-layout").scrollIntoView({block:"end"})')
await settle()
await writeFile(join(artifacts, `${theme}-1440-aligned-bottom.png`), (await win.webContents.capturePage()).toPNG())
await js('document.querySelector(".page-shell").scrollTop = 0')
}
if (width >= 1024)
assert.equal(
report.scroller.scrollWidth,
report.scroller.clientWidth,
'Desktop canvas must fit'
)
if (width === 390) {
assert(
report.scroller.scrollWidth > report.scroller.clientWidth,
'Keep mobile labels readable with local scrolling'
)
await js(
'document.querySelector(".supervisor-workspace__map-scroll").scrollLeft = 600'
)
assert(
await js(
'document.querySelector(".supervisor-workspace__map-scroll").scrollLeft > 0'
)
)
await writeFile(
join(artifacts, `${theme}-${width}-scrolled.png`),
(await win.webContents.capturePage()).toPNG()
)
}
}
}
// The viewport stays wide while the actual workspace is constrained.
win.setContentSize(1440, 1100)
await js(
`document.documentElement.dataset.theme = 'light'; document.querySelector('.supervisor-workspace__map [aria-label="观察工作过程"]').dispatchEvent(new MouseEvent('click', {bubbles:true}))`
)
await settle()
await js(
'document.querySelector(".supervisor-workspace__detail > .link-button").click()'
)
await wait('!!document.querySelector(".supervisor-workspace__source")')
assert(
await js(
'document.querySelector(".supervisor-workspace__source").textContent.includes("不是真实用户数据")'
)
)
await settle()
await writeFile(
join(artifacts, 'selected-source-1440.png'),
(await win.webContents.capturePage()).toPNG()
)
win.setContentSize(390, 1100)
await js(
'document.querySelector(".supervisor-workspace__detail").scrollIntoView({block:"start"})'
)
await settle()
await writeFile(
join(artifacts, 'selected-source-390.png'),
(await win.webContents.capturePage()).toPNG()
)
assert(
await js('document.documentElement.scrollWidth <= innerWidth'),
'Mobile source overflow'
)
win.setContentSize(1440, 1100)
for (const width of [800, 860, 861, 980, 1000, 1010, 1110, 1190, 1191]) {
await js(
`document.querySelector('.supervisor-workspace').style.width = '${width}px'`
)
await settle()
const result = await js(
`(() => { const e = document.querySelector('.supervisor-workspace'); return { width: e.clientWidth, scrollWidth: e.scrollWidth, columns: getComputedStyle(e.querySelector('.supervisor-workspace__graph-layout')).gridTemplateColumns }; })()`
)
assert.equal(result.width, result.scrollWidth, 'Breakpoint overflow')
reports.push({ constrainedContainer: result })
}
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?short=1')
await wait('!!document.querySelector("#supervisor-tab-graph")')
await js('document.querySelector("#supervisor-tab-graph").click()')
await wait('!!document.querySelector(".supervisor-workspace__map")')
for (const width of [1440, 390]) {
win.setContentSize(width, 1100)
await settle()
const short = await js(`(() => {
const list = document.querySelector('.supervisor-workspace__graph-list');
const r = list.getBoundingClientRect();
return { width: innerWidth, height: r.height, clientHeight: list.clientHeight, scrollHeight: list.scrollHeight,
canvasHeight: document.querySelector('.supervisor-workspace__graph-canvas').getBoundingClientRect().height,
buttonHeights: [...list.querySelectorAll('button')].map(e => e.getBoundingClientRect().height) };
})()`)
assert(short.buttonHeights.every(height => height < 80), 'Short rows must not stretch to fill space')
assert.equal(short.scrollHeight, short.clientHeight, 'Short list must not acquire artificial overflow')
if (width === 1440) assert(Math.abs(short.height - short.canvasHeight) < 1, 'Short sidebar must still fill its row')
else assert(short.height <= 320, 'Short mobile list must use its natural height')
reports.push({ shortList: short })
await js('document.querySelector(".supervisor-workspace__graph-list").scrollIntoView({block:"end"})')
await settle()
await writeFile(join(artifacts, `short-${width}.png`), (await win.webContents.capturePage()).toPNG())
}
win.setContentSize(1440, 1100)
await open(true)
for (const width of [1440, 1024, 390]) {
win.setContentSize(width, 1100)
await settle()
const scrolling = await js(`(() => {
const list = document.querySelector('.supervisor-workspace__graph-list');
const canvas = document.querySelector('.supervisor-workspace__graph-canvas');
const shell = document.querySelector('.page-shell');
list.scrollIntoView({block:'end'});
const pageHeight = shell.scrollHeight, pageTop = shell.scrollTop;
list.scrollTop = list.scrollHeight;
const last = list.lastElementChild.getBoundingClientRect();
const button = list.querySelector('button:last-of-type');
const b = button.getBoundingClientRect(), r = list.getBoundingClientRect();
return { width: innerWidth, height: r.height, canvasHeight: canvas.getBoundingClientRect().height,
clientHeight: list.clientHeight, scrollHeight: list.scrollHeight, scrollTop: list.scrollTop,
bottomGap: r.bottom - last.bottom,
paddingBottom: parseFloat(getComputedStyle(list).paddingBottom),
bottomVisible: last.bottom <= r.bottom && last.top >= r.top,
lastButtonHit: button.contains(document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2)),
pageStable: pageHeight === shell.scrollHeight && pageTop === shell.scrollTop,
pageHeight };
})()`)
assert(scrolling.scrollHeight > scrolling.clientHeight, 'Dense list must scroll locally')
assert(Math.abs(scrolling.scrollTop + scrolling.clientHeight - scrolling.scrollHeight) <= 1, 'List bottom unreachable')
assert(scrolling.bottomVisible && scrolling.lastButtonHit, 'Last relation and trailing copy must be visible')
assert(Math.abs(scrolling.bottomGap - scrolling.paddingBottom) <= 1,
`List must use the full scroll area, leaving only its padding: ${JSON.stringify(scrolling)}`)
assert(scrolling.pageStable, 'List scrolling must not move or grow the page')
if (width >= 1024)
assert(Math.abs(scrolling.height - scrolling.canvasHeight) < 1, 'Dense list must follow canvas height')
else
assert(scrolling.height <= 320, 'Single-column list must remain bounded')
reports.push({ denseScrolling: scrolling })
await settle()
await writeFile(join(artifacts, `dense-bottom-${width}.png`), (await win.webContents.capturePage()).toPNG())
}
win.setContentSize(1440, 1100)
await js(
'document.querySelector(".supervisor-workspace__graph-list button:last-of-type").click()'
)
await settle()
assert(
await js('document.querySelectorAll("[data-relation]").length === 1'),
'Selected relation endpoints must remain visible'
)
const target = await js(
`(() => { const e = document.querySelectorAll('.supervisor-workspace__graph-list button')[79]; e.scrollIntoView({block:'center'}); const r = e.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), hit: e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) }; })()`
)
assert(target.hit, 'Last event is not reachable')
win.webContents.sendInputEvent({
type: 'mouseDown',
x: target.x,
y: target.y,
button: 'left',
clickCount: 1
})
win.webContents.sendInputEvent({
type: 'mouseUp',
x: target.x,
y: target.y,
button: 'left',
clickCount: 1
})
await settle()
assert(
await js(
`!!document.querySelector('.supervisor-workspace__map [aria-label="模拟事件 80"][aria-pressed=true]')`
)
)
assert.equal(
await js(
'document.querySelectorAll(".supervisor-workspace__graph-list button").length'
),
121
)
const positions = await js(
'[...document.querySelectorAll(".supervisor-workspace__node:not(.supervisor-workspace__entity) circle")].map(e => [e.getAttribute("cx"), e.getAttribute("cy")].join())'
)
assert.equal(new Set(positions).size, 8)
assert(await js(`(() => {
return [...document.querySelectorAll('.supervisor-workspace__event-index')].every(e => {
const range = document.createRange(); range.selectNodeContents(e);
const tops = [...range.getClientRects()].map(r => r.top);
return Math.max(...tops) - Math.min(...tops) < 1;
});
})()`), 'Dense event numbers must not wrap')
await js(
'document.querySelector(".supervisor-workspace__graph-canvas").scrollIntoView({block:"start"})'
)
await settle()
await writeFile(
join(artifacts, 'dense-selected.png'),
(await win.webContents.capturePage()).toPNG()
)
await js('document.querySelector("#supervisor-tab-settings").click()')
await settle()
assert.equal(
await js('document.querySelectorAll("h1").length'),
1,
'Duplicate settings h1'
)
await writeFile(
join(artifacts, 'settings.png'),
(await win.webContents.capturePage()).toPNG()
)
await js('document.querySelector("#heartbeat-tab-plans").click()')
for (const width of [1440, 1024, 390]) {
win.setContentSize(width, 1100)
await js('document.querySelector(".page-shell").scrollTop = 0')
await settle()
const sizes = await js(`(() => {
const shell = document.querySelector('.page-shell');
return { viewport: innerWidth, page: document.documentElement.scrollWidth,
clientWidth: shell.clientWidth, scrollWidth: shell.scrollWidth, headings: document.querySelectorAll('h1').length };
})()`)
assert(
sizes.page <= width && sizes.scrollWidth <= sizes.clientWidth,
'Settings overflow'
)
assert.equal(sizes.headings, 1)
reports.push({ settings: sizes })
await writeFile(
join(artifacts, `settings-plans-${width}.png`),
(await win.webContents.capturePage()).toPNG()
)
}
for (const query of [
'story=1',
'story=2',
'long=1',
'state=loading',
'state=error',
'state=unavailable',
'state=empty'
]) {
await win.loadURL(process.env.GOODBUDDY_SUPERVISOR_URL + '?' + query)
await wait('!!document.querySelector("#supervisor-tab-graph")')
await js('document.querySelector("#supervisor-tab-graph").click()')
await wait(
query === 'state=loading'
? '!!document.querySelector(".empty-state")'
: '!document.querySelector(".supervisor-workspace[aria-busy=true]")'
)
for (const width of [1440, 1024, 390]) {
win.setContentSize(width, 1100)
await settle()
assert(
await js('document.documentElement.scrollWidth <= innerWidth'),
`Overflow: ${query}/${width}`
)
if (!query.startsWith('state=')) {
const labels = await js(
'[...document.querySelectorAll(".supervisor-workspace__map text")].map(e => { const b = e.getBBox(); return { text:e.textContent, x:b.x, y:b.y, width:b.width, height:b.height } })'
)
for (const [index, a] of labels.entries()) {
for (const b of labels.slice(index + 1)) {
assert(
!(
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
),
`Overlapping ${query}: ${a.text} / ${b.text}`
)
}
}
reports.push({ scenario: query, viewport: width, labels })
}
await writeFile(
join(artifacts, `${query.replace('=', '-')}-${width}.png`),
(await win.webContents.capturePage()).toPNG()
)
}
}
await js('document.querySelector("#supervisor-tab-overview").click()')
await settle()
await js(
'document.querySelector(".supervisor-workspace__toolbar .primary-button").click()'
)
await wait(
'!!document.querySelector(".supervisor-workspace__inline-error")'
)
assert(
await js(
'document.querySelector("[role=alert]").textContent.includes("模型暂时不可用")'
)
)
await writeFile(
join(artifacts, 'manual-review-error-390.png'),
(await win.webContents.capturePage()).toPNG()
)
assert.deepEqual(errors, [])
await writeFile(
join(artifacts, 'measurements.json'),
JSON.stringify(reports, null, 2)
)
console.log(
JSON.stringify(
{
artifacts,
cases: reports.map(({ texts, tokens, ...report }) => ({
...report,
svgTexts: texts?.length,
resolvedTokens: tokens && Object.keys(tokens).length
})),
errors
},
null,
2
)
)
win.destroy()
app.quit()
})
.catch((error) => {
console.error(error)
app.exit(1)
})
@@ -0,0 +1,229 @@
// Simulated visual fixture only. No preload, user database, or model is used.
import { createRoot } from 'react-dom/client'
import { HeartbeatCenter } from '../../src/renderer/src/HeartbeatCenter'
import { PageShell } from '../../src/renderer/src/WorkspacePrimitives'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import type { SupervisionGraphView } from '../../src/shared/supervision-contracts'
import '../../src/renderer/src/styles.css'
import '@fontsource-variable/noto-sans-sc/wght.css'
import { installBundledUiFonts } from '../../src/renderer/src/fonts'
installBundledUiFonts()
const params = new URLSearchParams(location.search)
const stories = [
{
title: '监督者 · 故事图谱',
events: [
'观察工作过程',
'记录关键决定',
'整理阶段总结',
'连接原始记录',
'核对归纳依据',
'保留知识来路',
'确认来源追溯',
'继续未完成工作'
],
entities: [
'工作过程',
'关键决定',
'阶段总结',
'原始记录',
'归纳依据',
'来源追溯'
]
},
{
title: '个人知识库',
events: [
'记录学习问题',
'整理问答笔记',
'发现共同主题',
'建立知识关联',
'补充适用条件',
'核对引用原文',
'保留知识出处',
'复用已有知识'
],
entities: [
'学习记录',
'问答笔记',
'共同主题',
'知识关联',
'适用条件',
'知识出处'
]
},
{
title: '记忆架构',
events: [
'保存原始数据',
'整理过程信息',
'区分理解层次',
'建立分层记忆',
'说明归纳依据',
'检验知识结论',
'连接底层记录',
'形成可解释归纳'
],
entities: [
'原始数据',
'过程信息',
'DIKW 分层',
'分层记忆',
'归纳依据',
'可解释归纳'
]
}
]
const story = stories[Number(params.get('story') ?? 0)] ?? stories[0]
const titles = story.events
const labels = story.entities
const dense = params.has('dense')
const long = params.has('long')
const state = params.get('state')
const graph: SupervisionGraphView = {
storyLine: { id: 'fixture', scope_json: '{"kind":"global"}' },
events: Array.from({ length: dense ? 80 : 8 }, (_, index) => ({
id: `event-${index}`,
title: dense
? `模拟事件 ${index + 1}`
: titles[index] +
(long ? ':跨团队讨论中仍需保留完整的上下文与决策依据'.repeat(3) : ''),
description:
'阶段总结与聚合知识应保留来源,支持追溯原始记录。每次形成判断,都可以回到当时的问题、决定和未完成事项。此处为固定模拟内容。',
occurred_at: dense
? '2026-09-21T08:00:00.000Z'
: `2026-09-${String(14 + index).padStart(2, '0')}T08:00:00.000Z`
})),
entities: Array.from({ length: dense ? 40 : 6 }, (_, index) => ({
id: `entity-${index}`,
canonical_label: dense
? `模拟实体 ${index + 1}`
: labels[index] + (long ? '需要关联跨阶段的完整原始记录'.repeat(4) : ''),
description:
'总结保留讨论的问题、形成的判断与尚未解决的事项。归纳需要连接原始依据,方便核对结论与理解语境。',
confirmation_state: 'automatic'
})),
relations: [
{
id: 'relation-1',
from_entity_id: 'entity-0',
to_entity_id: 'entity-5',
relation_type: 'supports',
reason: '归纳结果需要能够回到原始工作记录,核对产生结论时的上下文。',
confirmation_state: 'automatic'
}
],
sources: [
{
id: 'source-1',
title: '模拟会议记录',
occurred_at: '2026-09-21T08:00:00.000Z'
}
],
eventEntities: titles.map((_, index) => ({
event_id: `event-${index}`,
entity_id: `entity-${index % 6}`
})),
eventSources: titles.map((_, index) => ({
event_id: `event-${index}`,
source_id: 'source-1'
}))
}
if (params.has('short')) {
graph.events = graph.events.slice(0, 1)
graph.entities = graph.entities.slice(0, 1)
graph.relations = []
graph.eventEntities = graph.eventEntities.slice(0, 1)
graph.eventSources = graph.eventSources.slice(0, 1)
}
Object.defineProperty(window, 'goodbuddy', {
value:
state === 'unavailable'
? {}
: {
supervision: {
overview: async () => {
if (state === 'loading') return new Promise(() => {})
if (state === 'error')
throw new Error('SIMULATED: 读取监督者数据库失败,请重试。')
return state === 'empty' ? [] : [{ id: 'fixture-result', storyLineId: 'fixture', sourceId: 'source-1',
summary: '模拟回顾', changeDigest: '', createdAt: '2026-09-22T00:00:00Z',
scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' }, openItems: [] }]
},
graph: async () =>
state === 'empty'
? {
storyLine: null,
events: [],
entities: [],
relations: [],
sources: [],
eventEntities: [],
eventSources: []
}
: graph,
run: async () => {
throw new Error('SIMULATED: 模型暂时不可用。此验证不调用模型。')
},
entityAction: async () => {},
relationAction: async () => {},
source: async () => ({
title: '模拟会议记录',
content: '视觉 fixture,不是真实用户数据。',
occurredAt: '2026-09-21T08:00:00.000Z'
})
}
}
})
const noop = async () => {}
createRoot(document.getElementById('root')!).render(
<UiLocaleProvider initialPreference="zh-CN">
<div
style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
minWidth: 0
}}
>
<div
style={{
padding: 'var(--space-2) var(--space-4)',
color: 'var(--text-secondary)',
display: 'flex',
flexWrap: 'wrap',
gap: 'var(--space-3)'
}}
>
<span>SIMULATED FIXTURE / 单故事 API / {story.title}</span>
{stories.map((item, index) => (
<a key={item.title} href={`?story=${index}`}>
{item.title}
</a>
))}
</div>
<PageShell variant="supervisor">
<HeartbeatCenter
configs={[]}
runs={[]}
entries={[]}
memories={[]}
projects={[]}
tasks={[]}
onCreate={noop}
onUpdate={noop}
onSetPaused={noop}
onRemove={noop}
onRunNow={noop}
onRefresh={noop}
onSetMemoryStatus={noop}
onSetTaskStatus={noop}
onUseFollowUpTask={() => {}}
onRetryLoad={noop}
/>
</PageShell>
</div>
</UiLocaleProvider>
)
+36
View File
@@ -0,0 +1,36 @@
// Local, simulated fixture only. Run: node tests/support/supervisor-preview.mjs
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import process from 'node:process'
import console from 'node:console'
const cacheDir = await mkdtemp(join(tmpdir(), 'goodbuddy-supervisor-preview-'))
const server = await createServer({
configFile: false,
root: resolve('.'),
cacheDir,
plugins: [react(), {
name: 'supervisor-preview',
configureServer(server) {
server.middlewares.use('/supervisor.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.end(await server.transformIndexHtml('/supervisor.html',
'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SIMULATED Supervisor validation</title><body><div id="root"></div><script type="module" src="/tests/support/supervisor-layout-regression.tsx"></script></body></html>'))
})
}
}],
server: { host: '127.0.0.1', port: 4179, strictPort: true }
})
await server.listen()
console.log(`${server.resolvedUrls.local[0]}supervisor.html`)
console.log('Fixed simulated data. Scenarios: ?story=1, ?story=2, ?long=1, ?dense=1, ?state=loading, ?state=error, ?state=empty')
const close = async () => {
await server.close()
await rm(cacheDir, { recursive: true, force: true })
process.exit(0)
}
process.on('SIGINT', close)
process.on('SIGTERM', close)