feat(web): F1/F2/F3 前端——拆书入库为规则 / 续写式链选择 / 模板库
- gen:api 纳入 /templates、chain continue_volume、teardown rules ingest 端点 - F1:GeneratorRunner 识别拆书为单对象入库(整 preview 作一条 teardown), ingestTable(BookTeardownResult)=rules,buildIngestRequest 组装 teardown 体, 按 isSingleObjectIngest 隐藏勾选框、改文案「入库为规则」 - F2:ChainStarter 加链类型单选(draft_volume 从头写 / continue_volume 续写), chain_key 作 path 参传给 run(CHAIN_KINDS 对齐后端 SUPPORTED_CHAINS) - F3:模板库页 app/templates + 全局 nav 入口 + TemplatesManager(列/建/删,乐观更新回滚); GeneratorRunner 加 TemplateFiller「从模板填入」(body 填进 brief/text,纯前端) - vitest 覆盖 templates/ingest teardown/templateFillTarget/CHAIN_KINDS 纯逻辑
This commit is contained in:
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { chainPhase, chainResultView } from "@/lib/chain/chain";
|
||||
import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
@@ -17,8 +17,6 @@ interface ChainPageProps {
|
||||
project: ProjectResponse;
|
||||
}
|
||||
|
||||
const CHAIN_KEY = "draft_volume";
|
||||
|
||||
// 多章工作流链页(Scope B B1):发起链(POST run)→job 轮询进度→interrupt 命中则读该章冲突裁决→
|
||||
// POST resume 续跑。复用 useJobPoll(轮询)+ ConflictCard/decisions(裁决)+ friendlyError(文案)。
|
||||
// 链是后台任务,走 job 轮询而非 SSE。awaiting 时停轮询(后端把 job 置 awaiting_input,
|
||||
@@ -43,14 +41,18 @@ export function ChainPage({ project }: ChainPageProps) {
|
||||
}, [phase, poll]);
|
||||
|
||||
const onStart = useCallback(
|
||||
async (startChapterNo: number, count: number): Promise<void> => {
|
||||
async (
|
||||
chainKey: ChainKind,
|
||||
startChapterNo: number,
|
||||
count: number,
|
||||
): Promise<void> => {
|
||||
setStarting(true);
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/chains/{chain_key}/run",
|
||||
{
|
||||
params: {
|
||||
path: { project_id: project.id, chain_key: CHAIN_KEY },
|
||||
path: { project_id: project.id, chain_key: chainKey },
|
||||
},
|
||||
body: { start_chapter_no: startChapterNo, count },
|
||||
},
|
||||
@@ -89,7 +91,10 @@ export function ChainPage({ project }: ChainPageProps) {
|
||||
activeNav="chains"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-6">
|
||||
<ChainStarter onStart={(s, c) => void onStart(s, c)} disabled={busy} />
|
||||
<ChainStarter
|
||||
onStart={(k, s, c) => void onStart(k, s, c)}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{showProgress && result ? (
|
||||
<ChainProgress phase={phase} progress={poll.progress} result={result} />
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
|
||||
|
||||
interface ChainStarterProps {
|
||||
// 发起一条链(起始章 + 章数);父层负责 POST run + 轮询。
|
||||
onStart: (startChapterNo: number, count: number) => void;
|
||||
// 发起一条链(链类型 + 起始章 + 章数);父层负责 POST run + 轮询。
|
||||
onStart: (chainKey: ChainKind, startChapterNo: number, count: number) => void;
|
||||
// 链正在运行/等待裁决时禁用(避免重复发起)。
|
||||
disabled: boolean;
|
||||
}
|
||||
@@ -12,10 +14,12 @@ interface ChainStarterProps {
|
||||
const DEFAULT_START = 1;
|
||||
const DEFAULT_COUNT = 3;
|
||||
const MAX_COUNT = 50;
|
||||
const DEFAULT_CHAIN: ChainKind = "draft_volume";
|
||||
|
||||
// 链发起表单(净新):选起始章号 + 连续写几章 → 调 onStart。
|
||||
// count 1..50(对齐后端 ChainRunRequest Field 约束;前端先拦一道,越界后端 422 兜底)。
|
||||
export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
const [chainKey, setChainKey] = useState<ChainKind>(DEFAULT_CHAIN);
|
||||
const [start, setStart] = useState(String(DEFAULT_START));
|
||||
const [count, setCount] = useState(String(DEFAULT_COUNT));
|
||||
|
||||
@@ -34,7 +38,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
aria-label="发起多章链"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (valid && !disabled) onStart(startNo, countNo);
|
||||
if (valid && !disabled) onStart(chainKey, startNo, countNo);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
@@ -43,6 +47,33 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。
|
||||
</p>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm text-ink-soft">链类型</legend>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{CHAIN_KINDS.map((kind) => (
|
||||
<label
|
||||
key={kind.key}
|
||||
className="flex items-start gap-2 text-sm text-ink"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="chain-kind"
|
||||
value={kind.key}
|
||||
checked={chainKey === kind.key}
|
||||
onChange={() => setChainKey(kind.key)}
|
||||
className="mt-1"
|
||||
aria-label={kind.label}
|
||||
/>
|
||||
<span>
|
||||
<span className="block">{kind.label}</span>
|
||||
<span className="block text-xs text-ink-soft">
|
||||
{kind.description}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block text-sm text-ink-soft">
|
||||
起始章号
|
||||
|
||||
Reference in New Issue
Block a user