merge: UX 审稿人体学(R1/R2/R3) + Tier4 全局 AI 工具条 + 本章指令直通

R1/R2/R3: 冲突卡内联片段 + 分组/排序/跳下一条/主次按钮 + 验收前清单。
T4-a: 全局 AI 工具条(AppShell 顶栏下,--chrome CSS 变量适配固定高度页)。
T4-b: draft 端点接收本章指令 directive 直通 assemble(volatile, 守缓存前缀),前端指令框+风格预设。
门禁绿: 后端 pytest 479 / 前端 vitest 213 / build。
This commit is contained in:
Yaojia Wang
2026-06-20 18:33:01 +02:00
29 changed files with 895 additions and 89 deletions

View File

@@ -176,3 +176,6 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移无漂移users st
- [2026-06-17] @docs 四份规格 + CLAUDE.md + 协同系统PROGRESS/memory就绪可进 Phase 0 / T0.1
- [2026-06-19] @llm **K1.2 修头**Kimi Code 适配器 `kimi_code_headers()` 改为**** `{"User-Agent": "KimiCLI/1.5"}`删全部 X-Msh-* + `kimi_device_id`/`KIMI_DEVICE_ID`/确定性 device-id 机制K1.5 联网实测有效 token X-Msh-* 疑似触发 `401 Invalid Authentication`对齐 pi-kimi-coder = UA-only。改 `adapters/kimi_code.py` + `__init__` 导出 + 两测adapter 6 /factory 3 default_headers UA X-Msh-*)。 contracts C1扩头集 UA-only+ gotcha门禁绿ruff/format/mypy/pytest56 passed)。
- [2026-06-19] @backend **bugfix 空写章 prompt**`assemble`C5从不含项目 premise/logline/theme/title 且无写章指令 全新项目 premise产空 `stable_core`+`volatile`写章触发 LLM `400 message must not be empty`修复`MemoryRepos` 加第 8 repo `project:ProjectSpecRepo``SqlProjectSpecRepo` project_id `projects``assemble` 作品蓝本放入 `stable_core` 首段书级 spec缓存前缀 #9+ `volatile` 始终含请创作第 N 章的正文。」。 `domain/repositories.py`(+ProjectSpecView/Repo) · `memory/sql_repositories.py`(+SqlProjectSpecRepo, sql_memory_repos 装配) · `memory/assemble.py`(_build_spec_section + 注入)测试 `test_memory.py`(+bare-project/chapter-directive 两测+fake) · `test_projects.py`/`test_generation.py`( project= fake)。无 DDL/迁移只读既有列)。门禁全绿ruff/format/mypy(154 文件)/alembic check(无漂移)/pytest **438 passed** contracts C5 + gotcha消费方注意手搓 `MemoryRepos(...)` 须补 `project=`
- [2026-06-20] @frontend **UX R1+R2 完成**审稿裁决人体学纯前端无后端改)。R1冲突卡内联命中段预览——新纯函数 `lib/review/snippet.ts` `where` N 解析 1-based 段号 取终稿对应段截断预览无段号/越界/空段null 回退跳转」),`ConflictCard` `snippet` prop 渲染朱砂引用块R2冲突按 type 分组 + 严重度排序设定违例>能力不符>时间线倒错>地理矛盾>性格漂移,未知殿后)+ 组计数徽标 + 「跳到下一条未裁决」(按显示顺序环回)+ 主次裁决按钮(采纳改法=朱砂主、忽略/手改=次级)——新纯函数 `lib/review/grouping.ts``groupConflicts`/`displayOrder`/`nextUnresolvedInOrder`**分组只改显示顺序、每条携原始 index守 conflict_index 覆盖 range 不变量**`ReviewReport` 改分组渲染。TDDsnippet 8 测 + grouping 10 测。前端门禁绿lint/tsc/**vitest 201**(+18)/build。分支 `feat/ux-r1-r2-review-ergonomics`。下一步(按 planR3 验收前清单 → Tier4。
- [2026-06-20] @frontend**UX R3 完成**(验收前清单 HITL gateUX §7.4,纯前端无后端改,续 `feat/ux-r1-r2-review-ergonomics`)。新纯函数 `lib/review/accept-preview.ts``buildAcceptPreview`:据现有 client state 列「本次验收将更新」——晋升版次 + 提炼章节摘要 + 写回裁决(有冲突时) + 验收后伏笔到期扫描(可能置 OVERDUE) + 伏笔建议待登记提醒(有建议时))。**只列后端 accept 事务确实做的事**(核对 `projects.py` accept 端点promote+digest+decisions+overdue 扫描;**不**声称改人物 latest_state/自动落库伏笔建议);带「以上为预期,最终以验收回执为准」免责。`AcceptPanel``foreshadowCount` prop + 未阻断时渲染清单,`ReviewReport``foreshadow.length`。TDD4 测。前端门禁绿lint/tsc/**vitest 205**(+4)/build。下一步按 planTier4T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。
- [2026-06-20] @frontend**Tier4 完成**T4-a 全局 AI 工具条 + T4-b 本章指令直通生成,分支 `feat/ux-tier4-ai-toolbar`,未并 develop。T4-a`lib/nav/ai-tools.ts`(aiToolItems) + `components/AiToolbar.tsx` 服务端组件AppShell 顶栏下仅项目页渲染(写本章/审稿/大纲/设定库/工具箱常驻一排,写本章=朱砂主动作),用 `--chrome` CSS 变量统一 chrome 高度(项目页 7rem=header4+toolbar3余 4rem6 个固定高度页改用 `calc(100vh-var(--chrome,4rem))` 适配。T4-b全链后端 `assemble/_build_volatile``directive` 形参(「本章指令」段领衔 volatile**绝不入 stable_core**,守缓存前缀不变量 #9`stream_draft` 加可选 body `DraftStreamRequest{directive}` 透传、`directive_len` 入 log 不记原文、不持久化无迁移,重生成 TS 客户端;前端新 `lib/workbench/directive.ts`(STYLE_PRESETS + composeDirective 纯函数)、`useDraftStream.start(p,no,directive?)` 非空时发 JSON body、Workbench 加可折叠「本章指令」textarea + 风格预设 chips。TDDai-tools 4 测 + directive 4 测 + assemble directive 测 + draft 端点 directive/向后兼容 2 测。门禁全绿:后端 ruff/format/mypy(163 文件)/**pytest 479 passed**;前端 lint/tsc/**vitest 213**/build。契约记 contracts C3 扩T4-b

View File

