diff --git a/PROGRESS.md b/PROGRESS.md index aed5880..a27f500 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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/pytest(56 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` 改分组渲染。TDD:snippet 8 测 + grouping 10 测。前端门禁绿:lint/tsc/**vitest 201**(+18)/build。分支 `feat/ux-r1-r2-review-ergonomics`。下一步(按 plan):R3 验收前清单 → Tier4。 +- [2026-06-20] @frontend — **UX R3 完成**(验收前清单 HITL gate,UX §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`。TDD:4 测。前端门禁绿:lint/tsc/**vitest 205**(+4)/build。下一步(按 plan):Tier4(T4-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,余 4rem),6 个固定高度页改用 `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。TDD:ai-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)。 diff --git a/apps/api/tests/test_projects.py b/apps/api/tests/test_projects.py index 1966272..2d72003 100644 --- a/apps/api/tests/test_projects.py +++ b/apps/api/tests/test_projects.py @@ -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→volatile(write 节点把 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 diff --git a/apps/api/ww_api/routers/projects.py b/apps/api/ww_api/routers/projects.py index e286a93..c65a967 100644 --- a/apps/api/ww_api/routers/projects.py +++ b/apps/api/ww_api/routers/projects.py @@ -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( diff --git a/apps/api/ww_api/schemas/projects.py b/apps/api/ww_api/schemas/projects.py index 12ca4fd..5cc8afe 100644 --- a/apps/api/ww_api/schemas/projects.py +++ b/apps/api/ww_api/schemas/projects.py @@ -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:自动保存草稿正文。""" diff --git a/apps/web/components/AiToolbar.tsx b/apps/web/components/AiToolbar.tsx new file mode 100644 index 0000000..7d86e82 --- /dev/null +++ b/apps/web/components/AiToolbar.tsx @@ -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 ( + + ); +} + +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`; +} diff --git a/apps/web/components/AppShell.tsx b/apps/web/components/AppShell.tsx index 634e137..372e7a5 100644 --- a/apps/web/components/AppShell.tsx +++ b/apps/web/components/AppShell.tsx @@ -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 ( -
+
+ {projectId ? ( + + ) : null}
-
{children}
+
+ {children} +
); diff --git a/apps/web/components/codex/CodexPage.tsx b/apps/web/components/codex/CodexPage.tsx index 803cb8c..ba12ffc 100644 --- a/apps/web/components/codex/CodexPage.tsx +++ b/apps/web/components/codex/CodexPage.tsx @@ -59,7 +59,7 @@ export function CodexPage({ projectId={project.id} activeNav="codex" > -
+
{TABS.map((t) => (
+ {snippet ? ( +
+ + {snippet} +
+ ) : null} +
{conflict.where ? ( diff --git a/apps/web/components/review/ReviewReport.tsx b/apps/web/components/review/ReviewReport.tsx index 1e9b632..6e6fdf8 100644 --- a/apps/web/components/review/ReviewReport.tsx +++ b/apps/web/components/review/ReviewReport.tsx @@ -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" > -
+
{/* 左:终稿正文 + 就地标注 */}
@@ -276,20 +296,54 @@ export function ReviewReport({ : "进页未带审稿留痕,点「重新审稿」开始。"}

) : ( -
    - {conflicts.map((c, i) => ( - +
    + {/* R2:未裁决进度 + 跳到下一条未裁决 */} +
    + + {unresolved > 0 ? ( + + {unresolved}/{conflictCount} 待裁决 + + ) : ( + ✓ 全部已裁决 + )} + + +
    + + {groups.map((group) => ( +
    +

    + {group.type} + + {group.items.length} + +

    +
      + {group.items.map(({ conflict: c, index: i }) => ( + + ))} +
    +
    ))} -
+
)}
@@ -322,6 +376,7 @@ export function ReviewReport({ void onAccept()} diff --git a/apps/web/components/style/StylePage.tsx b/apps/web/components/style/StylePage.tsx index e9c4089..c08c368 100644 --- a/apps/web/components/style/StylePage.tsx +++ b/apps/web/components/style/StylePage.tsx @@ -27,7 +27,7 @@ export function StylePage({ project, initialFingerprint }: StylePageProps) { projectId={project.id} activeNav="style" > -
+

学习文风

(null); + // 本章指令(T4-b):自由文本 + 风格预设 → 写本章时组装传入 draft 生成。 + const [directive, setDirective] = useState(""); + const [presetIds, setPresetIds] = useState([]); 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" > -
+
+
void; + presetIds: readonly string[]; + onTogglePreset: (id: string) => void; +} + +// 本章指令面板(T4-b):可折叠的 textarea + 风格快捷预设 chips。 +// 默认折叠(不抢编辑视野);指令覆盖/补充大纲节拍,写本章时组装传入生成。 +function DirectivePanel({ + directive, + onDirectiveChange, + presetIds, + onTogglePreset, +}: DirectivePanelProps) { + return ( +
+ + 本章指令(可选) + +
+