-
Notifications
You must be signed in to change notification settings - Fork 4
feat(history): add project workflow history #105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| # History 页面模块 | ||
|
|
||
| History 展示项目下的 WorkflowRun 和其内部 Revision。它回答“这次任务做到了哪一步、重做过几次、当前该继续还是只读查看”,不展示正式角色资产,也不记录 Playtest 核验结论。 | ||
|
|
||
| ## 数据层级 | ||
|
|
||
| ```text | ||
| Project | ||
| └── WorkflowRun(一次创建角色或生成动作任务) | ||
| └── WorkflowRevision(同一任务的一次执行版本) | ||
| └── WorkflowStep(该版本中的有序步骤) | ||
| ``` | ||
|
|
||
| 页面不能把 Revision 拍平成新的 Run。用户主动重做时 Run ID 不变,旧 Revision 仍用于解释新结果从哪里产生。 | ||
|
|
||
| ## 数据怎么进入页面 | ||
|
|
||
| 1. 路由提供 `projectId`。 | ||
| 2. 页面调用 `controller.listWorkflows(projectId)` 读取初始快照。 | ||
| 3. 页面通过 `controller.subscribeAll()` 接收全局变化,并再次按 `projectId` 过滤。 | ||
| 4. 页面卸载时调用 Controller 返回的取消订阅函数。 | ||
|
|
||
| 页面不接触 `WorkflowRunStore`、localStorage 或后端传输。History 在页面入口声明只包含 `listWorkflows` 与 `subscribeAll` 的只读接口;正式 WorkflowController 只要满足这两个方法就能注入。将来持久化方式改变时,History 无需跟着改写。 | ||
|
|
||
| ## 页面状态 | ||
|
|
||
| - **进行中**:可以继续;AI 驱动的 Run 返回 Quick Start,手动 Run 返回 Workflow Editor。 | ||
| - **已中断**:任务未失败,仍按原来的交互界面恢复。 | ||
| - **失败**:保留错误任务,供用户查看问题。 | ||
| - **已完成**:只读查看任务和全部 Revision。 | ||
| - **无效记录**:`currentRevisionId` 找不到对应 Revision 时明确报错,不让整个历史页崩溃。 | ||
|
|
||
| 每张 Run 卡片展示任务目的、最近 Revision 时间、当前版本、步骤进度和版本数量。展开后显示每个 Revision 的来源、重开步骤和步骤状态。当前 WorkflowRun 没有独立的 `updatedAt` 字段,因此页面以最新 Revision 的 `createdAt` 作为最近活动时间,不伪造 Entity 数据。 | ||
|
|
||
| History 只选择恢复目标并传递 `runId`。真正的状态恢复由 Quick Start 或 Workflow Editor 调用 `WorkflowController.resume(runId)` 完成,History 不复制恢复逻辑。 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The README assigns resume behavior to |
||
|
|
||
| 正式应用接入 `/projects/:projectId/history` 时,顶部产品导航仍由 AppShell 提供,但应放在普通文档流中。用户向下浏览较长的历史列表时,导航随页面一起滚走,不固定或吸附在视口顶部。 | ||
|
|
||
| ## 模块边界 | ||
|
|
||
| History 可以依赖 `@/entities` 的公开类型,并由外层注入满足只读接口的 WorkflowController,但不得: | ||
|
|
||
| - 直接读取 Store 或 localStorage。 | ||
| - 调用生成、候选确认、审核或发布命令。 | ||
| - 从 Character 或 Playtest 数据反推 WorkflowRun 状态。 | ||
| - 实现资产库、Workflow Editor 或 AppShell。 | ||
|
|
||
| ## 验证 | ||
|
|
||
| ```bash | ||
| npm test -- src/pages/history | ||
| npm run typecheck | ||
| npm run lint | ||
| npm run build | ||
| ``` | ||
|
|
||
| 测试覆盖项目隔离、最近 Revision 时间排序、四种 Run 状态、Revision 来源、步骤明细、订阅更新、取消订阅、空状态、错误状态和坏记录。 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| /** @vitest-environment jsdom */ | ||
| import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' | ||
| import { afterEach, describe, expect, it, vi } from 'vitest' | ||
| import { MemoryRouter, Route, Routes } from 'react-router' | ||
|
|
||
| import type { WorkflowRevision, WorkflowRun } from '@/entities' | ||
| import type { HistoryController } from './index' | ||
| import { HistoryPage } from './index' | ||
|
|
||
| const NOW = '2026-08-04T10:00:00.000Z' | ||
|
|
||
| function revision(id: string, options: Partial<WorkflowRevision> = {}): WorkflowRevision { | ||
| return { | ||
| id, | ||
| basedOnRevisionId: null, | ||
| restartStepId: null, | ||
| status: 'active', | ||
| steps: [ | ||
| { | ||
| id: `${id}-setup`, | ||
| type: 'character-setup', | ||
| status: 'passed', | ||
| input: null, | ||
| output: null, | ||
| taskId: null, | ||
| referenceStepIds: [], | ||
| }, | ||
| { | ||
| id: `${id}-template`, | ||
| type: 'character-template', | ||
| status: 'active', | ||
| input: null, | ||
| output: null, | ||
| taskId: 'generation-1', | ||
| referenceStepIds: [], | ||
| }, | ||
| ], | ||
| generationStatus: 'in_progress', | ||
| exportStatus: 'not_exported', | ||
| createdAt: NOW, | ||
| ...options, | ||
| } | ||
| } | ||
|
|
||
| type RunOptions = Partial<WorkflowRun> & { revisionCreatedAt?: string } | ||
|
|
||
| function run(id: string, options: RunOptions = {}): WorkflowRun { | ||
| const { revisionCreatedAt = NOW, ...overrides } = options | ||
| const current = revision(`${id}-revision`, { createdAt: revisionCreatedAt }) | ||
| const base: WorkflowRun = { | ||
| id, | ||
| projectId: 'project-1', | ||
| purpose: 'create_character', | ||
| driver: 'manual', | ||
| status: 'active', | ||
| currentRevisionId: current.id, | ||
| revisions: [current], | ||
| prompt: `任务 ${id}`, | ||
| characterId: null, | ||
| outfitId: null, | ||
| } | ||
| return { ...base, ...overrides } | ||
| } | ||
|
|
||
| function controller(initial: WorkflowRun[] = []): HistoryController & { | ||
| emit(items: WorkflowRun[]): void | ||
| unsubscribe: ReturnType<typeof vi.fn> | ||
| } { | ||
| let listener: ((runs: WorkflowRun[]) => void) | null = null | ||
| const unsubscribe = vi.fn() | ||
| return { | ||
| listWorkflows: vi.fn(() => initial), | ||
| subscribeAll: vi.fn((nextListener) => { | ||
| listener = nextListener | ||
| return unsubscribe | ||
| }), | ||
| emit(items) { | ||
| listener?.(items) | ||
| }, | ||
| unsubscribe, | ||
| } | ||
| } | ||
|
|
||
| function renderHistory(testController: HistoryController, path = '/projects/project-1/history') { | ||
| return render( | ||
| <MemoryRouter initialEntries={[path]}> | ||
| <Routes> | ||
| <Route | ||
| path="/projects/:projectId/history" | ||
| element={<HistoryPage controller={testController} />} | ||
| /> | ||
| </Routes> | ||
| </MemoryRouter>, | ||
| ) | ||
| } | ||
|
|
||
| afterEach(cleanup) | ||
|
|
||
| describe('HistoryPage', () => { | ||
| it('只展示当前项目,并按最近 Revision 时间从新到旧排列', () => { | ||
| const older = run('older-run', { revisionCreatedAt: '2026-08-03T10:00:00.000Z' }) | ||
| const newer = run('newer-run', { revisionCreatedAt: '2026-08-04T11:00:00.000Z' }) | ||
| const anotherProject = run('foreign-run', { projectId: 'project-2' }) | ||
| const testController = controller([older, anotherProject, newer]) | ||
|
|
||
| renderHistory(testController) | ||
|
|
||
| const cards = screen.getAllByTestId('history-run') | ||
| expect(cards).toHaveLength(2) | ||
| expect(within(cards[0]!).getByText('任务 newer-run')).toBeTruthy() | ||
| expect(within(cards[1]!).getByText('任务 older-run')).toBeTruthy() | ||
| expect(screen.queryByText('任务 foreign-run')).toBeNull() | ||
| expect(testController.listWorkflows).toHaveBeenCalledWith('project-1') | ||
| }) | ||
|
|
||
| it('区分四种 Run 状态,并为活动与终态提供不同操作文案', () => { | ||
| renderHistory( | ||
| controller([ | ||
| run('active-run'), | ||
| run('paused-run', { status: 'interrupted' }), | ||
| run('failed-run', { status: 'failed' }), | ||
| run('done-run', { status: 'completed' }), | ||
| ]), | ||
| ) | ||
|
|
||
| expect(screen.getByRole('heading', { name: '进行中' })).toBeTruthy() | ||
| expect(screen.getByRole('heading', { name: '已中断' })).toBeTruthy() | ||
| expect(screen.getByRole('heading', { name: '失败' })).toBeTruthy() | ||
| expect(screen.getByRole('heading', { name: '已完成' })).toBeTruthy() | ||
| expect(screen.getAllByRole('link', { name: '继续任务' })).toHaveLength(2) | ||
| expect(screen.getAllByRole('link', { name: '查看记录' })).toHaveLength(2) | ||
| }) | ||
|
|
||
| it('继续任务时回到创建该 Run 的交互界面', () => { | ||
| renderHistory( | ||
| controller([ | ||
| run('ai-run', { driver: 'ai' }), | ||
| run('manual-run', { driver: 'manual', status: 'interrupted' }), | ||
| ]), | ||
| ) | ||
|
|
||
| const aiCard = screen.getByText('任务 ai-run').closest('article') | ||
| const manualCard = screen.getByText('任务 manual-run').closest('article') | ||
| expect(aiCard).not.toBeNull() | ||
| expect(manualCard).not.toBeNull() | ||
| expect(within(aiCard!).getByRole('link', { name: '继续任务' }).getAttribute('href')).toBe( | ||
| '/quick-start/ai-run', | ||
| ) | ||
| expect(within(manualCard!).getByRole('link', { name: '继续任务' }).getAttribute('href')).toBe( | ||
| '/workflow-editor/manual-run', | ||
| ) | ||
| }) | ||
|
|
||
| it('展开 Run 后展示 Revision 来源与步骤状态', () => { | ||
| const first = revision('revision-1', { status: 'abandoned' }) | ||
| const second = revision('revision-2', { | ||
| basedOnRevisionId: first.id, | ||
| restartStepId: 'character-template', | ||
| status: 'active', | ||
| }) | ||
| const item = run('restarted-run', { | ||
| currentRevisionId: second.id, | ||
| revisions: [first, second], | ||
| }) | ||
|
|
||
| renderHistory(controller([item])) | ||
| fireEvent.click(screen.getByText('查看 2 个版本')) | ||
|
|
||
| expect(screen.getByText('首次执行')).toBeTruthy() | ||
| expect(screen.getByText('基于版本 revision,从 角色候选生成 重开')).toBeTruthy() | ||
| expect(screen.getAllByText('角色设定')).toHaveLength(2) | ||
| expect(screen.getAllByText('已通过')).toHaveLength(2) | ||
| }) | ||
|
|
||
| it('响应全局订阅但继续按项目过滤,并在卸载时取消订阅', () => { | ||
| const testController = controller([]) | ||
| const view = renderHistory(testController) | ||
| expect(screen.getByText('还没有创作记录')).toBeTruthy() | ||
|
|
||
| act(() => { | ||
| testController.emit([run('arrived-run'), run('foreign-run', { projectId: 'project-2' })]) | ||
| }) | ||
| expect(screen.getByText('任务 arrived-run')).toBeTruthy() | ||
| expect(screen.queryByText('任务 foreign-run')).toBeNull() | ||
|
|
||
| view.unmount() | ||
| expect(testController.unsubscribe).toHaveBeenCalledTimes(1) | ||
| }) | ||
|
|
||
| it('无效 currentRevisionId 不会让页面崩溃,而是标出待修复记录', () => { | ||
| renderHistory(controller([run('broken-run', { currentRevisionId: 'missing-revision' })])) | ||
|
|
||
| expect( | ||
| screen.getByText('当前版本 missing-revision 不存在,这条记录需要修复后才能继续。'), | ||
| ).toBeTruthy() | ||
| }) | ||
|
|
||
| it('读取失败时展示原始错误,不伪造空历史', () => { | ||
| const testController = controller([]) | ||
| vi.mocked(testController.listWorkflows).mockImplementation(() => { | ||
| throw new Error('历史服务暂不可用') | ||
| }) | ||
|
|
||
| renderHistory(testController) | ||
|
|
||
| expect(screen.getByRole('alert').textContent).toContain('历史服务暂不可用') | ||
| expect(screen.queryByText('还没有创作记录')).toBeNull() | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This says the formal
WorkflowControllercan be injected if it provideslistWorkflows/subscribeAll, but the current exportedWorkflowControllerinterface has neither method. As written, the documented integration path cannot compile against the actual controller; please add an adapter/interface that exists in the codebase or correct the README.