diff --git a/backend/app/routes/main_agent.py b/backend/app/routes/main_agent.py
index 574245f5..7edf1c3a 100644
--- a/backend/app/routes/main_agent.py
+++ b/backend/app/routes/main_agent.py
@@ -963,12 +963,15 @@ async def run_and_record() -> dict[str, Any]:
assistant_message = str(result.get("assistant_message") or "").strip()
completed_conversation = conversation
if assistant_message:
+ # Re-read: steering messages and parked sibling Runs may have
+ # appended to this conversation while the Run was executing.
+ latest_conversation = get_conversation(conversation["id"]) or conversation
completed_conversation = await _ui_operation_outputs(
"save_harness_conversation",
{
"conversation_id": conversation["id"],
"messages": [
- *conversation["messages"],
+ *(latest_conversation.get("messages") or []),
{"role": "assistant", "content": assistant_message},
],
},
@@ -1263,6 +1266,42 @@ async def resume_runtime_run(run_id: str) -> dict[str, Any]:
raise HTTPException(status_code=400, detail=safe_error_message(exc))
+class RuntimeSteerBody(BaseModel):
+ message: str = Field(min_length=1, max_length=4000)
+
+
+@runtime_router.post("/runtime/runs/{run_id}/steer")
+async def steer_runtime_run(run_id: str, body: RuntimeSteerBody) -> dict[str, Any]:
+ """Deliver a mid-run user message to the live Run (D-1: input never locks).
+
+ ``not_running`` means the Run is not executing a prompt; the client then
+ sends the text as a normal follow-up turn. Nothing here approves anything.
+ """
+ try:
+ provider = await _provider_for_run(run_id)
+ steer = getattr(provider, "steer_run", None)
+ if steer is None:
+ return {"ok": True, "disposition": "not_running"}
+ result = await steer(run_id, message=body.message)
+ except ValueError as exc:
+ raise HTTPException(status_code=404, detail=safe_error_message(exc))
+ if result.get("disposition") == "steered":
+ conversation_id = str((result.get("run") or {}).get("conversation_id") or "")
+ conversation = get_conversation(conversation_id) if conversation_id else None
+ if conversation is not None:
+ await _ui_operation_outputs(
+ "save_harness_conversation",
+ {
+ "conversation_id": conversation_id,
+ "messages": [
+ *(conversation.get("messages") or []),
+ {"role": "user", "content": body.message},
+ ],
+ },
+ )
+ return {"ok": True, "disposition": result.get("disposition") or "not_running"}
+
+
@runtime_router.post("/runtime/runs/{run_id}/abort")
async def abort_runtime_run(run_id: str) -> dict[str, Any]:
try:
diff --git a/backend/app/services/agent_runtime.py b/backend/app/services/agent_runtime.py
index f0898bd3..3ff0e63a 100644
--- a/backend/app/services/agent_runtime.py
+++ b/backend/app/services/agent_runtime.py
@@ -89,6 +89,7 @@
"run.waiting_confirmation": "approval.requested",
"input.required": "approval.requested",
"input.answered": "approval.resolved",
+ "input.steered": "reasoning.status",
"run.waiting_input": "approval.requested",
"continuation.requested": "reasoning.status",
"approval.requested": "approval.requested",
@@ -294,6 +295,11 @@ async def abort_run(self, run_id: str) -> dict[str, Any]:
return await abort_embedded_agent_run(run_id)
+ async def steer_run(self, run_id: str, *, message: str) -> dict[str, Any]:
+ from app.services.embedded_agent_host import steer_embedded_agent_run
+
+ return await steer_embedded_agent_run(run_id, message)
+
class ReplayAgentRunProvider:
"""Durable, deterministic Main Agent provider for local acceptance paths."""
diff --git a/backend/app/services/embedded_agent_host.py b/backend/app/services/embedded_agent_host.py
index fd5e8e8d..f2636ca2 100644
--- a/backend/app/services/embedded_agent_host.py
+++ b/backend/app/services/embedded_agent_host.py
@@ -45,6 +45,7 @@
from app.services.embedded_agent_worker import (
PROTOCOL_VERSION,
EmbeddedAgentWorker,
+ EmbeddedAgentWorkerError,
get_embedded_agent_worker,
)
from app.services.security_redaction import safe_error_message
@@ -991,6 +992,16 @@ async def _request_user_input(args: dict[str, Any]) -> dict[str, Any]:
await publish("run.completed", {"status": "completed"})
await active_worker.dispose_run(run_id)
current = await load_agent_run(run_id) or current
+ if current["status"] in _PARKED_WAITING_STATUSES and active_worker.active_run_id == run_id:
+ # D-1: a Run that waits on the user must not hold the single
+ # embedded worker. Its session file stays on disk; approvals and
+ # answers already resume through continuations, which restart the
+ # worker from that file. Parking lets the user keep chatting.
+ try:
+ await active_worker.dispose_run(run_id)
+ except Exception:
+ pass
+ current = await load_agent_run(run_id) or current
await persist_and_publish(
"guardian.reviewed",
{
@@ -1339,6 +1350,43 @@ async def reject_embedded_agent_action(
return result
+_PARKED_WAITING_STATUSES = frozenset({"waiting_confirmation", "waiting_input", "waiting_decision"})
+
+
+async def steer_embedded_agent_run(
+ run_id: str,
+ message: str,
+ *,
+ worker: EmbeddedAgentWorker | None = None,
+) -> dict[str, Any]:
+ """Inject a user message into a live Run at its next tool boundary.
+
+ Steering is collaboration input, never authorization. When the Run is not
+ executing a prompt right now (finished, parked or waiting on the user) the
+ caller gets ``not_running`` and should start a follow-up turn instead.
+ """
+
+ text = str(message or "").strip()
+ if not text:
+ raise ValueError("引导消息不能为空。")
+ run = await load_agent_run(run_id)
+ if run is None:
+ raise ValueError(f"Agent Run {run_id} 不存在。")
+ active_worker = worker or get_embedded_agent_worker()
+ if active_worker.active_run_id != run_id or not active_worker.prompt_active:
+ return {"ok": True, "disposition": "not_running", "run": run}
+ try:
+ await active_worker.steer_run(run_id, text[:4000])
+ except EmbeddedAgentWorkerError:
+ return {"ok": True, "disposition": "not_running", "run": run}
+ await append_agent_run_event(
+ run_id,
+ event_type="input.steered",
+ payload={"characters": len(text)},
+ )
+ return {"ok": True, "disposition": "steered", "run": run}
+
+
async def abort_embedded_agent_run(
run_id: str,
*,
diff --git a/backend/app/services/resume_workspace.py b/backend/app/services/resume_workspace.py
index a57ed25a..b1a4a9cf 100644
--- a/backend/app/services/resume_workspace.py
+++ b/backend/app/services/resume_workspace.py
@@ -614,6 +614,18 @@ def _find_section(resume: Resume, row: Optional[dict[str, Any]]) -> Optional[Res
)
+def _change_conflicts_with_manual_edit(resume: Resume, diff: dict[str, Any]) -> bool:
+ """True when the section this change targets no longer matches the proposal's ``before``."""
+ change_type = str(diff.get("change_type") or "modified")
+ before = diff.get("before") if isinstance(diff.get("before"), dict) else None
+ if change_type == "added" or before is None:
+ return False
+ target = _find_section(resume, before)
+ if target is None:
+ return True
+ return _json_hash(target.content_json or []) != _json_hash(before.get("content_json") or [])
+
+
async def review_resume_proposal_item(
proposal_id: str, resume_id: int, change_id: str, action: str, edited_text: str = "",
) -> dict[str, Any]:
@@ -723,11 +735,19 @@ async def _review_resume_proposal_items(
await db.commit()
raise ValueError(proposal.review_note)
if expected_hash and workspace_content_hash(resume) != expected_hash:
- proposal.status = "stale"
- proposal.review_note = "用户手动修改后,原提案已过期,请重新生成或重新计算"
- proposal.reviewed_at = _now()
- await db.commit()
- raise ValueError(proposal.review_note)
+ # 人工修改优先,冲突按段落判断(docs/05 §5):用户改了别处,
+ # 这条建议仍可采用;只有建议要改的那一段在生成后被用户改过,
+ # 才拒绝采用这一条,且绝不覆盖用户的版本。整份提案不因此失效。
+ conflicted = [
+ change_id
+ for change_id in pending_ids
+ if _change_conflicts_with_manual_edit(resume, diffs[change_id])
+ ]
+ if conflicted:
+ raise ValueError(
+ "这一段在 AI 建议生成后被你改过,建议基于旧内容,不能直接采用;"
+ "可以跳过,或让 AI 基于你的新版本重新建议"
+ )
for clean_change_id in pending_ids:
diff = diffs[clean_change_id]
if clean_action == "accept":
diff --git a/backend/tests/test_agent_never_locks.py b/backend/tests/test_agent_never_locks.py
new file mode 100644
index 00000000..d08f7a7a
--- /dev/null
+++ b/backend/tests/test_agent_never_locks.py
@@ -0,0 +1,100 @@
+"""docs/02 D-1 / D-2: a Run waiting on the user never holds the Agent hostage.
+
+* A Run that reaches ``waiting_confirmation`` parks: it releases the single
+ embedded worker so the user can start another turn, while its session file
+ and plan stay durable for the later approval continuation.
+* A mid-run user message is steered into the live Run, or reported as
+ ``not_running`` so the client sends it as a normal follow-up turn.
+"""
+from __future__ import annotations
+
+import asyncio
+import os
+import sys
+import unittest
+from pathlib import Path
+from typing import Any
+
+BACKEND_DIR = Path(__file__).resolve().parents[1]
+os.chdir(BACKEND_DIR)
+if str(BACKEND_DIR) not in sys.path:
+ sys.path.insert(0, str(BACKEND_DIR))
+TESTS_DIR = Path(__file__).resolve().parent
+if str(TESTS_DIR) not in sys.path:
+ sys.path.insert(0, str(TESTS_DIR))
+
+from app.database import init_db
+from app.services.agent_run_state import create_agent_run, list_agent_run_events, load_agent_run
+from app.services.embedded_agent_host import start_embedded_agent_run, steer_embedded_agent_run
+from test_embedded_agent_host import FakeEmbeddedWorker
+
+PROVIDER = {"name": "fixture", "model": "fixture"}
+METADATA = {"runtime": "python_agent", "provider_id": "embedded"}
+
+
+class SteerableWorker:
+ def __init__(self, run_id: str, *, prompt_active: bool) -> None:
+ self.active_run_id = run_id
+ self.prompt_active = prompt_active
+ self.steered: list[str] = []
+
+ async def steer_run(self, run_id: str, message: str) -> dict[str, Any]:
+ assert run_id == self.active_run_id
+ self.steered.append(message)
+ return {"run_id": run_id, "disposition": "queued"}
+
+
+class AgentNeverLocksTests(unittest.TestCase):
+ def test_waiting_confirmation_parks_the_worker_but_keeps_the_run(self) -> None:
+ worker = FakeEmbeddedWorker()
+
+ async def run() -> tuple[dict[str, Any], str | None, dict[str, Any] | None]:
+ await init_db()
+ from app.database import async_session
+ from app.models.models import Job
+ async with async_session() as db:
+ if await db.get(Job, 74291) is None:
+ db.add(Job(id=74291, title="Synthetic recovery role", company="Fixture",
+ raw_description="Public synthetic JD", hash_key="host-recovery-job"))
+ await db.commit()
+ started = await start_embedded_agent_run(
+ message="帮我调研这个岗位",
+ skill_id="company_research",
+ conversation_id="never-locks-park",
+ worker=worker,
+ provider_config=PROVIDER,
+ provider_metadata=METADATA,
+ )
+ return started, worker.active_run_id, await load_agent_run(started["run"]["id"])
+
+ started, active_run_id, stored = asyncio.run(run())
+ self.assertTrue(started["ok"], started)
+ self.assertEqual(stored["status"], "waiting_confirmation")
+ self.assertIsNone(active_run_id)
+ self.assertTrue(started["run"]["final_result"]["requires_confirmation"])
+
+ def test_steer_reaches_only_a_live_prompt(self) -> None:
+ async def run() -> tuple[dict[str, Any], dict[str, Any], list[str], list[str]]:
+ await init_db()
+ record = await create_agent_run(conversation_id="never-locks-steer", goal="steer fixture", mode="skill", skill_id="discovery", actions=[])
+ run_id = record["id"]
+ live = SteerableWorker(run_id, prompt_active=True)
+ steered = await steer_embedded_agent_run(run_id, " 换成只看上海的岗位 ", worker=live)
+ idle = SteerableWorker(run_id, prompt_active=False)
+ not_running = await steer_embedded_agent_run(run_id, "这条应作为新一轮", worker=idle)
+ events = [event["type"] for event in await list_agent_run_events(run_id)]
+ return steered, not_running, live.steered + idle.steered, events
+
+ steered, not_running, delivered, events = asyncio.run(run())
+ self.assertEqual(steered["disposition"], "steered")
+ self.assertEqual(not_running["disposition"], "not_running")
+ self.assertEqual(delivered, ["换成只看上海的岗位"])
+ self.assertEqual(events.count("input.steered"), 1)
+
+ def test_empty_steer_is_rejected(self) -> None:
+ with self.assertRaises(ValueError):
+ asyncio.run(steer_embedded_agent_run("run_0000000000000000", " "))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_resume_workspace.py b/backend/tests/test_resume_workspace.py
index a4259904..b358cc16 100644
--- a/backend/tests/test_resume_workspace.py
+++ b/backend/tests/test_resume_workspace.py
@@ -115,13 +115,14 @@ async def run() -> dict:
self.assertEqual(len(result["automation_events"]), 1)
self.assertEqual(result["automation_events"][0].payload_json["resume_version_id"], result["version"]["id"])
- def test_manual_edit_makes_unreviewed_proposal_stale(self) -> None:
- async def run() -> str:
+ def test_manual_edit_elsewhere_keeps_the_suggestion_acceptable(self) -> None:
+ """人工修改优先,冲突按段落:改了别处,这条建议仍可采用。"""
+ async def run() -> tuple[str, str, str]:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
sessions = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
- fixture = await _seed(sessions, "stale")
+ fixture = await _seed(sessions, "elsewhere")
with patch.object(resume_workspace, "async_session", sessions), patch.object(
resume_route_operations, "async_session", sessions
), patch.object(
@@ -135,19 +136,68 @@ async def run() -> str:
await resume_route_operations.update_resume_record(
workspace["resume"]["id"], {"summary": "用户自己的最新修改"}
)
- with self.assertRaisesRegex(ValueError, "过期"):
+ reviewed = await resume_workspace.review_resume_proposal_item(
+ proposal_id=fixture["proposal_id"],
+ resume_id=workspace["resume"]["id"],
+ change_id=fixture["change_id"],
+ action="accept",
+ )
+ async with sessions() as db:
+ proposal = await db.get(ResumeOptimizationProposal, fixture["proposal_id"])
+ await engine.dispose()
+ return (
+ proposal.status if proposal else "missing",
+ reviewed["resume"]["summary"],
+ reviewed["resume"]["sections"][0]["content_json"][0]["description"],
+ )
+
+ status, summary, description = asyncio.run(run())
+ self.assertNotEqual(status, "stale")
+ self.assertEqual(summary, "用户自己的最新修改")
+ self.assertEqual(description, "new evidence")
+
+ def test_manual_edit_of_the_target_section_blocks_only_that_suggestion(self) -> None:
+ """建议要改的那一段被用户改过:不覆盖用户版本,提案整体也不失效。"""
+ async def run() -> tuple[str, str]:
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+ sessions = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+ async with engine.begin() as connection:
+ await connection.run_sync(Base.metadata.create_all)
+ fixture = await _seed(sessions, "target-edit")
+ with patch.object(resume_workspace, "async_session", sessions), patch.object(
+ resume_route_operations, "async_session", sessions
+ ), patch.object(
+ resume_workspace,
+ "get_pre_application_state",
+ new=AsyncMock(return_value={"stage": "resume_proposal_ready"}),
+ ):
+ workspace = await resume_workspace.ensure_resume_workspace(
+ job_id=fixture["job_id"], proposal_id=fixture["proposal_id"]
+ )
+ sections = [dict(item) for item in workspace["resume"]["sections"]]
+ sections[0]["content_json"] = [{"company": "Example", "description": "用户亲手写的版本"}]
+ await resume_route_operations.update_resume_record(
+ workspace["resume"]["id"], {"sections": sections}
+ )
+ with self.assertRaisesRegex(ValueError, "改过"):
await resume_workspace.review_resume_proposal_item(
proposal_id=fixture["proposal_id"],
resume_id=workspace["resume"]["id"],
change_id=fixture["change_id"],
action="accept",
)
+ current = await resume_workspace.get_resume_workspace(workspace["resume"]["id"])
async with sessions() as db:
proposal = await db.get(ResumeOptimizationProposal, fixture["proposal_id"])
await engine.dispose()
- return proposal.status if proposal else "missing"
+ return (
+ proposal.status if proposal else "missing",
+ current["resume"]["sections"][0]["content_json"][0]["description"],
+ )
- self.assertEqual(asyncio.run(run()), "stale")
+ status, description = asyncio.run(run())
+ self.assertNotEqual(status, "stale")
+ self.assertEqual(description, "用户亲手写的版本")
def test_edit_then_accept_replaces_only_suggested_text(self) -> None:
async def run() -> str:
@@ -351,11 +401,13 @@ async def run() -> None:
async with sessions() as db:
proposal = await db.get(ResumeOptimizationProposal, fixture["proposal_id"])
proposal.diff_json = [*proposal.diff_json, {**proposal.diff_json[0], "change_id": "other"}]
- resume = await db.get(Resume, workspace["resume"]["id"])
- resume.summary = "manual edit"
+ from app.models.models import ResumeSection
+ section = (await db.execute(select(ResumeSection).where(ResumeSection.resume_id == workspace["resume"]["id"]))).scalars().first()
+ section.content_json = [{"company": "Example", "description": "manual edit"}]
await db.commit()
await resume_workspace.review_resume_proposal_items(fixture["proposal_id"], workspace["resume"]["id"], ["other"], "reject")
- with self.assertRaisesRegex(ValueError, "过期"):
+ # A reject must not launder a manual edit of the target section.
+ with self.assertRaisesRegex(ValueError, "改过"):
await resume_workspace.review_resume_proposal_items(fixture["proposal_id"], workspace["resume"]["id"], [fixture["change_id"]], "accept")
await engine.dispose()
asyncio.run(run())
diff --git a/docs/02-interaction-design.md b/docs/02-interaction-design.md
index 84b94803..68da68f9 100644
--- a/docs/02-interaction-design.md
+++ b/docs/02-interaction-design.md
@@ -153,10 +153,10 @@ Ask 支持 `selection_mode: single | multiple`、推荐项、可跳过、自由
## 11. 已知交互缺陷(待修复,按优先级)
+> 2026-10 已修复:D-1(等待中的 Run 不再禁用输入、能力选择和快捷动作)与 D-2(新建 / 切换对话不再 abort Run,运行中的流只分离到后台)。实现见 §11.1。
+
| 优先级 | 位置 | 问题 | 违反 |
| --- | --- | --- | --- |
-| P0 | `frontend/src/components/workbench/AgentPanel.tsx`,约 1161、1189 行 | 存在 pending action、plan review 或 interrupted run 时,能力选择器和全部快捷动作被 `disabled` | D-1 |
-| P0 | 同文件 `startNewConversation` / `loadConversation`,约 551–600 行 | 有待办时调用 `agentRuntimeApi.abort(activeRunId)`,新建对话会杀掉任务 | D-2 |
| P1 | `AgentAskPanel.tsx` | 单选问题被渲染成复选框 | §4.2 |
| P1 | `AgentPanel.tsx` 中的 `QUICK_ACTIONS` | 写死了校招动作 | H-1 |
| P1 | `components/layout/Sidebar.tsx` | 11 个入口、中英混排、三个简历入口 | N-1、N-2 |
@@ -168,6 +168,19 @@ Ask 支持 `selection_mode: single | multiple`、推荐项、可跳过、自由
修复顺序:先修两个 P0 死锁,再修 P1 的策略和导航问题,最后做 P2 的拆分。每修一项都要补上对应规则的测试。
+### 11.1 D-1 / D-2 的实现方式(参考 Codex steer/queue 与 Claude Code agent view)
+
+| 用户动作 | 当前 Run 状态 | 行为 |
+| --- | --- | --- |
+| 发消息 | 正在执行 | `POST /api/agent/runtime/runs/{id}/steer`:在下一个工具边界注入当前 Run,气泡标注「已交给正在进行的任务」;返回 `not_running` 时进入待发送队列 |
+| 发消息 | 等审核 / 等回答 / 已中断 | 立即开新一轮;原 Run 收进「停放」条(「之前的任务等你审核,状态已保存 · 回去处理」),不取消、不批准 |
+| 发消息 | 有审核提交在途 / 后台流未结束 | 进入待发送队列;队列可见、可改、可删,空闲后按顺序发送 |
+| 新建 / 切换对话 | 任意 | 只把当前流分离到后台,不调用 abort;等待中的 Run 在历史对话和统一审核入口继续可见 |
+
+后端配合:Run 进入 `waiting_confirmation / waiting_input / waiting_decision` 时释放(停放)唯一的内置 Worker,会话文件保留;审批和回答本来就经由 continuation 从会话文件恢复,所以停放不改变授权路径。测试:`backend/tests/test_agent_never_locks.py`、`frontend/src/components/workbench/AgentPanel.test.tsx`「Agent input never locks」。
+
+已知限制:整页刷新或关闭应用仍会断开 SSE,后端会把执行中的 Run 标记为 `interrupted`(可恢复,不会丢失);彻底解决需要把 Run 执行与 SSE 连接解耦,留给 AG-UI interrupt 方向(§10)。
+
## 12. 验收指标(单个真实岗位的完整旅程)
- 任务进行中,聊天输入始终可用;导航从不隐式中止 Run;
diff --git a/docs/05-module-resume.md b/docs/05-module-resume.md
index 757e7eef..8ea642c4 100644
--- a/docs/05-module-resume.md
+++ b/docs/05-module-resume.md
@@ -86,6 +86,30 @@ Career Truth + 目标岗位 + Role Intelligence + 当前简历 + Skill 方法
- 设计系统:Editorial / Classic / Modern 三套预设。原则是克制、打印效果与屏幕一致、清晰的字体层级、关注信息密度。模板与 HTML 简历(原 Studio)作为检查器里的「设计」面板。
- PDF 优先用 managed Chromium(Python Playwright),与预览共用同一个 React 渲染器;失败时回退到 ReportLab;仍然失败就显示可重试的错误。导出的 PDF 必须对应用户实际采用的版本。
+### 8.1 「纸」模板:成品即编辑器(2026-10 已实现,切片 ①)
+
+- 新模板 `paper`(「纸 · 可直接编辑」),组件 `frontend/src/app/resume/components/templates/ResumePaper.tsx`。同一个组件既是导出 PDF 的成品,也是工作区画布:`editable=true` 时页面上每段文字都能直接点击修改,所以画布所见就是导出所得。
+- 编辑直接写回原始字段(`content_json` 里的 `company / position / startDate / description …`),左侧表单、AI 建议和导出读的是同一份数据;自动保存、撤销、版本沿用现有机制,不调用模型。
+- 键盘:回车提交并新增下一条要点,空要点按退格删除,Esc 撤回本次输入;悬停一条经历可删除,段落末尾「+ 添加一条」。
+- 分页可见:画布量出成品高度,超过一页时在第一页底部画出分页线并标注超出多少毫米,由用户决定删哪条,而不是自动缩字号。
+- 排版约束参考 tw93/Kami(MIT)的 design.md:单一墨蓝强调色 `#1B365D`、暖灰文字层级、衬线两字重 400/500、行高 1.45、页边距 11/13mm、无装饰线与阴影;暖纸 `#f5f4ed` / 白纸可一键切换。中文字体只用 OFL 许可的思源宋体 / Noto Serif SC,**不使用**仅限个人免费的仓耳今楷。
+- 其他模板暂时仍是只读预览,画布顶部提供「换成『纸』模板,直接在页面上改」。
+- 限制:要点按纯文本编辑,带加粗等富文本格式的那一条在被修改后会变成纯文本;未修改的条目原样保留。
+- 测试:`ResumePaper.test.tsx`(直接编辑写回原字段、Esc 还原、未改不提交、增删条目、隐藏要点索引对齐、默认排版只作用于纸模板)。
+- 下一切片:③ 排版控件与「压到一页」;④ DOCX 导出。
+
+### 8.2 画布上的 AI 协作(2026-10 已实现,切片 ②)
+
+- **行内 diff**:AI 建议显示在它要改的那一段标题下方,按字比较,新增的字用墨蓝底色,删除的字划掉;每块都可以「采用 / 修改 / 跳过」。画布顶部显示「AI 建议 n 条 · 全部采用 · 只看当前 / 显示对比」。代码在 `components/canvas/CanvasAssist.tsx`、`components/canvas/proposalDiff.ts`。
+- **修改后采用**:只改了一句的建议,可以先在原位置改成自己想要的样子再采用(沿用 `edited_text`,后端会对新文本重新做事实门检查);改了多处的建议,先采用,再直接在页面上改。
+- **人工修改优先,冲突按段落判断**(后端 `_change_conflicts_with_manual_edit`):
+ - 用户改了别处,比如简介、样式或其他段落,这条建议照样可以采用,整份提案**不再**因此作废;
+ - 只有建议要改的那一段在建议生成后被用户改过,这一条才不能采用:页面标注「以你的版本为准」,提供「跳过」和「让 AI 按我的版本重写」;「全部采用」只采用没有冲突的条目;
+ - 拒绝一条建议不会顺带让其他条目的冲突失效;JD 或档案事实变了,仍然让整份提案作废(这条规则不变)。
+- **选区浮条与修改意见**:在画布上选中文字,浮条提供「更精炼 / 对齐岗位 / 换个说法 / 留言给 AI」。意见先堆在右下角的托盘里,可以逐条删除,最后点「一次交给 AI」。
+- **交给 Agent 的通道**:`lib/agentCompose.ts` 提供邮箱加事件。Agent 面板挂载时取走邮箱里的消息,已挂载的面板也能立即收到;消息一律先进待发送队列,按 D-1 规则发送。提示词要求 Agent 先读取最新工作区、只改圈出的文字、没有证据的数字先问用户,并以可审核的建议提交,不直接覆盖。
+- 测试:`CanvasAssist.test.tsx`、`[id]/page.paper.test.tsx`、`AgentPanel.test.tsx`「Agent compose mailbox」、`backend/tests/test_resume_workspace.py` 的两条段落级冲突用例。
+
## 9. 验收
- 一次单岗位定制:最终采用决定 ≤ 1 次;需要审阅的独立 Proposal 达到 50–80 个即判为不合格。
diff --git a/frontend/src/app/resume/[id]/page.paper.test.tsx b/frontend/src/app/resume/[id]/page.paper.test.tsx
new file mode 100644
index 00000000..1f2b4389
--- /dev/null
+++ b/frontend/src/app/resume/[id]/page.paper.test.tsx
@@ -0,0 +1,118 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ResumeWorkspace } from "@/lib/api";
+
+const api = vi.hoisted(() => ({
+ workspace: vi.fn(),
+ update: vi.fn(),
+ reviewProposalItem: vi.fn(),
+ reviewProposalItems: vi.fn(),
+}));
+
+vi.mock("@/lib/api", () => ({
+ resumeApi: {
+ workspace: api.workspace,
+ update: api.update,
+ reviewProposalItem: api.reviewProposalItem,
+ reviewProposalItems: api.reviewProposalItems,
+ createVersion: vi.fn(),
+ exportPdf: vi.fn(),
+ restoreVersion: vi.fn(),
+ updateDesign: vi.fn(),
+ },
+}));
+vi.mock("next/navigation", () => ({
+ useParams: () => ({ id: "7" }),
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn(), forward: vi.fn(), refresh: vi.fn() }),
+ usePathname: () => "/resume/7",
+ useSearchParams: () => new URLSearchParams(""),
+}));
+vi.mock("@/lib/showcase/router", () => ({ SHOWCASE: false }));
+vi.mock("../components/SectionEditor", () => ({ default: () =>
}));
+
+import ResumeEditorPage from "./page";
+
+const before = {
+ section_type: "experience", title: "工作经历", sort_order: 0, visible: true, source_section_ids: [11],
+ content_json: [{ company: "示例科技", position: "后端", description: "" }],
+};
+const after = { ...before, content_json: [{ ...before.content_json[0], description: "" }] };
+
+function workspace(sectionContent = before.content_json): ResumeWorkspace {
+ return {
+ resume: {
+ id: 7, workspace_revision: 3, user_name: "林一", title: "后端工程师", summary: "", contact_json: {},
+ template_id: "paper", style_config: { template: "paper" }, language: "zh",
+ sections: [{ id: 5, resume_id: 7, section_type: "experience", title: "工作经历", sort_order: 0, visible: true,
+ content_json: sectionContent, source_section_ids: [11] }],
+ } as unknown as ResumeWorkspace["resume"],
+ job: { id: 42, title: "后端工程师", company: "示例公司" },
+ workspace: { revision: 3, content_hash: "h", is_tailored: true },
+ application_packet: {
+ job_id: 42, resume_id: 7, current_version_id: 1, current_version_number: 1, status: "ready",
+ application_id: null, application_attempt_id: null, artifacts: {},
+ },
+ proposals: [{
+ proposal_id: "prop-1", status: "ready", job_id: 42, job_title: "后端工程师", company: "示例公司", profile_id: 1,
+ research_run_id: "run-1", change_count: 1, fact_gate_status: "passed", fact_gate_warnings_count: 0, review_note: "",
+ created_at: "", updated_at: "", source_section_ids: [11], source_snapshot_hash: "s", research_snapshot_hash: "r",
+ original_summary: "", proposed_summary: "", original_rows: [before], proposed_rows: [after],
+ diff: [{ change_id: "c1", change_type: "modified", title: "工作经历", section_type: "experience", before, after, source_section_ids: [11] }],
+ strategy: {}, presentation: {}, fact_gates: {}, trace: {}, item_reviews: {},
+ }],
+ versions: [],
+ } as unknown as ResumeWorkspace;
+}
+
+describe("纸画布上的 AI 建议", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ HTMLElement.prototype.scrollTo = vi.fn();
+ api.update.mockImplementation(async (_id: number, data: Record) => ({ id: 7, ...data }));
+ });
+
+ it("shows the suggestion inside the section it changes and accepts it from there", async () => {
+ api.workspace.mockResolvedValue(workspace());
+ const reviewed = workspace();
+ reviewed.proposals[0].item_reviews = { c1: { action: "accept" } };
+ api.reviewProposalItem.mockResolvedValue(reviewed);
+ const user = userEvent.setup();
+ render();
+
+ const inline = await screen.findByTestId("inline-proposal-c1");
+ expect(within(inline).getByText("日均千万笔的")).toBeInTheDocument();
+ expect(screen.getByTestId("resume-canvas-proposals")).toHaveTextContent("AI 建议 1 条");
+ await user.click(within(inline).getByRole("button", { name: /^采用/ }));
+ await waitFor(() => expect(api.reviewProposalItem).toHaveBeenCalledWith("prop-1", {
+ resume_id: 7, change_id: "c1", action: "accept", edited_text: "",
+ }));
+ await waitFor(() => expect(screen.queryByTestId("inline-proposal-c1")).not.toBeInTheDocument());
+ });
+
+ it("keeps the user's own edit and refuses to apply a suggestion built on the old text", async () => {
+ api.workspace.mockResolvedValue(workspace([{ ...before.content_json[0], description: "" }]));
+ const user = userEvent.setup();
+ render();
+
+ const inline = await screen.findByTestId("inline-proposal-c1");
+ expect(within(inline).getByRole("note")).toHaveTextContent("以你的版本为准");
+ expect(within(inline).getByRole("button", { name: /^采用/ })).toBeDisabled();
+ expect(screen.getByRole("button", { name: /全部采用/ })).toBeDisabled();
+
+ await user.click(within(inline).getByRole("button", { name: /跳过/ }));
+ await waitFor(() => expect(api.reviewProposalItem).toHaveBeenCalledWith("prop-1", {
+ resume_id: 7, change_id: "c1", action: "reject", edited_text: "",
+ }));
+ });
+
+ it("hides the comparison on demand without touching the content", async () => {
+ api.workspace.mockResolvedValue(workspace());
+ const user = userEvent.setup();
+ render();
+ await screen.findByTestId("inline-proposal-c1");
+ await user.click(screen.getByRole("button", { name: "只看当前" }));
+ expect(screen.queryByTestId("inline-proposal-c1")).not.toBeInTheDocument();
+ expect(screen.getAllByText("负责对账服务").length).toBeGreaterThan(0);
+ });
+});
diff --git a/frontend/src/app/resume/[id]/page.tsx b/frontend/src/app/resume/[id]/page.tsx
index 5a190750..40a0ee4c 100644
--- a/frontend/src/app/resume/[id]/page.tsx
+++ b/frontend/src/app/resume/[id]/page.tsx
@@ -47,6 +47,10 @@ import type { ResumeDetail, ResumeSectionBlock } from "@/lib/hooks";
import SectionEditor from "../components/SectionEditor";
import ResumePreview from "../components/ResumePreview";
import ResumeDesignPanel from "../components/ResumeDesignPanel";
+import { normalizeTemplateSettings } from "../components/templates/templateSettings";
+import { buildCommentPrompt, CanvasSelectionBar, CommentTray, InlineProposal, type CanvasComment, type InlineChange } from "../components/canvas/CanvasAssist";
+import { conflictsWithManualEdit, findTargetSection } from "../components/canvas/proposalDiff";
+import { composeToAgent } from "@/lib/agentCompose";
import { safeClientErrorMessage } from "@/lib/safe-error";
type DraftResume = ResumeDetail & { sections: ResumeSectionBlock[] };
@@ -378,6 +382,27 @@ export default function ResumeEditorPage() {
const [exporting, setExporting] = useState(false);
const [restoring, setRestoring] = useState(null);
const [canUndo, setCanUndo] = useState(false);
+ // 「纸」画布:量出成品高度,超过一页时在第一页底部画出分页线,让用户自己决定删哪条。
+ const [canvasEl, setCanvasEl] = useState(null);
+ const [pageOverflowMm, setPageOverflowMm] = useState(0);
+ const canvasNodeRef = useRef(null);
+ const canvasRef = useCallback((node: HTMLDivElement | null) => { canvasNodeRef.current = node; setCanvasEl(node); }, []);
+ // 切片 ②:画布上堆积的修改意见,以及「对比 / 当前」视图。
+ const [comments, setComments] = useState([]);
+ const [showProposals, setShowProposals] = useState(true);
+ const pageSizeKey = draft?.style_config?.pageSize === "LETTER" ? "LETTER" : "A4";
+ const pageHeightPx = ((pageSizeKey === "LETTER" ? 279.4 : 297) * 96) / 25.4;
+ useEffect(() => {
+ if (!canvasEl || typeof ResizeObserver === "undefined") { setPageOverflowMm(0); return; }
+ const measure = () => {
+ const height = canvasEl.getBoundingClientRect().height;
+ setPageOverflowMm(Math.max(0, ((height - pageHeightPx) * 25.4) / 96));
+ };
+ const observer = new ResizeObserver(measure);
+ observer.observe(canvasEl);
+ measure();
+ return () => observer.disconnect();
+ }, [canvasEl, pageHeightPx]);
const hydratedRef = useRef(false);
const skipAutosaveRef = useRef(false);
const lastSavedSignatureRef = useRef("");
@@ -586,6 +611,7 @@ export default function ResumeEditorPage() {
if (loadError || !workspace || !draft) return {loadError || "简历工作区不存在"}
;
const style = draft.style_config || {};
+ const paperMode = normalizeTemplateSettings(style).template === "paper";
const setStyle = (key: string, value: string) => updateDraft({ style_config: { ...style, [key]: value } });
const targetLabel = workspace.job ? `${workspace.job.company} · ${workspace.job.title}` : "未绑定目标岗位";
const packetArtifactState = workspace.application_packet.artifact_state;
@@ -598,11 +624,49 @@ export default function ResumeEditorPage() {
const packetSubmissionStatus = externalSubmissionStatus(workspace.application_packet.external_submission);
const activeProposal = workspace.proposals.find((item) => ["ready", "in_review", "blocked"].includes(item.status));
const staleProposal = workspace.proposals.find((item) => item.status === "stale");
+ // AI 建议落到它要改的那一段里。冲突(建议生成后用户改过这段)以用户版本为准,不能直接采用。
+ const pendingChanges: InlineChange[] = activeProposal
+ ? (activeProposal.diff || []).filter((change) => !activeProposal.item_reviews?.[String(change.change_id || "")]) as InlineChange[]
+ : [];
+ const changeTargets = pendingChanges.map((change) => ({
+ change,
+ target: change.change_type === "added" ? undefined : findTargetSection(draft.sections, change),
+ }));
+ const isConflicted = (change: InlineChange, target?: ResumeSectionBlock) => conflictsWithManualEdit(target, change);
+ const safeChangeIds = changeTargets.filter(({ change, target }) => !isConflicted(change, target)).map(({ change }) => String(change.change_id));
+ const factGateBlocked = activeProposal?.fact_gate_status === "blocked";
+ const askRewrite = (target: ResumeSectionBlock | undefined) => composeToAgent({
+ skillId: "tailor_resume",
+ message: buildCommentPrompt(draft.id, targetLabel, [{
+ id: "rewrite", quote: target?.title || "这一段", section: target?.title || "这一段",
+ instruction: "我在你的建议生成后手动改过这一段。请基于我现在的版本重新给出建议,保留我改过的措辞。",
+ }]),
+ });
+ const renderInline = (change: InlineChange, target?: ResumeSectionBlock) => (
+ void handleProposalAction(String(change.change_id), "accept", edited || "")}
+ onSkip={() => void handleProposalAction(String(change.change_id), "reject")} onAskRewrite={() => askRewrite(target)} />
+ );
+ const renderSectionAside = showProposals && paperMode
+ ? (section: { id: number }) => {
+ const items = changeTargets.filter(({ target }) => target?.id === section.id);
+ return items.length ? <>{items.map(({ change, target }) => renderInline(change, target))}> : null;
+ }
+ : undefined;
+ const orphanChanges = changeTargets.filter(({ target }) => !target);
+ const canvasFooter = showProposals && paperMode && orphanChanges.length
+ ? {orphanChanges.map(({ change }) => renderInline(change))}
+ : undefined;
+ const submitComments = () => {
+ if (!comments.length) return;
+ composeToAgent({ skillId: "tailor_resume", message: buildCommentPrompt(draft.id, targetLabel, comments) });
+ setComments([]);
+ };
return (
- Resume Workspace
{packetResumeStatus.label}{targetLabel}
+ 岗位简历
{packetResumeStatus.label}{targetLabel}
{saveState === "saving" && "正在保存…"}{saveState === "saved" && "已保存"}{saveState === "failed" && "保存失败"}
@@ -615,9 +679,9 @@ export default function ResumeEditorPage() {
{EDITOR_SECTION_TYPES.map(([type, label]) => )}
-
Live Preview {style.pageSize === "LETTER" ? "Letter" : "A4"}
编辑即更新
+
{paperMode ? "成品" : "预览"} {style.pageSize === "LETTER" ? "Letter" : "A4"}
{paperMode ?
{pendingChanges.length > 0 && AI 建议 {pendingChanges.length} 条}选中文字可留言给 AI · 点任意文字直接修改 · 回车新增要点 · Esc 撤回
:
}
updateDraft(patch)} onSectionChange={(section) => updateSection(section as ResumeSectionBlock)} renderSectionAside={renderSectionAside} canvasFooter={canvasFooter} />{paperMode && setComments((current) => [...current, { ...comment, id: `comment-${Date.now()}-${current.length}` }])} />}{paperMode && pageOverflowMm > 0 && 第一页到此 · 超出约 {Math.round(pageOverflowMm)} mm
} {paperMode &&
setComments((current) => current.filter((item) => item.id !== id))} onSubmit={submitComments} />}
-