From 15111e4f557748367374e26567d768a16ca3b518 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:21:02 +0800 Subject: [PATCH 01/32] apply 0001: main agent steering --- backend/app/routes/main_agent.py | 41 +++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) 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: From 56360785078f1f542a789f1a6a2d92fbb15613b4 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:21:56 +0800 Subject: [PATCH 02/32] apply 0001: backend/app/services/agent_runtime.py --- backend/app/services/agent_runtime.py | 6 ++++++ 1 file changed, 6 insertions(+) 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.""" From 6cfcfbc85cf74596d30cb49622aaece7076085b0 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:22:42 +0800 Subject: [PATCH 03/32] apply 0001: park waiting runs and support steer --- backend/app/services/embedded_agent_host.py | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) 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, *, From 4d2b822532efce5bd866e9b243d618370de76147 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:23:16 +0800 Subject: [PATCH 04/32] apply 0001: add agent never-locks tests --- backend/tests/test_agent_never_locks.py | 100 ++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 backend/tests/test_agent_never_locks.py 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() From f64deaada3230662a62898194787941b1f22f61d Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:23:56 +0800 Subject: [PATCH 05/32] apply 0001: document D-1/D-2 behavior --- docs/02-interaction-design.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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; From 93d1d5ee4d8fad0515a5c0ad957279cad751ce4f Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:24:00 +0800 Subject: [PATCH 06/32] apply 0001: add runtime steer API --- frontend/src/lib/api.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 67c57d20..c3a20b70 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1445,6 +1445,12 @@ export const agentRuntimeApi = { `/api/agent/runtime/runs/${encodeURIComponent(runId)}/resume`, { method: "POST" } ), + /** D-1:运行中发来的消息在下一个工具边界注入当前 Run;not_running 表示应作为新一轮发送。 */ + steer: (runId: string, message: string) => + request<{ ok: boolean; disposition: "steered" | "not_running" }>( + `/api/agent/runtime/runs/${encodeURIComponent(runId)}/steer`, + { method: "POST", body: JSON.stringify({ message }) } + ), abort: (runId: string) => request<{ ok: boolean; run: AgentRunRecord }>( `/api/agent/runtime/runs/${encodeURIComponent(runId)}/abort`, From 84e092ffc70b5ed36d297505a1f3f3ee6abfa4c9 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:24:38 +0800 Subject: [PATCH 07/32] apply 0001: add AgentPanel never-locks tests --- .../components/workbench/AgentPanel.test.tsx | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/frontend/src/components/workbench/AgentPanel.test.tsx b/frontend/src/components/workbench/AgentPanel.test.tsx index 0580eddf..d57eec6f 100644 --- a/frontend/src/components/workbench/AgentPanel.test.tsx +++ b/frontend/src/components/workbench/AgentPanel.test.tsx @@ -10,6 +10,7 @@ const api = vi.hoisted(() => ({ events: vi.fn(), run: vi.fn(), abort: vi.fn(), + steer: vi.fn(), resume: vi.fn(), confirm: vi.fn(), reject: vi.fn(), @@ -28,6 +29,7 @@ vi.mock("@/lib/api", () => ({ events: api.events, run: api.run, abort: api.abort, + steer: api.steer, resume: api.resume, confirm: api.confirm, reject: api.reject, @@ -597,3 +599,114 @@ describe("Proposal Plan review routing", () => { window.removeEventListener("offeru-open-plan-review", openPlanReview); }); }); + + +/** docs/02 D-1 / D-2:等待中的 Run 不锁输入,导航不中断 Run。 */ +const waitingPlanRun = () => { + const runId = `run_${"4".repeat(32)}`; + const planId = `plan_${"5".repeat(32)}`; + const groupId = `group_${"6".repeat(32)}`; + const node = { + id: `node_${"7".repeat(32)}`, + group_id: groupId, + operation: "review_resume_proposal_items", + summary: "简历修改", + status: "pending", + display: { before: "旧", after: "新", evidence: "证据", rationale: "理由" }, + }; + return runRecord({ + id: runId, + status: "waiting_confirmation", + proposal_authority: "proposal-plan-v2", + proposal_plans: [{ + id: planId, run_id: runId, revision: 1, status: "sealed", digest: "a".repeat(64), title: "计划", + groups: [{ id: groupId, title: "组", summary: "一组", rationale: "", digest: "b".repeat(64), status: "pending", nodes: [node] }], + continuations: [], + }], + steps: [{ + ...node, tool: node.operation, risk_level: "confirm", requires_confirmation: true, args: {}, + idempotency_key: "idem", attempts: 0, plan_id: planId, group_id: groupId, projection_only: true, + }], + }); +}; + +describe("Agent input never locks", () => { + it("keeps typing, skills and quick actions usable while a plan waits, and parks the waiting run", async () => { + const waiting = waitingPlanRun(); + api.start.mockResolvedValueOnce(runResponse({ run: waiting, pending_actions: waiting.steps })); + api.start.mockResolvedValueOnce(runResponse()); + api.run.mockResolvedValue({ run: waiting }); + const user = userEvent.setup(); + renderPanel(); + await sendPrompt(user, "准备岗位化简历"); + await screen.findByTestId("agent-plan-review"); + + expect(screen.getByRole("textbox")).toBeEnabled(); + expect(screen.getByRole("combobox", { name: "当前 Agent Skill" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "校招体检" })).toBeEnabled(); + + await sendPrompt(user, "顺便看看今天的新岗位"); + await waitFor(() => expect(api.start).toHaveBeenCalledTimes(2)); + expect(api.start.mock.calls[1][0].message).toBe("顺便看看今天的新岗位"); + expect(api.abort).not.toHaveBeenCalled(); + const parked = await screen.findByTestId("agent-parked-runs"); + expect(parked).toHaveTextContent("等你审核"); + + await user.click(screen.getByRole("button", { name: "回去处理" })); + expect(await screen.findByTestId("agent-plan-review")).toBeInTheDocument(); + expect(api.run).toHaveBeenCalledWith(waiting.id); + }); + + it("steers a live run with a mid-run message instead of blocking it", async () => { + let finish: (value: unknown) => void = () => {}; + api.start.mockImplementationOnce((_payload: unknown, onEvent?: (name: string, data: unknown) => void) => { + onEvent?.("run.started", { run_id: "run-1" }); + return new Promise((resolve) => { finish = resolve; }); + }); + api.steer.mockResolvedValue({ ok: true, disposition: "steered" }); + const user = userEvent.setup(); + renderPanel(); + await sendPrompt(user, "找后端岗位"); + await waitFor(() => expect(api.start).toHaveBeenCalledTimes(1)); + + await sendPrompt(user, "只要上海的"); + await waitFor(() => expect(api.steer).toHaveBeenCalledWith("run-1", "只要上海的")); + expect(await screen.findByText("已交给正在进行的任务")).toBeInTheDocument(); + expect(api.start).toHaveBeenCalledTimes(1); + await act(async () => { finish(runResponse()); }); + }); + + it("queues a message the live run cannot take and sends it once the run finishes", async () => { + let finish: (value: unknown) => void = () => {}; + api.start.mockImplementationOnce((_payload: unknown, onEvent?: (name: string, data: unknown) => void) => { + onEvent?.("run.started", { run_id: "run-1" }); + return new Promise((resolve) => { finish = resolve; }); + }); + api.start.mockResolvedValueOnce(runResponse()); + api.steer.mockResolvedValue({ ok: true, disposition: "not_running" }); + const user = userEvent.setup(); + renderPanel(); + await sendPrompt(user, "找后端岗位"); + await sendPrompt(user, "再帮我写封求职信"); + expect(await screen.findByTestId("agent-message-queue")).toHaveTextContent("再帮我写封求职信"); + + await act(async () => { finish(runResponse()); }); + await waitFor(() => expect(api.start).toHaveBeenCalledTimes(2)); + expect(api.start.mock.calls[1][0].message).toBe("再帮我写封求职信"); + await waitFor(() => expect(screen.queryByTestId("agent-message-queue")).not.toBeInTheDocument()); + }); + + it("starting a new conversation never aborts a run that waits on the user", async () => { + const waiting = waitingPlanRun(); + api.start.mockResolvedValueOnce(runResponse({ run: waiting, pending_actions: waiting.steps })); + api.run.mockResolvedValue({ run: waiting }); + const user = userEvent.setup(); + renderPanel(); + await sendPrompt(user, "准备岗位化简历"); + await screen.findByTestId("agent-plan-review"); + await user.click(screen.getByTitle("打开历史对话")); + await user.click(await screen.findByRole("button", { name: "新建" })); + await screen.findByText(/新对话已开始/); + expect(api.abort).not.toHaveBeenCalled(); + }); +}); From ea8184622f5cb737d9396ab6a92ed88d35e26758 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:27:17 +0800 Subject: [PATCH 08/32] apply 0001: preserve live runs while keeping agent input usable --- .../src/components/workbench/AgentPanel.tsx | 194 ++++++++++++++++-- 1 file changed, 178 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/workbench/AgentPanel.tsx b/frontend/src/components/workbench/AgentPanel.tsx index ddd62e7a..1f2d5266 100644 --- a/frontend/src/components/workbench/AgentPanel.tsx +++ b/frontend/src/components/workbench/AgentPanel.tsx @@ -61,8 +61,28 @@ interface PanelMessage { role: "user" | "assistant"; content: string; response?: AgentResponse; + /** D-1:运行中发来、已在下一个工具边界注入当前任务的消息。 */ + steered?: boolean; } +interface QueuedPanelMessage { + id: string; + content: string; + skillId?: string; +} + +interface ParkedRun { + id: string; + status: string; +} + +const PARKED_RUN_LABELS: Record = { + waiting_confirmation: "等你审核", + waiting_decision: "等你决定", + waiting_input: "等你回答", + interrupted: "已中断,可恢复", +}; + interface ResumeCandidate { conversationId: string; conversationTitle: string; @@ -312,6 +332,11 @@ export function AgentPanel() { const sendRequestPendingRef = useRef(false); const suppressResumePromptRef = useRef(false); const currentConvRef = useRef(null); + // D-2:导航只分离正在跑的流,不中断它;后台流结束前新消息先排队。 + const [backgroundStreams, setBackgroundStreams] = useState(0); + const [queuedMessages, setQueuedMessages] = useState([]); + const [parkedRuns, setParkedRuns] = useState([]); + const detachedControllersRef = useRef>(new Set()); const hasPendingActions = pendingActions.length > 0; const [reviewPending, setReviewPending] = useState(0); const hasPlanReview = runHasProposalPlan(activeRun); @@ -320,6 +345,9 @@ export function AgentPanel() { const hasPendingPlanReview = hasPlanReview && !["cancelled", "aborted"].includes(activeRun?.status || "") && ( planPendingGroupCount > 0 || ["waiting_confirmation", "executing"].includes(activeRun?.status || "") ); + const runNeedsUser = hasPendingActions || hasPendingPlanReview || Boolean(interruptedRunId) + || activeRun?.status === "waiting_input"; + const agentBusy = loading || backgroundStreams > 0 || reviewPending > 0; const latestResponse = useMemo(() => { return [...messages].reverse().find((message) => message.response)?.response; @@ -451,9 +479,58 @@ export function AgentPanel() { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }, [messages, loading]); + /** 把等用户处理的 Run 收进停放条;状态已持久化,随时可回来处理。 */ + const parkActiveRun = () => { + if (!activeRunId || !runNeedsUser) return; + const status = interruptedRunId ? "interrupted" : activeRun?.status || "waiting_confirmation"; + const parkedId = activeRunId; + setParkedRuns((current) => [ + ...current.filter((item) => item.id !== parkedId), + { id: parkedId, status }, + ]); + setActiveRunId(null); + setActiveRun(null); + setPendingActions([]); + setInterruptedRunId(null); + setPlanReviewNotice(""); + setPlanReviewLoading(false); + }; + + const enqueueMessage = (content: string, skillId?: string) => { + setQueuedMessages((current) => [ + ...current, + { id: `queued-${Date.now()}-${current.length}`, content, skillId }, + ]); + }; + const sendMessage = async (text?: string, explicitSkillId?: string) => { const content = (text ?? input).trim(); + if (!content) return; + if (text === undefined) setInput(""); + if (agentBusy || sendRequestPendingRef.current) { + if (loading && activeRunId && reviewPending === 0 && !explicitSkillId) { + try { + const result = await agentRuntimeApi.steer(activeRunId, content); + if (result.disposition === "steered") { + setMessages((current) => [ + ...current, + { id: `user-steer-${Date.now()}`, role: "user", content, steered: true }, + ]); + return; + } + } catch { + // 引导失败时退回排队,消息不能丢。 + } + } + enqueueMessage(content, explicitSkillId); + return; + } + await startTurn(content, explicitSkillId); + }; + + const startTurn = async (content: string, explicitSkillId?: string) => { if (!content || loading || sendRequestPendingRef.current) return; + parkActiveRun(); const requestSkillId = explicitSkillId || selectedSkillId; const currentConversationId = conversationId; suppressResumePromptRef.current = true; @@ -468,9 +545,9 @@ export function AgentPanel() { content, }; const nextMessages = [...messages, userMessage]; + const isDetached = () => detachedControllersRef.current.has(controller) || controller.signal.aborted; setMessages(nextMessages); - setInput(""); setLoading(true); setProgressText("正在启动内置助手..."); setStreamingText(""); @@ -487,7 +564,7 @@ export function AgentPanel() { conversation_id: currentConversationId, }, (event, data) => { - if (currentConvRef.current !== currentConversationId) return; + if (isDetached() || currentConvRef.current !== currentConversationId) return; setToolStream((current) => applyRuntimeToolEvent(current, event, data || {})); const eventRunId = String(data?.run_id || ""); if (eventRunId) setActiveRunId(eventRunId); @@ -507,7 +584,7 @@ export function AgentPanel() { setPlanReviewLoading(true); void agentRuntimeApi.run(planRunId) .then(({ run }) => { - if (currentConvRef.current !== currentConversationId) return; + if (isDetached() || currentConvRef.current !== currentConversationId) return; setActiveRun(run); setPendingActions(pendingActionsFromRun(run)); setPlanReviewNotice(planReviewSummary(run, false)); @@ -538,7 +615,7 @@ export function AgentPanel() { }, controller.signal, ); - if (currentConvRef.current !== currentConversationId) return; + if (isDetached() || currentConvRef.current !== currentConversationId) return; if (!runtimeResponse.ok) { setActiveRunId(runtimeResponse.run.id); setActiveRun(runtimeResponse.run); @@ -571,6 +648,9 @@ export function AgentPanel() { setError(safeClientErrorMessage(err, "OfferU 请求失败")); setInput((current) => current || content); } finally { + if (detachedControllersRef.current.delete(controller)) { + setBackgroundStreams((count) => Math.max(0, count - 1)); + } if (abortControllerRef.current === controller) { abortControllerRef.current = null; sendRequestPendingRef.current = false; @@ -580,11 +660,55 @@ export function AgentPanel() { } }; + // 排队消息在 Agent 真正空闲且当前没有待用户处理的 Run 时自动发送。 + useEffect(() => { + if (agentBusy || runNeedsUser || queuedMessages.length === 0) return; + const [next, ...rest] = queuedMessages; + setQueuedMessages(rest); + void startTurn(next.content, next.skillId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agentBusy, runNeedsUser, queuedMessages]); + + const sendQueuedNow = (id: string) => { + if (agentBusy) return; + const item = queuedMessages.find((entry) => entry.id === id); + if (!item) return; + setQueuedMessages((current) => current.filter((entry) => entry.id !== id)); + void startTurn(item.content, item.skillId); + }; + + const editQueued = (id: string) => { + const item = queuedMessages.find((entry) => entry.id === id); + if (!item) return; + setQueuedMessages((current) => current.filter((entry) => entry.id !== id)); + setInput((current) => (current ? `${current}\n${item.content}` : item.content)); + }; + + const restoreParkedRun = async (id: string) => { + if (loading) return; + try { + const { run } = await agentRuntimeApi.run(id); + parkActiveRun(); + setParkedRuns((current) => current.filter((item) => item.id !== id)); + if (!["waiting_confirmation", "waiting_decision", "waiting_input", "interrupted"].includes(run.status)) return; + setActiveRunId(run.id); + setActiveRun(run); + setPendingActions(run.status === "waiting_confirmation" ? pendingActionsFromRun(run) : []); + setInterruptedRunId(run.status === "interrupted" ? run.id : null); + if (runHasProposalPlan(run)) setPlanReviewNotice(planReviewSummary(run, false)); + } catch (err: any) { + setError(safeClientErrorMessage(err, "读取待处理任务失败")); + } + }; + const detachActiveStream = () => { const controller = abortControllerRef.current; abortControllerRef.current = null; sendRequestPendingRef.current = false; - controller?.abort(); + if (controller && loading && !controller.signal.aborted) { + setBackgroundStreams((count) => count + 1); + detachedControllersRef.current.add(controller); + } setLoading(false); }; @@ -595,6 +719,7 @@ export function AgentPanel() { currentConvRef.current = null; setStreamingText(""); setToolStream(createInitialAgentStreamState()); + setParkedRuns([]); setConversationId(null); setConversationTitle("新对话"); setActiveRunId(null); @@ -623,6 +748,7 @@ export function AgentPanel() { setError(""); setStreamingText(""); setToolStream(createInitialAgentStreamState()); + setParkedRuns([]); try { const conversation = await agentSupportApi.conversation(id); setConversationId(conversation.id); @@ -1219,7 +1345,6 @@ export function AgentPanel() { aria-label="当前 Agent Skill" title="默认由助手按任务自动选择 Skill" value={selectedSkillId} - disabled={loading} onChange={(event) => setSelectedSkillId(event.target.value)} className="min-w-0 flex-1 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 py-1 text-[11.5px] text-[var(--foreground)] outline-none disabled:opacity-50" > @@ -1247,7 +1372,6 @@ export function AgentPanel() { + + ))} + + )} + + {queuedMessages.length > 0 && ( +
+

+ {agentBusy ? "待发送:助手空闲后按顺序发送" : runNeedsUser ? "待发送:当前任务在等你,处理完会自动发送" : "待发送"} +

+ {queuedMessages.map((item) => ( +
+ {item.content} + {!agentBusy && runNeedsUser && ( + + )} + + +
+ ))} +
+ )} + {error && (

{agentRecovery(error).title}

@@ -1420,13 +1581,11 @@ export function AgentPanel() { minRows={1} maxRows={4} placeholder={ - hasPendingActions || hasPendingPlanReview || reviewPending > 0 - ? "继续对话或说明如何调整;旧审核仍需单独决定" - : interruptedRunId - ? "Run 已中断;可继续对话或开始其他任务" - : loading - ? "正在处理;可先写下下一步..." - : "问 OfferU,或说你要推进哪一步..." + loading + ? "补充或纠正,助手会在下一步读到..." + : agentBusy + ? "可以继续输入,空闲后按顺序发送..." + : "问 OfferU,或说你要推进哪一步..." } variant="bordered" className="flex-1" @@ -1442,10 +1601,10 @@ export function AgentPanel() { isIconOnly aria-label="发送" onPress={() => sendMessage()} - isDisabled={!input.trim() || loading} + isDisabled={!input.trim()} className="bauhaus-button bauhaus-button-outline !min-h-9 !min-w-9 !px-0 !py-0" > - {loading ? : } +
@@ -1473,6 +1632,9 @@ function PanelMessageBubble({ > {message.content} + {message.steered && ( +

已交给正在进行的任务

+ )} {response && (
{response.alerts && response.alerts.length > 0 && } From b6a74d829e01e53dc65e52ec832d43ff37a5817c Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:28:47 +0800 Subject: [PATCH 09/32] apply 0002: document paper template --- docs/05-module-resume.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/05-module-resume.md b/docs/05-module-resume.md index 757e7eef..f38939d6 100644 --- a/docs/05-module-resume.md +++ b/docs/05-module-resume.md @@ -86,6 +86,18 @@ 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 还原、未改不提交、增删条目、隐藏要点索引对齐、默认排版只作用于纸模板)。 +- 下一切片:② 选中文字浮条(精炼 / 对齐岗位 / 留言给 AI,可堆叠后一次提交)与行内 diff 的采用 / 修改 / 跳过;③ 排版控件与「压到一页」;④ DOCX 导出。 + ## 9. 验收 - 一次单岗位定制:最终采用决定 ≤ 1 次;需要审阅的独立 Proposal 达到 50–80 个即判为不合格。 From bf61366e188f7f2db24d99910fb43d41e6543fee Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:28:50 +0800 Subject: [PATCH 10/32] apply 0002: render paper template --- .../app/resume/components/ResumePreview.tsx | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/resume/components/ResumePreview.tsx b/frontend/src/app/resume/components/ResumePreview.tsx index ffda0b72..d5f4e5ba 100644 --- a/frontend/src/app/resume/components/ResumePreview.tsx +++ b/frontend/src/app/resume/components/ResumePreview.tsx @@ -6,6 +6,7 @@ import { ResumeSwissTwoColumn } from "./templates/ResumeSwissTwoColumn"; import { ResumeModernSingle } from "./templates/ResumeModernSingle"; import { ResumeModernTwoColumn } from "./templates/ResumeModernTwoColumn"; import { ResumeReference } from "./templates/ResumeReference"; +import { ResumePaper, type PaperSection, type ResumePaperProps } from "./templates/ResumePaper"; import { normalizeTemplateSettings, settingsToCssVars, @@ -35,6 +36,10 @@ interface ResumePreviewProps { sections: Section[]; styleConfig: Record; highlightKeywords?: string[]; + /** 仅「纸」模板:在画布上直接编辑成品。其他模板保持只读预览。 */ + editable?: boolean; + onProfileChange?: ResumePaperProps["onProfileChange"]; + onSectionChange?: (section: Section) => void; } function normalizeSkillEntry(item: any, index: number): NormalizedResumeItem { @@ -208,6 +213,21 @@ const ResumePreview = forwardRef(function Re const highlightKeywords = useMemo(() => props.highlightKeywords || [], [props.highlightKeywords]); const template = (() => { + if (settings.template === "paper") { + return ( + + ); + } if ( settings.template === "reference" || settings.template === "reference-compact" @@ -230,7 +250,7 @@ const ResumePreview = forwardRef(function Re
Date: Thu, 8 Oct 2026 16:28:52 +0800 Subject: [PATCH 11/32] apply 0002: add paper template defaults --- .../components/templates/templateSettings.ts | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/resume/components/templates/templateSettings.ts b/frontend/src/app/resume/components/templates/templateSettings.ts index a64d1860..ed683339 100644 --- a/frontend/src/app/resume/components/templates/templateSettings.ts +++ b/frontend/src/app/resume/components/templates/templateSettings.ts @@ -1,6 +1,7 @@ import type { CSSProperties } from "react"; export type ResumeTemplateType = + | "paper" | "reference" | "reference-compact" | "swiss-single" @@ -89,6 +90,11 @@ export const TEMPLATE_OPTIONS: Array<{ name: string; description: string; }> = [ + { + id: "paper", + name: "纸 · 可直接编辑", + description: "克制的衬线排版,点页面上的任意文字直接修改,所见即导出。", + }, { id: "reference", name: "中文经典", @@ -202,6 +208,7 @@ function asSpacingLevel(value: unknown, fallback: SpacingLevel): SpacingLevel { function asTemplate(value: unknown): ResumeTemplateType { if ( + value === "paper" || value === "reference" || value === "reference-compact" || value === "swiss-single" || @@ -275,20 +282,31 @@ function lineHeightToLevel(value: unknown, fallback: SpacingLevel): SpacingLevel return 5; } +/** 「纸」模板的默认排版:参考 Kami 的简历密排参数(页边距 11/13mm、行高 1.45、墨蓝 #1B365D)。 */ +const PAPER_EXACT_DEFAULTS: ResumeTemplateSettings["exact"] = { + ...DEFAULT_TEMPLATE_SETTINGS.exact, + bodySize: 10, headingSize: 12, nameSize: 22, + lineHeight: 1.45, sectionGap: 12, itemGap: 6, paragraphGap: 1, headerGap: 9, + headingColor: "#1B365D", ruleColor: "#E8E6DC", +}; +const PAPER_MARGINS = { top: 11, right: 13, bottom: 11, left: 13 }; + export function normalizeTemplateSettings(config: Record = {}): ResumeTemplateSettings { const rawTemplate = config.template || config.templateType; const template = asTemplate(rawTemplate); const reference = template === "reference" || template === "reference-compact"; + const paper = template === "paper"; const accent = asAccent(config.accentColorName || config.accentColor); - const base = DEFAULT_TEMPLATE_SETTINGS.exact; + const base = paper ? PAPER_EXACT_DEFAULTS : DEFAULT_TEMPLATE_SETTINGS.exact; + const marginDefaults = paper ? PAPER_MARGINS : DEFAULT_TEMPLATE_SETTINGS.margins; return { template, pageSize: config.pageSize === "LETTER" ? "LETTER" : "A4", margins: { - top: parseMarginMm(config.marginTop ?? config.pageMargin, DEFAULT_TEMPLATE_SETTINGS.margins.top), - right: parseMarginMm(config.marginRight ?? config.pageMargin, DEFAULT_TEMPLATE_SETTINGS.margins.right), - bottom: parseMarginMm(config.marginBottom ?? config.pageMargin, DEFAULT_TEMPLATE_SETTINGS.margins.bottom), - left: parseMarginMm(config.marginLeft ?? config.pageMargin, DEFAULT_TEMPLATE_SETTINGS.margins.left), + top: parseMarginMm(config.marginTop ?? config.pageMargin, marginDefaults.top), + right: parseMarginMm(config.marginRight ?? config.pageMargin, marginDefaults.right), + bottom: parseMarginMm(config.marginBottom ?? config.pageMargin, marginDefaults.bottom), + left: parseMarginMm(config.marginLeft ?? config.pageMargin, marginDefaults.left), }, spacing: { section: asSpacingLevel( @@ -314,7 +332,7 @@ export function normalizeTemplateSettings(config: Record = {}): Res showContactIcons: config.showContactIcons === true || config.showContactIcons === "true", accentColor: accent, exact: { - bodySize: numeric(config.bodySize, config.fontSize ? parseFloat(FONT_SIZE_MAP[asSpacingLevel(config.fontSize, 2)]) : reference ? base.bodySize : 12, 8, 20), + bodySize: numeric(config.bodySize, config.fontSize ? parseFloat(FONT_SIZE_MAP[asSpacingLevel(config.fontSize, 2)]) : reference || paper ? base.bodySize : 12, 8, 20), headingSize: numeric(config.headingSize, base.headingSize, 8, 28), nameSize: numeric(config.nameSize, base.nameSize, 10, 40), lineHeight: numeric(config.lineHeight, config.lineHeightLevel ? LINE_HEIGHT_MAP[asSpacingLevel(config.lineHeightLevel, 3)] : base.lineHeight, 1, 2), @@ -322,7 +340,7 @@ export function normalizeTemplateSettings(config: Record = {}): Res itemGap: numeric(config.itemGap, config.itemSpacing ? parseFloat(ITEM_SPACING_MAP[asSpacingLevel(config.itemSpacing, 2)]) * 12 : base.itemGap, 0, 20), paragraphGap: numeric(config.paragraphGap, base.paragraphGap, 0, 16), headerGap: numeric(config.headerGap, base.headerGap, 0, 30), - headingColor: color(config.accentColorHex, color(config.accentColor, ACCENT_COLOR_MAP[accent].primary)), + headingColor: color(config.accentColorHex, color(config.accentColor, paper ? base.headingColor : ACCENT_COLOR_MAP[accent].primary)), ruleColor: color(config.ruleColor, base.ruleColor), photoWidth: numeric(config.photoWidth, base.photoWidth, 10, 50), photoHeight: numeric(config.photoHeight, base.photoHeight, 10, 60), From e43edb0081bc13a9bee172cc1bd592d9cfdcbcd7 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:29:39 +0800 Subject: [PATCH 12/32] apply 0002: make paper template the editable canvas --- frontend/src/app/resume/[id]/page.tsx | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/resume/[id]/page.tsx b/frontend/src/app/resume/[id]/page.tsx index 5a190750..d51df654 100644 --- a/frontend/src/app/resume/[id]/page.tsx +++ b/frontend/src/app/resume/[id]/page.tsx @@ -47,6 +47,7 @@ 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 { safeClientErrorMessage } from "@/lib/safe-error"; type DraftResume = ResumeDetail & { sections: ResumeSectionBlock[] }; @@ -378,6 +379,23 @@ 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 canvasRef = useCallback((node: HTMLDivElement | null) => setCanvasEl(node), []); + 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 +604,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; @@ -602,7 +621,7 @@ export default function ResumeEditorPage() { return (
-

Resume Workspace

{packetResumeStatus.label}

{targetLabel}

+

岗位简历

{packetResumeStatus.label}

{targetLabel}

{saveState === "saving" && "正在保存…"}{saveState === "saved" && "已保存"}{saveState === "failed" && "保存失败"}
@@ -615,9 +634,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 ?
点任意文字直接修改 · 回车新增要点 · Esc 撤回本次输入
: }
updateDraft(patch)} onSectionChange={(section) => updateSection(section as ResumeSectionBlock)} />{paperMode && pageOverflowMm > 0 &&
第一页到此 · 超出约 {Math.round(pageOverflowMm)} mm
}
-