@@ -196,6 +196,33 @@ async def test_draft_stream_yields_sse_tokens_and_done() -> None:
assert gateway.requests[0].tier == "writer"
@pytest.mark.asyncio
async def test_draft_stream_threads_directive_into_volatile() -> None:
gateway = FakeWriterGateway(chunks=["正文"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/chapters/1/draft", json={"directive": "多写战斗"}
)
assert resp.status_code == 200
assert len(gateway.requests) == 1
# 指令直通 assemble→volatilewrite 节点把 volatile 放进 LlmRequest.input
assert "多写战斗" in str(gateway.requests[0].input)
@pytest.mark.asyncio
async def test_draft_stream_backward_compatible_without_body() -> None:
gateway = FakeWriterGateway(chunks=["正文"])
client, _, _, _ = _make_client(gateway=gateway)
pid = uuid.uuid4()
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/draft")
assert resp.status_code == 200
assert len(gateway.requests) == 1
assert "本章指令" not in str(gateway.requests[0].input)
@pytest.mark.asyncio
async def test_draft_stream_maps_error_to_sse_error_event() -> None:
from ww_shared import AppError

View File

@@ -53,6 +53,7 @@ from ww_api.schemas.projects import (
AcceptResponse,
DraftResponse,
DraftSaveRequest,
DraftStreamRequest,
DraftView,
ProjectCreateRequest,
ProjectListResponse,
@@ -240,11 +241,17 @@ async def stream_draft(
gateway: GatewayDep,
injection_repo: InjectionRepoDep,
session: Annotated[AsyncSession, Depends(get_session)],
body: DraftStreamRequest | None = None,
) -> StreamingResponse:
"""流式写章草稿:组装记忆(含作者注入覆盖)→ 网关流 → 归一 SSE 事件 → text/event-stream。"""
"""流式写章草稿:组装记忆(含作者注入覆盖 + 本章指令)→ 网关流 → 归一 SSE event-stream。
`body.directive`可选T4-b是临时本章指令直通 assemble→volatile不持久化
无 body 的旧调用方仍可用(向后兼容)。
"""
request_id = getattr(request.state, "request_id", None)
directive = body.directive if body else None
override = await injection_repo.get(project_id, chapter_no)
context = await assemble(repos, project_id, chapter_no, override=override)
context = await assemble(repos, project_id, chapter_no, override=override, directive=directive)
log.info(
"draft_stream_start",
project_id=str(project_id),
@@ -252,6 +259,7 @@ async def stream_draft(
request_id=request_id,
stable_core_len=len(context.stable_core),
volatile_len=len(context.volatile),
directive_len=len(body.directive) if body and body.directive else 0,
)
deltas = stream_chapter_draft(

View File

@@ -42,6 +42,16 @@ class ProjectListResponse(BaseModel):
projects: list[ProjectResponse] = Field(default_factory=list)
class DraftStreamRequest(BaseModel):
"""POST /projects/:id/chapters/:no/draft本章生成的可选输入T4-b
`directive` = 作者本章指令(覆盖/补充大纲节拍)。临时输入,不持久化、无迁移;
后端只入 volatile守缓存前缀不变量 #9。无 body 时退化为纯按大纲生成(向后兼容)。
"""
directive: str | None = None
class DraftSaveRequest(BaseModel):
"""PUT /projects/:id/chapters/:no/draft自动保存草稿正文。"""

View File

@@ -0,0 +1,48 @@
import Link from "next/link";
import { aiToolItems } from "@/lib/nav/ai-tools";
import type { ActiveNav } from "@/lib/nav/items";
interface AiToolbarProps {
projectId: string;
activeNav?: ActiveNav;
}
// T4-a · 项目内页常驻 AI 工具条顶栏下UX §3/§5
// 纯链接服务端组件:写本章=朱砂主动作,其余次级;激活项朱砂高亮。窄屏横向滚动。
export function AiToolbar({ projectId, activeNav }: AiToolbarProps) {
return (
<nav
aria-label="AI 工具条"
className="flex h-12 items-center gap-2 overflow-x-auto whitespace-nowrap border-b border-line bg-panel px-4 sm:px-6"
>
{aiToolItems(projectId).map((item) => {
const isActive = item.key === activeNav;
const className = toolClassName(item.primary === true, isActive);
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
className={className}
>
<span aria-hidden="true">{item.glyph}</span>
<span>{item.label}</span>
</Link>
);
})}
</nav>
);
}
function toolClassName(isPrimary: boolean, isActive: boolean): string {
const base =
"flex items-center gap-1.5 rounded border px-3 py-1.5 text-sm transition-colors";
if (isActive) {
return `${base} border-cinnabar text-cinnabar`;
}
if (isPrimary) {
return `${base} border-cinnabar bg-cinnabar text-panel hover:border-cinnabar`;
}
return `${base} border-line text-ink-soft hover:border-cinnabar hover:text-cinnabar`;
}

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import type { ReactNode } from "react";
import type { ActiveNav } from "@/lib/nav/items";
import { AiToolbar } from "./AiToolbar";
import { LeftNav } from "./LeftNav";
import { NavDrawer } from "./NavDrawer";
@@ -25,7 +26,10 @@ export function AppShell({
activeNav,
}: AppShellProps) {
return (
<div className="min-h-screen">
<div
className="min-h-screen"
style={{ ["--chrome" as string]: projectId ? "7rem" : "4rem" }}
>
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-4 sm:px-6">
<NavDrawer projectId={projectId} activeNav={activeNav} />
<Link
@@ -50,9 +54,14 @@ export function AppShell({
</Link>
</nav>
</header>
{projectId ? (
<AiToolbar projectId={projectId} activeNav={activeNav} />
) : null}
<div className="flex">
<LeftNav projectId={projectId} activeNav={activeNav} />
<main className="min-h-[calc(100vh-4rem)] flex-1 bg-bg">{children}</main>
<main className="min-h-[calc(100vh-var(--chrome,4rem))] flex-1 bg-bg">
{children}
</main>
</div>
</div>
);

View File

@@ -59,7 +59,7 @@ export function CodexPage({
projectId={project.id}
activeNav="codex"
>
<div className="flex h-[calc(100vh-4rem)] flex-col p-4">
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<div className="mb-4 flex items-center gap-2" role="tablist">
{TABS.map((t) => (
<button

View File

@@ -46,7 +46,7 @@ export function ForeshadowBoard({
projectId={project.id}
activeNav="foreshadow"
>
<div className="flex h-[calc(100vh-4rem)] flex-col p-4">
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-4">
<div className="mb-4 flex items-start justify-between gap-4">
<h1 className="font-serif text-lg text-ink"></h1>
<RegisterForm

View File

@@ -31,7 +31,7 @@ export function OutlineEditor({
projectId={project.id}
activeNav="outline"
>
<div className="flex h-[calc(100vh-4rem)] flex-col p-6">
<div className="flex h-[calc(100vh-var(--chrome,4rem))] flex-col p-6">
<div className="mb-4 flex items-center gap-3">
<h1 className="font-serif text-lg text-ink"></h1>
<label htmlFor="vol" className="ml-auto text-xs text-ink-soft">

View File

@@ -1,10 +1,13 @@
"use client";
import type { AcceptResponse } from "@/lib/api/types";
import { buildAcceptPreview } from "@/lib/review/accept-preview";
interface AcceptPanelProps {
conflictCount: number;
unresolvedCount: number;
// R3当前伏笔建议数验收前清单提醒只读不自动落库
foreshadowCount: number;
accepting: boolean;
result: AcceptResponse | null;
onAccept: () => void;
@@ -14,11 +17,16 @@ interface AcceptPanelProps {
export function AcceptPanel({
conflictCount,
unresolvedCount,
foreshadowCount,
accepting,
result,
onAccept,
}: AcceptPanelProps) {
const blocked = unresolvedCount > 0;
const preview = buildAcceptPreview({
conflictCount,
foreshadowSuggestions: foreshadowCount,
});
if (result) {
return (
@@ -56,11 +64,35 @@ export function AcceptPanel({
{unresolvedCount}
</p>
) : (
<p className="mb-2 text-xs text-ink-soft">
{conflictCount === 0
? "无冲突,可直接验收。"
: "全部冲突已裁决,可验收本章。"}
</p>
<>
<p className="mb-2 text-xs text-ink-soft">
{conflictCount === 0
? "无冲突,可直接验收。"
: "全部冲突已裁决,可验收本章。"}
</p>
{/* R3验收前「本次将更新」清单预期落库口径以验收回执为准。 */}
<div className="mb-3 rounded border border-line/70 bg-bg/50 p-3">
<h4 className="mb-1.5 text-xs font-semibold text-ink">
</h4>
<ul className="space-y-1.5">
{preview.map((item) => (
<li key={item.label} className="flex gap-2 text-xs text-ink-soft">
<span className="text-cinnabar" aria-hidden="true">
</span>
<span>
<span className="text-ink">{item.label}</span>
<span className="text-ink-soft">{item.detail}</span>
</span>
</li>
))}
</ul>
<p className="mt-2 text-[11px] text-ink-soft/70">
</p>
</div>
</>
)}
<button
type="button"

View File

@@ -8,17 +8,27 @@ interface ConflictCardProps {
conflict: ReviewConflict;
draft: DecisionDraft;
missing: boolean;
// R1命中段内联预览无段号/越界时为 null回退到「跳转」按钮
snippet: string | null;
onVerdict: (index: number, verdict: Verdict) => void;
onNote: (index: number, note: string) => void;
onJump: (index: number) => void;
}
const VERDICT_OPTIONS: { value: Verdict; label: string }[] = [
{ value: "accept", label: "采纳改法" },
{ value: "ignore", label: "忽略" },
{ value: "manual", label: "手改" },
// R2采纳改法=朱砂主按钮primary忽略/手改=次级secondary不再三者同权。
const VERDICT_OPTIONS: { value: Verdict; label: string; primary: boolean }[] = [
{ value: "accept", label: "采纳改法", primary: true },
{ value: "ignore", label: "忽略", primary: false },
{ value: "manual", label: "手改", primary: false },
];
// 裁决按钮样式:激活=朱砂填充;未激活的主按钮=朱砂描边强调,次级=低对比描边。
function verdictClass(active: boolean, primary: boolean): string {
if (active) return "bg-cinnabar text-panel";
if (primary) return "border border-cinnabar text-cinnabar hover:bg-cinnabar/10";
return "border border-line text-ink-soft hover:border-cinnabar hover:text-ink";
}
// 单个冲突报告卡UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
// 冲突=赭红;未决/缺判时加图标+文案不单靠色a11y §10
export function ConflictCard({
@@ -26,6 +36,7 @@ export function ConflictCard({
conflict,
draft,
missing,
snippet,
onVerdict,
onNote,
onJump,
@@ -52,6 +63,15 @@ export function ConflictCard({
<p className="flex-1 text-sm text-ink">{conflict.suggestion}</p>
</div>
{snippet ? (
<blockquote className="mt-2 border-l-2 border-conflict/40 bg-bg/50 py-1 pl-3 text-xs leading-relaxed text-ink-soft">
<span className="mr-1 text-conflict" aria-hidden="true">
</span>
{snippet}
</blockquote>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2 pl-1 text-xs text-ink-soft">
{conflict.where ? (
<button
@@ -80,11 +100,10 @@ export function ConflictCard({
type="button"
aria-pressed={active}
onClick={() => onVerdict(index, opt.value)}
className={`rounded px-3 py-1 text-xs ${
active
? "bg-cinnabar text-panel"
: "border border-line text-ink hover:border-cinnabar"
}`}
className={`rounded px-3 py-1 text-xs ${verdictClass(
active,
opt.primary,
)}`}
>
{opt.label}
</button>

View File

@@ -19,6 +19,12 @@ import {
normalizeForeshadowSug,
normalizePace,
} from "@/lib/review/history";
import {
displayOrder,
groupConflicts,
nextUnresolvedInOrder,
} from "@/lib/review/grouping";
import { conflictSnippet } from "@/lib/review/snippet";
import { useAccept } from "@/lib/review/useAccept";
import { useReviewStream } from "@/lib/review/useReviewStream";
import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse";
@@ -139,12 +145,26 @@ export function ReviewReport({
const onNote = (index: number, note: string): void =>
setDrafts((prev) => setNote(prev, index, note));
// R2按 type 分组 + 严重度排序(仅改显示顺序,每条仍携原始 index
const groups = useMemo(() => groupConflicts(conflicts), [conflicts]);
const order = useMemo(() => displayOrder(groups), [groups]);
const jumpToCard = (index: number): void => {
setFocusedIndex(index);
document
.getElementById(`conflict-card-${index}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
};
// R2跳到显示顺序中的下一条未裁决环回全决则提示完成。
const jumpToNextUnresolved = (): void => {
const next = nextUnresolvedInOrder(order, drafts, focusedIndex);
if (next === null) {
toast("全部冲突已裁决。", "success");
return;
}
jumpToCard(next);
};
const jumpToAnchor = (index: number): void => {
setFocusedIndex(index);
document
@@ -195,7 +215,7 @@ export function ReviewReport({
projectId={project.id}
activeNav="review"
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[1fr_24rem]">
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[1fr_24rem]">
{/* 左:终稿正文 + 就地标注 */}
<section className="flex min-w-0 flex-col bg-bg">
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3">
@@ -276,20 +296,54 @@ export function ReviewReport({
: "进页未带审稿留痕,点「重新审稿」开始。"}
</p>
) : (
<ul className="space-y-3">
{conflicts.map((c, i) => (
<ConflictCard
key={i}
index={i}
conflict={c}
draft={drafts[i] ?? { verdict: null, note: "" }}
missing={missing.has(i)}
onVerdict={onVerdict}
onNote={onNote}
onJump={jumpToAnchor}
/>
<div className="space-y-4">
{/* R2未裁决进度 + 跳到下一条未裁决 */}
<div className="flex items-center justify-between text-xs text-ink-soft">
<span>
{unresolved > 0 ? (
<span className="text-conflict">
{unresolved}/{conflictCount}
</span>
) : (
<span className="text-pass"> </span>
)}
</span>
<button
type="button"
onClick={jumpToNextUnresolved}
disabled={unresolved === 0}
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
</div>
{groups.map((group) => (
<div key={group.type}>
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink">
{group.type}
<span className="rounded-full bg-[var(--color-conflict)]/10 px-2 py-0.5 font-mono text-conflict">
{group.items.length}
</span>
</h3>
<ul className="space-y-3">
{group.items.map(({ conflict: c, index: i }) => (
<ConflictCard
key={i}
index={i}
conflict={c}
draft={drafts[i] ?? { verdict: null, note: "" }}
missing={missing.has(i)}
snippet={conflictSnippet(finalText, c.where)}
onVerdict={onVerdict}
onNote={onNote}
onJump={jumpToAnchor}
/>
))}
</ul>
</div>
))}
</ul>
</div>
)}
</section>
@@ -322,6 +376,7 @@ export function ReviewReport({
<AcceptPanel
conflictCount={conflictCount}
unresolvedCount={resolved ? 0 : unresolved}
foreshadowCount={foreshadow.length}
accepting={accept.status === "accepting"}
result={accept.result}
onAccept={() => void onAccept()}

View File

@@ -27,7 +27,7 @@ export function StylePage({ project, initialFingerprint }: StylePageProps) {
projectId={project.id}
activeNav="style"
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[28rem_1fr]">
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[28rem_1fr]">
<section className="flex flex-col overflow-auto border-r border-line bg-panel px-6 py-6">
<h1 className="mb-3 font-serif text-lg text-ink"></h1>
<StyleUpload

View File

@@ -10,6 +10,7 @@ import { friendlyError } from "@/lib/errors/messages";
import { useAutosave } from "@/lib/autosave/useAutosave";
import { useDraftStream } from "@/lib/stream/useDraftStream";
import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter";
import { composeDirective, STYLE_PRESETS } from "@/lib/workbench/directive";
import { ChapterList, ChapterListContent } from "./ChapterList";
import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
import { Editor } from "./Editor";
@@ -32,6 +33,9 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
// stream.state.text 起始为空,仅当 streaming/done/aborted 且变化才写回)。
const [text, setText] = useState(initialText);
const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
// 本章指令T4-b自由文本 + 风格预设 → 写本章时组装传入 draft 生成。
const [directive, setDirective] = useState("");
const [presetIds, setPresetIds] = useState<string[]>([]);
const autosave = useAutosave(project.id, chapterNo);
const stream = useDraftStream();
const lastStreamText = useRef("");
@@ -52,7 +56,17 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
}, [stream.state.phase, stream.state.text, autosave]);
const onWrite = (): void => {
void stream.start(project.id, chapterNo);
void stream.start(
project.id,
chapterNo,
composeDirective(directive, presetIds),
);
};
const togglePreset = (id: string): void => {
setPresetIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const onEditorChange = (value: string): void => {
@@ -70,10 +84,16 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
projectId={project.id}
activeNav="write"
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
<ChapterList currentChapterNo={chapterNo} />
<section className="flex min-w-0 flex-col bg-bg">
<DirectivePanel
directive={directive}
onDirectiveChange={setDirective}
presetIds={presetIds}
onTogglePreset={togglePreset}
/>
<div className="flex-1 overflow-auto px-6 py-8">
<Editor
value={text}
@@ -120,6 +140,62 @@ export function Workbench({ project, initialText = "" }: WorkbenchProps) {
);
}
interface DirectivePanelProps {
directive: string;
onDirectiveChange: (value: string) => void;
presetIds: readonly string[];
onTogglePreset: (id: string) => void;
}
// 本章指令面板T4-b可折叠的 textarea + 风格快捷预设 chips。
// 默认折叠(不抢编辑视野);指令覆盖/补充大纲节拍,写本章时组装传入生成。
function DirectivePanel({
directive,
onDirectiveChange,
presetIds,
onTogglePreset,
}: DirectivePanelProps) {
return (
<details className="border-b border-line bg-panel px-6 py-2">
<summary className="cursor-pointer text-sm text-ink-soft hover:text-cinnabar">
</summary>
<div className="mt-2 space-y-2">
<label className="block">
<span className="sr-only"></span>
<textarea
value={directive}
onChange={(e) => onDirectiveChange(e.target.value)}
rows={2}
placeholder="本章想怎么写?(可选,覆盖/补充大纲节拍)"
className="w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink placeholder:text-ink-soft focus:border-cinnabar focus:outline-none"
/>
</label>
<div className="flex flex-wrap gap-2">
{STYLE_PRESETS.map((preset) => {
const active = presetIds.includes(preset.id);
return (
<button
key={preset.id}
type="button"
aria-pressed={active}
onClick={() => onTogglePreset(preset.id)}
className={
active
? "rounded-full border border-cinnabar bg-cinnabar px-3 py-1 text-xs text-panel"
: "rounded-full border border-line px-3 py-1 text-xs text-ink-soft hover:border-cinnabar hover:text-cinnabar"
}
>
{preset.label}
</button>
);
})}
</div>
</div>
</details>
);
}
// 写章失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function StreamErrorNote({
streamError,

View File

@@ -142,7 +142,10 @@ export interface paths {
put: operations["save_draft_projects__project_id__chapters__chapter_no__draft_put"];
/**
* Stream Draft
* @description 流式写章草稿:组装记忆(含作者注入覆盖)→ 网关流 → 归一 SSE 事件 → text/event-stream。
* @description 流式写章草稿:组装记忆(含作者注入覆盖 + 本章指令)→ 网关流 → 归一 SSE event-stream。
*
* `body.directive`可选T4-b是临时本章指令直通 assemble→volatile不持久化
* 无 body 的旧调用方仍可用(向后兼容)。
*/
post: operations["stream_draft_projects__project_id__chapters__chapter_no__draft_post"];
delete?: never;
@@ -839,6 +842,17 @@ export interface components {
/** Text */
text: string;
};
/**
* DraftStreamRequest
* @description POST /projects/:id/chapters/:no/draft本章生成的可选输入T4-b
*
* `directive` = 作者本章指令(覆盖/补充大纲节拍)。临时输入,不持久化、无迁移;
* 后端只入 volatile守缓存前缀不变量 #9。无 body 时退化为纯按大纲生成(向后兼容)。
*/
DraftStreamRequest: {
/** Directive */
directive?: string | null;
};
/**
* DraftView
* @description GET .../draft回灌已保存草稿含正文供工作台重载编辑器
@@ -1848,7 +1862,11 @@ export interface operations {
};
cookie?: never;
};
requestBody?: never;
requestBody?: {
content: {
"application/json": components["schemas"]["DraftStreamRequest"] | null;
};
};
responses: {
/** @description Successful Response */
200: {

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { aiToolItems } from "./ai-tools";
describe("aiToolItems", () => {
it("returns 5 items", () => {
expect(aiToolItems("p1")).toHaveLength(5);
});
it("leads with 写本章 (primary, key write)", () => {
const [first] = aiToolItems("p1");
expect(first.label).toBe("写本章");
expect(first.primary).toBe(true);
expect(first.key).toBe("write");
});
it("scopes every href under the project path", () => {
for (const item of aiToolItems("abc")) {
expect(item.href.startsWith("/projects/abc/")).toBe(true);
}
});
it("maps 审稿 to the review page and key", () => {
const review = aiToolItems("p1").find((item) => item.label === "审稿");
expect(review?.href).toBe("/projects/p1/review");
expect(review?.key).toBe("review");
});
});

View File

@@ -0,0 +1,29 @@
// T4-a · 顶部 AI 工具条数据纯逻辑UX §3/§5
// 把高频 AI 动作(写本章/审稿/大纲/设定库/工具箱)做成常驻一排,根治「命令面板太隐蔽」。
// 只露已实现能力;新生成器(创作工具箱 B 组)落地后并入工具箱入口。
// 组件只渲染;条目与激活键集中在此,复用 ActiveNav 高亮规则。
import type { ActiveNav } from "./items";
export interface AiToolItem {
href: string;
label: string;
// 行首字形纸感非语义aria-hidden 渲染)。
glyph: string;
// 高亮键(与 AppShell activeNav 比对)。
key: ActiveNav;
// 主动作(写本章)朱砂强调,其余次级。
primary?: boolean;
}
// 项目内页 AI 工具条条目(按已实现能力露出)。
export function aiToolItems(projectId: string): AiToolItem[] {
const base = `/projects/${projectId}`;
return [
{ href: `${base}/write`, label: "写本章", glyph: "✍", key: "write", primary: true },
{ href: `${base}/review`, label: "审稿", glyph: "✦", key: "review" },
{ href: `${base}/outline`, label: "大纲", glyph: "❡", key: "outline" },
{ href: `${base}/codex`, label: "设定库", glyph: "❖", key: "codex" },
{ href: `${base}/skills`, label: "工具箱", glyph: "✧", key: "skills" },
];
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { buildAcceptPreview } from "./accept-preview";
describe("buildAcceptPreview", () => {
it("always previews version promotion, digest, and overdue scan", () => {
const items = buildAcceptPreview({
conflictCount: 0,
foreshadowSuggestions: 0,
});
const labels = items.map((it) => it.label);
expect(labels).toContain("晋升章节版次");
expect(labels).toContain("提炼章节摘要");
expect(labels).toContain("扫描伏笔到期");
});
it("omits the decisions line when there are no conflicts", () => {
const items = buildAcceptPreview({
conflictCount: 0,
foreshadowSuggestions: 0,
});
expect(items.some((it) => it.label === "写回冲突裁决")).toBe(false);
});
it("includes the decisions line with the conflict count", () => {
const items = buildAcceptPreview({
conflictCount: 3,
foreshadowSuggestions: 0,
});
const decisions = items.find((it) => it.label === "写回冲突裁决");
expect(decisions?.detail).toContain("3");
});
it("reminds about pending foreshadow suggestions only when present", () => {
expect(
buildAcceptPreview({
conflictCount: 0,
foreshadowSuggestions: 0,
}).some((it) => it.label === "伏笔建议待登记"),
).toBe(false);
const items = buildAcceptPreview({
conflictCount: 0,
foreshadowSuggestions: 2,
});
const foreshadow = items.find((it) => it.label === "伏笔建议待登记");
expect(foreshadow?.detail).toContain("2");
});
});

View File

@@ -0,0 +1,49 @@
// R3 · 验收前「本次将更新」清单HITL gateUX §7.4)。
// 只列后端 accept 事务确实会做的事projects.py accept 端点):晋升版次 + 提炼章节摘要
// + 写回裁决 + 验收后伏笔到期扫描。**不**声称改人物 latest_state / 自动落库伏笔建议
// (那些不在 accept 事务里)。最终口径以后端 AcceptResponse 回执为准。
// 纯逻辑,便于 node 环境单测。
export interface AcceptPreviewInput {
conflictCount: number;
foreshadowSuggestions: number;
}
export interface AcceptPreviewItem {
label: string;
detail: string;
}
// 据现有客户端 state 生成验收前预览项(非落库口径,仅给作者一个「将要发生什么」的预期)。
export function buildAcceptPreview({
conflictCount,
foreshadowSuggestions,
}: AcceptPreviewInput): AcceptPreviewItem[] {
const items: AcceptPreviewItem[] = [
{
label: "晋升章节版次",
detail: "终稿晋升为新的接受版本(旧草稿保留可追溯)",
},
{
label: "提炼章节摘要",
detail: "从终稿提炼一行摘要,追加到记忆真源",
},
];
if (conflictCount > 0) {
items.push({
label: "写回冲突裁决",
detail: `${conflictCount} 条裁决落库留痕`,
});
}
items.push({
label: "扫描伏笔到期",
detail: "验收后台扫描,可能将逾期伏笔置为 OVERDUE",
});
if (foreshadowSuggestions > 0) {
items.push({
label: "伏笔建议待登记",
detail: `${foreshadowSuggestions} 条只读建议,验收不自动落库,需你另行登记`,
});
}
return items;
}

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { emptyDecisions, setVerdict } from "./decisions";
import {
displayOrder,
groupConflicts,
nextUnresolvedInOrder,
} from "./grouping";
import type { ReviewConflict } from "./sse";
function conflict(type: string, suggestion = "x"): ReviewConflict {
return { type, where: "", refs: [], suggestion };
}
describe("groupConflicts", () => {
it("groups by type and carries the original index", () => {
const conflicts = [
conflict("性格漂移", "a"),
conflict("设定违例", "b"),
conflict("性格漂移", "c"),
];
const groups = groupConflicts(conflicts);
const drift = groups.find((g) => g.type === "性格漂移");
expect(drift?.items.map((it) => it.index)).toEqual([0, 2]);
expect(drift?.items.map((it) => it.conflict.suggestion)).toEqual(["a", "c"]);
});
it("orders groups by severity (设定违例 before 性格漂移)", () => {
const groups = groupConflicts([
conflict("性格漂移"),
conflict("设定违例"),
]);
expect(groups.map((g) => g.type)).toEqual(["设定违例", "性格漂移"]);
});
it("places unknown types after the known five", () => {
const groups = groupConflicts([conflict("排版错误"), conflict("能力不符")]);
expect(groups.map((g) => g.type)).toEqual(["能力不符", "排版错误"]);
});
it("returns an empty array for no conflicts", () => {
expect(groupConflicts([])).toEqual([]);
});
});
describe("displayOrder", () => {
it("flattens grouped indices in display order", () => {
const groups = groupConflicts([
conflict("性格漂移"),
conflict("设定违例"),
conflict("性格漂移"),
]);
// 设定违例(index 1) 先,再 性格漂移(0,2)
expect(displayOrder(groups)).toEqual([1, 0, 2]);
});
});
describe("nextUnresolvedInOrder", () => {
const order = [1, 0, 2];
it("finds the first unresolved when nothing is focused", () => {
const drafts = emptyDecisions(3);
expect(nextUnresolvedInOrder(order, drafts, null)).toBe(1);
});
it("advances to the next unresolved after the focused index", () => {
let drafts = emptyDecisions(3);
drafts = setVerdict(drafts, 1, "accept");
expect(nextUnresolvedInOrder(order, drafts, 1)).toBe(0);
});
it("wraps around to earlier unresolved items", () => {
let drafts = emptyDecisions(3);
drafts = setVerdict(drafts, 2, "ignore");
expect(nextUnresolvedInOrder(order, drafts, 2)).toBe(1);
});
it("returns null when all are resolved", () => {
let drafts = emptyDecisions(3);
drafts = setVerdict(drafts, 0, "accept");
drafts = setVerdict(drafts, 1, "accept");
drafts = setVerdict(drafts, 2, "accept");
expect(nextUnresolvedInOrder(order, drafts, null)).toBeNull();
});
it("returns null for an empty order", () => {
expect(nextUnresolvedInOrder([], [], null)).toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
// R2 · 冲突分组/排序 + 跳到下一条未裁决UX §6.4)。
// 分组仅按 type严重度排序已知五类给定优先级未知殿后。
// 关键:分组/排序只改「显示顺序」,每条仍携原始 index——裁决草稿与 accept 的
// conflict_index 一律以原始下标为准不变量conflict_index 覆盖 range(len(conflicts)))。
// 纯逻辑,便于 node 环境单测。
import type { DecisionDraft } from "./decisions";
import type { ReviewConflict } from "./sse";
// 严重度优先级(数字越小越靠前)。对齐 C6 五类 ConflictType。
const SEVERITY_RANK: Record<string, number> = {
设定违例: 0,
能力不符: 1,
时间线倒错: 2,
地理矛盾: 3,
性格漂移: 4,
};
const UNKNOWN_RANK = 99;
export interface ConflictItem {
conflict: ReviewConflict;
index: number;
}
export interface ConflictGroup {
type: string;
items: ConflictItem[];
}
function severityRank(type: string): number {
return SEVERITY_RANK[type] ?? UNKNOWN_RANK;
}
// 按 type 分组并按严重度排序;组内保留原始出现顺序。
export function groupConflicts(
conflicts: readonly ReviewConflict[],
): ConflictGroup[] {
const byType = new Map<string, ConflictItem[]>();
conflicts.forEach((conflict, index) => {
const items = byType.get(conflict.type) ?? [];
items.push({ conflict, index });
byType.set(conflict.type, items);
});
return [...byType.entries()]
.map(([type, items]) => ({ type, items }))
.sort((a, b) => severityRank(a.type) - severityRank(b.type));
}
// 显示顺序下的原始下标序列(用于「跳到下一条未裁决」按显示顺序推进)。
export function displayOrder(groups: readonly ConflictGroup[]): number[] {
return groups.flatMap((group) => group.items.map((item) => item.index));
}
// 显示顺序中、当前聚焦之后的下一条未裁决下标;到末尾环回;无未裁决 → null。
export function nextUnresolvedInOrder(
order: readonly number[],
drafts: readonly DecisionDraft[],
fromIndex: number | null,
): number | null {
if (order.length === 0) return null;
const pos = fromIndex === null ? -1 : order.indexOf(fromIndex);
for (let step = 1; step <= order.length; step++) {
const idx = order[(pos + step + order.length) % order.length];
if (idx !== undefined && drafts[idx]?.verdict == null) return idx;
}
return null;
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { conflictSnippet, parseParagraphNo } from "./snippet";
describe("parseParagraphNo", () => {
it("extracts 1-based paragraph number from 第 N 段 wording", () => {
expect(parseParagraphNo("第 3 段,主角忽然示弱")).toBe(3);
expect(parseParagraphNo("第9段老者道破来历")).toBe(9);
});
it("returns null when no paragraph number present", () => {
expect(parseParagraphNo("战斗场景")).toBeNull();
expect(parseParagraphNo("开篇提到三日后")).toBeNull();
expect(parseParagraphNo("")).toBeNull();
});
it("takes the first number for a range like 第 46 段", () => {
expect(parseParagraphNo("第 46 段反复描写天气")).toBe(4);
});
});
describe("conflictSnippet", () => {
const text = "第一段正文。\n\n第二段正文。\n\n第三段正文命中冲突的句子。";
it("returns the trimmed paragraph for a parseable where", () => {
expect(conflictSnippet(text, "第 3 段,主角忽然示弱")).toBe(
"第三段正文,命中冲突的句子。",
);
});
it("returns null when paragraph number is unparseable", () => {
expect(conflictSnippet(text, "战斗场景")).toBeNull();
});
it("returns null when paragraph index is out of range", () => {
expect(conflictSnippet(text, "第 9 段")).toBeNull();
});
it("returns null when the located paragraph is empty", () => {
expect(conflictSnippet("\n\n\n\n正文", "第 1 段")).toBeNull();
});
it("truncates long paragraphs with an ellipsis", () => {
const long = "甲".repeat(200);
const snippet = conflictSnippet(long, "第 1 段");
expect(snippet).not.toBeNull();
expect(snippet!.length).toBeLessThanOrEqual(81);
expect(snippet!.endsWith("…")).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
// R1 · 冲突卡内联上下文片段UX §6.4)。
// `where` 是 LLM 给的文字定位(如「第 3 段主角忽然示弱」M2 无精确字符 offset。
// 这里按段级回放:从 where 解析 1-based 段号 → 取终稿对应段 → 截断成预览。
// 纯逻辑,与 ReviewReport.segmentText 同口径(空行切段),便于 node 环境单测。
// 预览窗口最大字符数(命中段超长时截断,避免撑爆卡片)。
const PREVIEW_MAX = 80;
// 从 where 文案解析 1-based 段号(「第 3 段」→ 3范围「第 46 段」取首个)。无则 null。
export function parseParagraphNo(where: string): number | null {
const match = where.match(/第\s*(\d+)/);
if (!match) return null;
const n = Number(match[1]);
return Number.isInteger(n) && n > 0 ? n : null;
}
// 取终稿第 N 段1-based正文预览无段号/越界/空段 → null卡片回退到「跳转」按钮
export function conflictSnippet(finalText: string, where: string): string | null {
const no = parseParagraphNo(where);
if (no === null) return null;
const paragraphs = finalText.split(/\n{2,}/);
const paragraph = paragraphs[no - 1]?.trim();
if (!paragraph) return null;
return paragraph.length <= PREVIEW_MAX
? paragraph
: `${paragraph.slice(0, PREVIEW_MAX)}`;
}

View File

@@ -41,7 +41,11 @@ function reducer(state: StreamState, action: Action): StreamState {
export interface UseDraftStream {
state: StreamState;
isStreaming: boolean;
start: (projectId: string, chapterNo: number) => Promise<void>;
start: (
projectId: string,
chapterNo: number,
directive?: string,
) => Promise<void>;
stop: () => void;
reset: (text: string) => void;
}
@@ -62,55 +66,72 @@ export function useDraftStream(): UseDraftStream {
dispatch({ type: "reset", text });
}, []);
const start = useCallback(async (projectId: string, chapterNo: number) => {
const controller = new AbortController();
controllerRef.current = controller;
dispatch({ type: "start" });
try {
const res = await fetch(
`${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/draft`,
{
method: "POST",
headers: { Accept: "text/event-stream" },
signal: controller.signal,
},
);
if (!res.ok || !res.body) {
// 流前错误(如无凭据 → 503 LLM_UNAVAILABLEJSON 信封而非帧)。
let code = "STREAM_FAILED";
let message = `写章请求失败(${res.status}`;
try {
const body = (await res.json()) as {
error?: { code?: string; message?: string };
};
if (body.error?.code) code = body.error.code;
if (body.error?.message) message = body.error.message;
} catch {
// 非 JSON 信封,沿用默认文案。
const start = useCallback(
async (projectId: string, chapterNo: number, directive?: string) => {
const controller = new AbortController();
controllerRef.current = controller;
dispatch({ type: "start" });
try {
// 仅当本章指令非空才发 JSON body无指令退回裸 POST与旧后端调用兼容
const trimmed = directive?.trim();
const init: RequestInit =
trimmed && trimmed.length > 0
? {
method: "POST",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ directive: trimmed }),
signal: controller.signal,
}
: {
method: "POST",
headers: { Accept: "text/event-stream" },
signal: controller.signal,
};
const res = await fetch(
`${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/draft`,
init,
);
if (!res.ok || !res.body) {
// 流前错误(如无凭据 → 503 LLM_UNAVAILABLEJSON 信封而非帧)。
let code = "STREAM_FAILED";
let message = `写章请求失败(${res.status}`;
try {
const body = (await res.json()) as {
error?: { code?: string; message?: string };
};
if (body.error?.code) code = body.error.code;
if (body.error?.message) message = body.error.message;
} catch {
// 非 JSON 信封,沿用默认文案。
}
dispatch({ type: "fail", code, message });
return;
}
dispatch({ type: "fail", code, message });
return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
const buffer = new SseFrameBuffer();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const events = buffer.push(decoder.decode(value, { stream: true }));
if (events.length > 0) dispatch({ type: "events", events });
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
// 用户主动停止state 已置 aborted。
return;
}
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", code: "NETWORK", message });
} finally {
controllerRef.current = null;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
const buffer = new SseFrameBuffer();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const events = buffer.push(decoder.decode(value, { stream: true }));
if (events.length > 0) dispatch({ type: "events", events });
}
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
// 用户主动停止state 已置 aborted。
return;
}
const message = err instanceof Error ? err.message : "未知网络错误";
dispatch({ type: "fail", code: "NETWORK", message });
} finally {
controllerRef.current = null;
}
}, []);
},
[],
);
return {
state,

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { composeDirective, STYLE_PRESETS } from "./directive";
describe("composeDirective", () => {
it("returns empty string when nothing selected", () => {
expect(composeDirective("", [])).toBe("");
expect(composeDirective(" ", [])).toBe("");
});
it("uses free text only when no presets", () => {
expect(composeDirective("多写战斗", [])).toBe("多写战斗");
});
it("combines presets (in canonical order) then free text", () => {
const result = composeDirective("多写战斗", ["fast-pace", "less-ai"]);
const lessAi = STYLE_PRESETS.find((p) => p.id === "less-ai")!.text;
const fastPace = STYLE_PRESETS.find((p) => p.id === "fast-pace")!.text;
expect(result).toBe(`${lessAi}${fastPace};多写战斗`);
});
it("ignores unknown preset ids", () => {
const lessAi = STYLE_PRESETS.find((p) => p.id === "less-ai")!.text;
expect(composeDirective("", ["nope", "less-ai"])).toBe(lessAi);
});
});

View File

@@ -0,0 +1,41 @@
// T4-b · 本章指令组装纯逻辑UX §3
// 作者自由文本 + 风格快捷预设 → 单条 directive 串,传入 draft 生成(直通后端 volatile
// 组件只渲染 chips/textarea预设清单与组装规则集中在此确定性、不可变。
export interface StylePreset {
id: string;
label: string;
text: string;
}
// 风格快捷预设(点选拼入指令开头,引导本章文风)。
export const STYLE_PRESETS: readonly StylePreset[] = [
{ id: "less-ai", label: "减少AI味", text: "减少 AI 腔,去掉套话与排比堆砌" },
{ id: "colloquial", label: "口语化", text: "用更口语、贴近人物的表达" },
{ id: "fast-pace", label: "快节奏", text: "加快节奏,少铺垫多冲突推进" },
];
const PRESET_BY_ID: ReadonlyMap<string, StylePreset> = new Map(
STYLE_PRESETS.map((preset) => [preset.id, preset]),
);
const SEPARATOR = "";
// 组合选中预设文案(按 STYLE_PRESETS 原序)+ 自由文本为一条指令。空 → ""。
export function composeDirective(
freeText: string,
presetIds: readonly string[],
): string {
const selected = new Set(presetIds);
const presetTexts = STYLE_PRESETS.filter((preset) =>
selected.has(preset.id),
).map((preset) => preset.text);
const trimmedFree = freeText.trim();
const parts = trimmedFree ? [...presetTexts, trimmedFree] : presetTexts;
return parts.join(SEPARATOR);
}
// 仅暴露给测试/组件的辅助id 是否为已知预设。
export function isKnownPreset(id: string): boolean {
return PRESET_BY_ID.has(id);
}

View File

@@ -162,6 +162,11 @@
- **复用读 seam**:直接调既有 `chapter_repo.get_draft(project_id, chapter_no)->ChapterDraftView|None`(同续审 `_resolve_review_draft` 的读缝);`ChapterDraftView` 已含全部字段,无需新 repo 方法/新 view。多版本时固定返草稿版次`DRAFT_VERSION=1`)那条可编辑工作副本,与 PUT 保存的同一行。
- **@frontend 行动**`pnpm gen:api` 纳入 `GET /projects/:id/chapters/:no/draft`(新 schema `DraftView`);工作台初次加载拉已保存草稿回灌编辑器(去掉「重访看似未保存」局限)。
### C3 扩展T4-b, 2026-06-20· `POST .../draft` 接收可选本章指令 `directive`(挂既有 `routers/projects.py` owner @backend 状态: 稳定
- **仅加可选 body向后兼容**`POST /projects/:id/chapters/:no/draft` 现接收可选 JSON body `DraftStreamRequest{directive: str|None=None}`(无 body 的旧调用方不变,仍按大纲生成)。`directive`=作者本章指令,**临时输入、不持久化、无迁移**。
- 直通 `assemble(..., directive=…)``_build_volatile` 以「本章指令」段领衔 volatile断点后**绝不入 stable_core**(守缓存前缀不变量 #9)。`draft_stream_start` 日志加 `directive_len`(不记原文,脱敏)。
- **@frontend 行动**`pnpm gen:api` 纳入 `DraftStreamRequest``useDraftStream.start(projectId, chapterNo, directive?)` 非空时 POST JSON body 传 directive。
### C3 扩展K1.3, 2026-06-19· Kimi Code OAuth device-flow 端点(独立 `routers/kimi_oauth.py`已注册 owner @backend 状态: 稳定
- snake_case新增 3 端点 + 3 schema → **@frontend 必须 `cd apps/web && pnpm gen:api`**K1.4 前置)。**响应/job 结果/日志绝不含 access/refresh token**(只 user_code/connected/expires_at 等非密信息token 仅以 Fernet 密文存 `provider_credentials.oauth_enc`
- `POST /settings/providers/kimi-code/oauth/start`**202** `OAuthStartResponse{job_id:uuid, user_code:str, verification_uri:str, verification_uri_complete:str|None, expires_in:int, interval:int}`。流程:`start_device_authorization(http)``job_repo.create(None,"kimi_oauth")` + `session.commit()`202 前持久化供轮询)→ `background_tasks.add_task(run_job, session_factory, job.id, _make_poll_work(device))`。后台 `work``run_job` 自建独立 session 上**循环 `poll_token`**`authorization_pending`→续轮询、`slow_down`→增大 interval、`expired`/`denied`→抛 AppError 置 job failed成功 → `upsert_oauth_credential`(加密包)+ job done结果只 `{connected:true, provider}`)。前端展示 user_code + 打开 verification_uri + 轮询 `GET /jobs/{id}`

View File

@@ -293,6 +293,18 @@ async def test_assemble_override_recent_n_limits_digests() -> None:
assert "实体1" not in names
async def test_assemble_directive_in_volatile_not_stable() -> None:
repos = build_repos(
characters=[char("林动", role="主角")],
spec=ProjectSpecView(title=""),
)
ctx = await assemble(repos, PROJECT, 1, directive="多写战斗")
assert "本章指令" in ctx.volatile
assert "多写战斗" in ctx.volatile
assert "多写战斗" not in ctx.stable_core
assert "本章指令" not in ctx.stable_core
# ---- render_cards ----

View File

@@ -104,6 +104,7 @@ def _build_volatile(
foreshadows: list[ForeshadowView],
digests: list[DigestView],
beats: dict[str, Any],
directive: str | None = None,
) -> str:
fore_lines = [
f"【伏笔】{f.code} {f.title} [{f.status}]"
@@ -113,6 +114,8 @@ def _build_volatile(
f"{d.chapter_no}章:{_ser(d.facts)}" for d in sorted(digests, key=lambda d: d.chapter_no)
]
sections = [
# 作者本章指令领衔——易变的每章输入,只入 volatile断点后守缓存前缀不变量 #9。
_section("本章指令", directive.strip()) if directive and directive.strip() else None,
# 写章指令始终在场——保证 volatile断点后的 input永不为空修空 prompt 400
_section("写作指令", f"请创作第 {chapter_no} 章的正文。"),
_section("本章注入卡片", cards),
@@ -130,11 +133,15 @@ async def assemble(
recent_k: int = RECENT_DIGEST_COUNT,
*,
override: InjectionOverride | None = None,
directive: str | None = None,
) -> AssembledContext:
"""组装本章 prompt 上下文(确定性、可缓存)。
`override`B0 可控版作者覆盖也是确定性输入pin 强制纳入 / excluded 强制剔除 /
recent_n 覆盖近况回看章数。draft 与注入读端点传同一覆盖,故「看到的=写章用的」(不变量 #6
`directive`T4-b 本章指令)是临时的每章输入——只入 volatile断点后绝不污染
stable_core 缓存前缀(不变量 #9不持久化。
"""
# recent_n 覆盖优先于默认 recent_k非正值视为未设守住至少回看若干章
effective_k = override.recent_n if (override and override.recent_n) else recent_k
@@ -171,6 +178,8 @@ async def assemble(
stable_core = _build_stable(spec, world_entities, main_characters, style, rules)
cards = render_cards(selection, characters, world_entities)
volatile = _build_volatile(chapter_no, cards, foreshadows, recent_digests, outline.beats)
volatile = _build_volatile(
chapter_no, cards, foreshadows, recent_digests, outline.beats, directive
)
return AssembledContext(stable_core=stable_core, volatile=volatile, selection=selection)