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} />}
-