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) => (