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:
@@ -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)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:自动保存草稿正文。"""
|
||||
|
||||
|
||||
48
apps/web/components/AiToolbar.tsx
Normal file
48
apps/web/components/AiToolbar.tsx
Normal 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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
22
apps/web/lib/api/schema.d.ts
vendored
22
apps/web/lib/api/schema.d.ts
vendored
@@ -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: {
|
||||
|
||||
28
apps/web/lib/nav/ai-tools.test.ts
Normal file
28
apps/web/lib/nav/ai-tools.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
29
apps/web/lib/nav/ai-tools.ts
Normal file
29
apps/web/lib/nav/ai-tools.ts
Normal 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" },
|
||||
];
|
||||
}
|
||||
48
apps/web/lib/review/accept-preview.test.ts
Normal file
48
apps/web/lib/review/accept-preview.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
49
apps/web/lib/review/accept-preview.ts
Normal file
49
apps/web/lib/review/accept-preview.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// R3 · 验收前「本次将更新」清单(HITL gate,UX §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;
|
||||
}
|
||||
89
apps/web/lib/review/grouping.test.ts
Normal file
89
apps/web/lib/review/grouping.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
67
apps/web/lib/review/grouping.ts
Normal file
67
apps/web/lib/review/grouping.ts
Normal 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;
|
||||
}
|
||||
50
apps/web/lib/review/snippet.test.ts
Normal file
50
apps/web/lib/review/snippet.test.ts
Normal 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 第 4–6 段", () => {
|
||||
expect(parseParagraphNo("第 4–6 段反复描写天气")).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);
|
||||
});
|
||||
});
|
||||
27
apps/web/lib/review/snippet.ts
Normal file
27
apps/web/lib/review/snippet.ts
Normal 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;范围「第 4–6 段」取首个)。无则 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)}…`;
|
||||
}
|
||||
@@ -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_UNAVAILABLE,JSON 信封而非帧)。
|
||||
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_UNAVAILABLE,JSON 信封而非帧)。
|
||||
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,
|
||||
|
||||
26
apps/web/lib/workbench/directive.test.ts
Normal file
26
apps/web/lib/workbench/directive.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
41
apps/web/lib/workbench/directive.ts
Normal file
41
apps/web/lib/workbench/directive.ts
Normal 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);
|
||||
}
|
||||
@@ -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}`。
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user