Files
human-voice-rewrite-demo/index.html
T
LuminousRuoxi a2c607c1be fix: 对话页「查看复检历史」点了没反应 + 再次点击不关闭
- 根因: 复检历史面板挂在 workbenchView 容器内,对话页该容器 display:none
  导致 fixed 面板整棵子树不渲染(用户实测)。面板移出视图容器挂到顶层
- 同时修复: handleAction 里 view-recheck-history 硬编码 toggleRecheckHistory(true),
  点第二次不会关闭;改为无参切换
- 验证: 对话页/改写页两个入口 7/7 通过(打开/关闭/再打开/点外部关闭/无 JS 报错)
2026-09-10 15:43:23 +08:00

1670 lines
112 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>智能改稿 · Human Voice Rewrite Demo</title>
<style>
:root{
--bg:#f6f7fb;--panel:#fff;--text:#171a21;--muted:#737b8d;--line:#e7e9f0;
--brand:#5654f5;--brand2:#4744e7;--brand-soft:#f0f1ff;--good:#13966d;--good-soft:#edf9f4;
--bad:#c8463a;--bad-soft:#fff3f1;--warn:#a86f10;--warn-soft:#fff8e8;--shadow:0 12px 34px rgba(23,27,42,.07);
}
*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);font-family:Inter,"PingFang SC","Microsoft YaHei",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.58}
button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!important}
.topbar{height:54px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 22px;position:sticky;top:0;z-index:50}
.crumbs{display:flex;gap:8px;align-items:center;color:#7c8496;font-size:12px}.crumbs strong{color:#34394a}.crumbs .slash{color:#c3c7d0}
.top-actions{display:flex;gap:8px;align-items:center}.chip{font-size:12px;padding:6px 9px;border-radius:8px;background:#f4f5f8;color:#667085}.chip.good{background:var(--good-soft);color:#167655}.chip.warn{background:var(--warn-soft);color:#8a5d10}.chip.bad{background:var(--bad-soft);color:#b53a30;cursor:pointer}.dotmenu{font-size:20px;color:#7d8391;padding:0 7px}
.btn{border:1px solid var(--line);background:#fff;color:#343849;border-radius:9px;padding:9px 14px;font-weight:650;transition:.15s}.btn:hover{background:#f8f9fc}.btn.primary{background:var(--brand);border-color:var(--brand);color:#fff}.btn.primary:hover{background:var(--brand2)}.btn.small{padding:7px 10px;font-size:12px}.btn.link{border:0;background:transparent;color:var(--brand);padding:5px 0}.btn:disabled{opacity:.45;cursor:not-allowed}
/* Entry */
.entry-shell{height:calc(100vh - 54px);display:grid;grid-template-columns:minmax(0,1fr) 372px;background:#fff}
.essay-pane{overflow:auto;padding:38px 42px 70px;border-right:1px solid var(--line)}
.essay-inner{max-width:790px;margin:0 auto}.essay-title{font-size:29px;line-height:1.25;margin:0 0 18px;color:#5a6070;font-weight:760}
.prompt-card{border:1px solid #dfe3f0;border-radius:11px;background:#fafbff;padding:13px 15px;font-size:12px;color:#687188;margin-bottom:28px}.prompt-card strong{color:#3c4355}.prompt-cn{display:block;margin-top:5px;color:#5852ea}
.essay-copy{font:17px/1.82 Georgia,"Times New Roman",serif;color:#2f3444}.essay-copy p{margin:0 0 23px}.word-badge{position:sticky;bottom:12px;margin:12px auto 0;width:max-content;background:#fff;border:1px solid var(--line);box-shadow:0 5px 20px rgba(20,25,35,.08);padding:5px 10px;border-radius:8px;color:#9aa0ad;font-size:11px}
.essay-head-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.essay-head-actions{display:flex;gap:8px;flex-shrink:0;padding-top:6px}
.version-switch{display:flex;align-items:center;gap:6px;margin:0 0 16px}
.vs-label{font-size:11px;color:#8e95a5;font-weight:800;margin-right:2px}
.vs-btn{border:1px solid #dfe1ec;background:#fff;border-radius:999px;padding:5px 13px;font-size:12px;color:#4f5668;font-weight:700;cursor:pointer}
.vs-btn:hover{border-color:#b8b9ff;color:#4440d5}
.vs-btn.active{background:var(--brand);border-color:var(--brand);color:#fff}
.essay-edit-inputs{display:flex;flex-direction:column;gap:12px}
.essay-edit-inputs textarea{width:100%;min-height:340px;border:1px solid #e2e4ed;border-radius:11px;padding:14px 15px;font:16px/1.8 Georgia,"Times New Roman",serif;color:#2f3443;resize:vertical;outline:0;transition:.15s}
.essay-edit-inputs textarea:focus{border-color:#b9baff;box-shadow:0 0 0 3px rgba(86,84,245,.08)}
.essay-edit-inputs input[type=text],.essay-edit-inputs input[type=number]{border:1px solid #e2e4ed;border-radius:9px;padding:9px 12px;font-size:13px;outline:0}
.essay-edit-inputs input:focus{border-color:#b9baff;box-shadow:0 0 0 3px rgba(86,84,245,.08)}
.essay-edit-row{display:flex;gap:10px}.essay-edit-row input[type=text]{flex:1}.essay-edit-row input[type=number]{width:150px}
.smart-pane{display:flex;flex-direction:column;background:#fff}.smart-head{height:48px;border-bottom:1px solid var(--line);padding:13px 18px;font-size:14px;font-weight:730}.smart-body{flex:1;display:flex;align-items:center;justify-content:center;padding:28px}.smart-intro{width:100%;max-width:290px}.smart-intro h2{font-size:22px;margin:0 0 12px}.smart-intro p{font-size:13px;color:#7d8495;margin:0 0 18px;line-height:1.8}.smart-intro .btn{width:100%;padding:11px}
.backend-banner{display:none;margin:0 0 14px;padding:9px 11px;border-radius:9px;background:var(--bad-soft);border:1px solid #f0cfc9;color:#9a3b32;font-size:12px;line-height:1.65}.backend-banner.show{display:block}
/* Agent */
.agent-shell{min-height:calc(100vh - 54px);background:#f6f7fb}.agent-top{height:52px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;padding:0 24px;position:sticky;top:54px;z-index:35}.back{border:0;background:transparent;color:#747b8c;font-size:13px}.back:hover{color:#4a5168}.work-back-row{display:flex;gap:14px;margin-bottom:5px}.work-back{padding:0}.agent-title{font-weight:760;font-size:14px}
.chat-wrap{max-width:1040px;margin:0 auto;padding:32px 26px 120px}.message{display:grid;grid-template-columns:38px 1fr;gap:12px;margin-bottom:22px}.message.user{grid-template-columns:1fr;justify-items:end}.avatar{width:36px;height:36px;border-radius:12px;background:linear-gradient(145deg,#4d4ae9,#7b69fa);color:#fff;display:grid;place-items:center;font-weight:800;font-size:13px;box-shadow:0 5px 16px rgba(79,70,229,.18)}
.bubble{width:100%;background:#fff;border:1px solid var(--line);border-radius:17px;padding:22px 24px;box-shadow:var(--shadow)}.user .bubble{width:auto;max-width:70%;background:#5b57ee;color:#fff;border:0;padding:11px 15px;box-shadow:none}
.bubble h1{font-size:25px;margin:0 0 7px;line-height:1.35}.bubble h2{font-size:18px;margin:20px 0 10px}.bubble h3{font-size:14px;margin:15px 0 7px}.bubble p{margin:7px 0;color:#414757}.user .bubble p{color:#fff;margin:0}.eyebrow{color:var(--brand);font-size:12px;font-weight:800;margin-bottom:7px}.bubble .muted{color:var(--muted);font-size:13px}.summary{background:#f8f8ff;border:1px solid #dfe1ff;border-radius:12px;padding:14px 16px;color:#30364a;line-height:1.75;margin:12px 0}
.agent-table{width:100%;border-collapse:separate;border-spacing:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;margin:10px 0 14px;font-size:13px}.agent-table th{background:#f8f9fc;text-align:left;padding:10px 11px;color:#687084;font-weight:700;border-bottom:1px solid var(--line)}.agent-table td{padding:11px;border-bottom:1px solid var(--line);vertical-align:top}.agent-table tr:last-child td{border-bottom:0}.agent-table td:first-child{font-weight:700;white-space:nowrap}
.inline-note{background:#fafbff;border-left:3px solid #7775f4;padding:9px 12px;border-radius:5px;margin:10px 0;color:#555c70;font-size:13px}.quick-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.quick{border:1px solid #dfe1ec;background:#fff;border-radius:999px;padding:8px 12px;font-size:12px;color:#4f5668;font-weight:650}.quick:hover{border-color:#b8b9ff;color:#4440d5;background:#f8f8ff}.quick.primary{background:var(--brand);border-color:var(--brand);color:#fff;padding:10px 18px;box-shadow:0 6px 16px rgba(86,84,245,.18)}.quick.primary:hover{background:var(--brand2);color:#fff}.quick.ghost{background:#fff}.quick:disabled{opacity:.45;cursor:not-allowed}
.agent-input{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(180deg,rgba(246,247,251,0),#f6f7fb 26%);padding:30px 20px 18px;z-index:40}.composer{max-width:900px;margin:0 auto;background:#fff;border:1px solid #dcdfe8;border-radius:15px;padding:9px 10px 9px 14px;display:flex;gap:10px;align-items:flex-end;box-shadow:0 10px 28px rgba(28,31,45,.09)}.composer textarea{flex:1;border:0;outline:0;resize:none;min-height:38px;max-height:120px;padding:7px 4px;font-size:13px}.composer button{width:36px;height:36px;border-radius:10px;border:0;background:var(--brand);color:#fff;font-weight:800}.input-hint{max-width:900px;margin:8px auto 0;color:#d83a3a;font-size:12px;font-weight:600;padding-left:14px}
.status-line{display:flex;gap:8px;align-items:center;margin:7px 0 13px;color:#737a8b;font-size:12px}.status-line .pill{padding:4px 8px;border-radius:999px;background:#f1f2f6}.constraint-box{margin-top:13px;background:#f9faff;border:1px solid #e0e2ef;border-radius:11px;padding:11px 13px;font-size:13px;color:#52596c}
/* Workbench — two columns */
.workbench-shell{min-height:calc(100vh - 54px);background:#f6f7fb}
.work-head{background:#fff;border-bottom:1px solid var(--line);padding:15px 24px;position:sticky;top:54px;z-index:30}
.work-head-inner{max-width:1540px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:24px}
.work-title{font-size:21px;font-weight:790;line-height:1.35}.work-sub{font-size:12px;color:var(--muted);margin-top:4px}
.progress-mini{display:flex;gap:12px;align-items:center;min-width:270px;justify-content:flex-end}.progress-track{width:138px;height:6px;background:#eceef4;border-radius:999px;overflow:hidden}.progress-track div{height:100%;width:0;background:var(--brand);transition:.25s}.progress-txt{font-size:12px;color:#4d5569;font-weight:750;white-space:nowrap}.progress-txt.over{color:#b53a30}
.constraint-entry{display:flex;flex-direction:column;align-items:flex-end;gap:8px}
.constraint-input-row{display:flex;flex-direction:column;gap:8px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:10px;box-shadow:var(--shadow);width:340px;position:absolute;right:24px;top:60px;z-index:40}
.constraint-input-row textarea{width:100%;min-height:64px;border:1px solid #e2e4ed;border-radius:9px;padding:8px 10px;font-size:12px;outline:0;resize:vertical}
.constraint-input-row .row-actions{display:flex;gap:8px;justify-content:flex-end}
.work-grid{max-width:1540px;margin:0 auto;padding:16px 24px 96px;display:grid;grid-template-columns:minmax(0,46fr) minmax(0,54fr);gap:14px;align-items:start}
.pane-card{background:#fff;border:1px solid var(--line);border-radius:14px;overflow:hidden;min-height:690px}.pane-head{height:54px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-weight:740;font-size:13px}.pane-body{padding:18px}
.sticky-pane{position:sticky;top:132px;max-height:calc(100vh - 300px);overflow:auto}
.original-pane-head{height:58px;gap:12px;flex-wrap:nowrap}.original-pane-title{display:flex;align-items:center;gap:7px;white-space:nowrap}
.paragraph-switcher{display:flex;align-items:center;gap:6px;margin-left:auto;overflow-x:auto;max-width:min(420px,42vw);padding-bottom:1px}
.para-tab{width:36px;height:34px;border:1px solid #dde0e8;border-radius:9px;background:#fff;color:#626a7c;font-size:13px;font-weight:780;display:grid;place-items:center;padding:0;transition:.15s;flex:none}.para-tab:hover{border-color:#b9baff;background:#f8f8ff;color:#4541d7}.para-tab.active{background:var(--brand);border-color:var(--brand);color:#fff;box-shadow:0 4px 10px rgba(86,84,245,.14)}.para-tab.has-copy:not(.active){border-color:#cfe4dc;color:#26765a;background:#f7fbf9}
.view-toggle{display:inline-flex;padding:3px;background:#eff1f5;border-radius:9px;gap:2px;flex:none;margin-left:4px}.view-btn{border:0;background:transparent;color:#737b8d;border-radius:7px;padding:6px 10px;font-size:11px;font-weight:700}.view-btn.active{background:#fff;color:#3936c9;box-shadow:0 1px 4px rgba(28,31,45,.08)}
.original-workspace{display:flex;flex-direction:column;gap:14px}
.revision-banner{border:1px solid #f0dfb8;background:#fff9ea;border-radius:11px;padding:13px 14px}
.revision-banner .eyebrow{margin-bottom:4px}.revision-banner h3{margin:0 0 8px;font-size:15px}.revision-banner p{margin:0 0 6px;font-size:12px;color:#5d5340}.revision-banner .summary{margin:8px 0 0}
.rewrite-direction{border:1px solid #e3e6ee;background:#fcfcfd;border-radius:11px;padding:13px 14px}.direction-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:9px}.direction-head strong{font-size:12px;color:#343a4b}.direction-head span{font-size:10px;color:#969dac;font-weight:700}.direction-line{display:grid;grid-template-columns:78px 1fr;gap:10px;align-items:start;padding:6px 0}.direction-line+.direction-line{border-top:1px solid #eef0f4}.direction-chip{font-size:10px;font-weight:800;line-height:22px;height:22px;text-align:center;border-radius:6px;white-space:nowrap}.direction-chip.meaning{color:#525b6d;background:#eef0f4}.direction-chip.action{color:#4b48cf;background:#eeeeff}.direction-chip.ai{color:#9a6519;background:#fff2dc}.direction-text{font-size:12px;color:#454c5e;line-height:1.68}.direction-line.meaning .direction-text{font-weight:620;color:#373e50}.direction-line.action .direction-text{font-weight:650;color:#3d4353}.direction-line.ai .direction-text{color:#535968}
.annotated-paper{padding:2px 2px 0}.paper-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:9px}.paper-title strong{font-size:12px;color:#4a5162}.paper-title span{font-size:10px;color:#9ca2af}.annotated-copy{font:15.5px/1.92 Georgia,"Times New Roman",serif;color:#343a49;margin:0}.annotation-anchor{--note-bg:#ececff;--note-line:#aaa9ff;--note-strong:#514ee1;--note-soft:#fafaff;background:linear-gradient(transparent 54%,var(--note-bg) 54%);border-bottom:1px solid var(--note-line);border-radius:2px;cursor:pointer;transition:.14s}.annotation-anchor sup{font-family:Inter,"PingFang SC",sans-serif;font-size:8px;font-weight:800;color:var(--note-strong);margin-left:1px}.annotation-anchor.active{background:var(--note-bg);box-shadow:0 0 0 2px color-mix(in srgb,var(--note-line) 28%,transparent)}
.annotation-list{display:flex;flex-direction:column;gap:9px;margin-top:13px}.annotation-card{--note-bg:#ececff;--note-line:#aaa9ff;--note-strong:#514ee1;--note-soft:#fafaff;border:1px solid color-mix(in srgb,var(--note-line) 38%,#e8eaf0);border-left:4px solid var(--note-line);background:var(--note-soft);border-radius:9px;padding:10px 11px 10px 12px;transition:.14s;scroll-margin:90px}.annotation-card.active{box-shadow:0 0 0 1px var(--note-line),0 5px 15px color-mix(in srgb,var(--note-line) 10%,transparent);background:var(--note-soft)}
.note-rhetoric{--note-bg:#eeeaff;--note-line:#a894ff;--note-strong:#6947d8;--note-soft:#fbf9ff}.note-repeat{--note-bg:#e9f2ff;--note-line:#83b5f3;--note-strong:#3478c7;--note-soft:#f6faff}.note-growth{--note-bg:#fff0db;--note-line:#e9ad56;--note-strong:#a96a16;--note-soft:#fff9f0}.note-structure{--note-bg:#e6f7f5;--note-line:#70c3bc;--note-strong:#247f78;--note-soft:#f4fbfa}
.annotation-head{display:flex;align-items:center;gap:7px;margin-bottom:4px}.annotation-num{width:19px;height:19px;border-radius:999px;background:var(--note-bg);color:var(--note-strong);display:grid;place-items:center;font-size:10px;font-weight:800;flex:none}.annotation-kind{font-size:9px;font-weight:800;color:var(--note-strong);background:var(--note-bg);border:1px solid color-mix(in srgb,var(--note-line) 38%,transparent);border-radius:999px;padding:2px 6px;white-space:nowrap}.annotation-title{font-size:12px;font-weight:780;color:#353b4b}.annotation-observation{font-size:11.5px;color:#666e80;line-height:1.68}.annotation-action{font-size:11.5px;color:#3f4658;line-height:1.68;margin-top:3px}.annotation-action strong{font-weight:760;color:var(--note-strong)}.why-toggle{border:0;background:transparent;padding:5px 0 0;color:#777e8f;font-size:10.5px;font-weight:700}.why-toggle:hover{color:var(--note-strong)}.annotation-why{display:none;margin-top:7px;padding-top:7px;border-top:1px solid color-mix(in srgb,var(--note-line) 22%,#ececf4);font-size:10.8px;color:#71788a;line-height:1.68}.annotation-card.why-open .annotation-why{display:block}
.context-hint{border-top:1px solid var(--line);padding-top:12px}.context-label{font-size:10px;color:#8e95a5;font-weight:800;margin-bottom:4px}.context-copy{font-size:11.5px;color:#666e80;line-height:1.7}
.prior-guidance{border:1px solid #e7e9f0;border-radius:10px;padding:10px 12px;background:#fafbff}.prior-guidance summary{cursor:pointer;font-size:12px;font-weight:720;color:#555c70}
.full-original-list{display:flex;flex-direction:column;gap:9px}.mode-note{font-size:11px;color:#9299aa;margin:0 0 2px}.full-paragraph{border:1px solid transparent;border-radius:11px;padding:12px 13px;transition:.15s;cursor:pointer;position:relative}.full-paragraph:hover{background:#fafaff;border-color:#e8e8ff}.full-paragraph.active{background:#f7f7ff;border-color:#dedfff}.full-paragraph.active:before{content:"";position:absolute;left:-1px;top:12px;bottom:12px;width:3px;border-radius:3px;background:var(--brand)}.full-paragraph-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:6px}.full-paragraph-label{font-size:11.5px;font-weight:780;color:#42485b}.full-paragraph-state{font-size:10px;color:#8f96a6}.full-paragraph.active .full-paragraph-state{color:var(--brand);font-weight:750}.full-paragraph-copy{font:13.5px/1.72 Georgia,"Times New Roman",serif;color:#4a5060;margin:0}.full-paragraph-foot{display:flex;justify-content:flex-end;margin-top:7px}.full-issues-button{border:0;background:transparent;color:#6663d9;font-size:10.5px;font-weight:750;padding:2px 0}.full-issues-button:hover{text-decoration:underline}
.rewrite-card{min-height:0;height:calc(100vh - 300px);max-height:none;display:flex;flex-direction:column}.rewrite-card .pane-body{display:flex;flex-direction:column;flex:1;min-height:0}.rewrite-intro{font-size:12px;color:#737b8d;margin-bottom:12px}.rewrite-area{width:100%;min-height:200px;border:1px solid #e2e4ed;border-radius:11px;outline:0;resize:none;padding:15px 16px;font:15px/1.8 Georgia,"Times New Roman",serif;color:#2f3443;background:#fff;transition:.15s}.rewrite-card .rewrite-area{flex:1}.rewrite-area:focus{border-color:#b9baff;box-shadow:0 0 0 3px rgba(86,84,245,.08)}.rewrite-meta{display:flex;justify-content:space-between;gap:10px;margin-top:8px;font-size:11px;color:#9aa0ad}.word-meta.warn{color:#b45309;font-weight:700}.translate-row{margin-top:10px;display:flex;align-items:center;gap:8px}.translate-row .btn.link{font-size:11.5px;font-weight:760;padding:0}.orig-translate-row{margin-top:9px;padding-top:8px;border-top:1px dashed #e8eaf3}.orig-translate-row .btn.link{font-size:11.5px;font-weight:760;padding:0}.orig-translate-box{margin-top:8px;background:#fafbff;border:1px solid #e8eaf6;border-radius:10px;padding:9px 10px}.ot-line{font-size:12.5px;line-height:1.75;color:#4c5468;padding:6px 9px;border-left:3px solid transparent;border-radius:6px;margin-bottom:5px;background:#fff}.ot-line:last-child{margin-bottom:0}.ot-line[data-note-id]{cursor:pointer}.ot-line sup{font-family:Inter,"PingFang SC",sans-serif;font-size:8px;font-weight:800;color:var(--note-strong,#514ee1);margin-right:3px}.ot-line.note-rhetoric,.ot-line.note-repeat,.ot-line.note-growth,.ot-line.note-structure{border-left-color:var(--note-line);background:var(--note-soft)}.ot-line.note-rhetoric.active,.ot-line.note-repeat.active,.ot-line.note-growth.active,.ot-line.note-structure.active{background:var(--note-bg);box-shadow:0 0 0 2px color-mix(in srgb,var(--note-line) 28%,transparent)}.translate-box{display:none;margin-top:8px;background:#f6f7ff;border:1px dashed #d9dcf5;border-radius:10px;padding:11px 12px;font-size:12px;color:#4c5468;line-height:1.75;white-space:pre-wrap}.translate-box.open{display:block}.translate-label{font-size:10px;color:#8b92a3;font-weight:800;margin-bottom:5px;display:block}.rewrite-help{margin-top:16px;border-top:1px solid var(--line);padding-top:13px}.help-entry{display:flex;align-items:center;gap:8px;font-size:11.5px;color:#777e8f}.help-entry .btn.link{font-size:11.5px;font-weight:760;padding:0}.help-panel{display:none;margin-top:10px;background:#fafbff;border:1px solid #e6e7f3;border-radius:10px;padding:11px 12px}.help-panel.open{display:block}.help-label{font-size:10px;color:#8b92a3;font-weight:800;margin-bottom:5px}.scaffold{font-size:12px;color:#50586a;line-height:1.7;white-space:pre-wrap}.reference-trigger{margin-top:9px;padding-top:8px;border-top:1px solid #eceef4}.reference{display:none;margin-top:8px;background:#fff;border:1px solid #e6e7ef;border-radius:8px;padding:9px 10px;font-size:11.5px;color:#596174;white-space:pre-wrap;line-height:1.7}.reference.open{display:block}.reference-note{font-size:10px;color:#9198a7;margin-top:6px}.write-tools{border-top:1px solid var(--line);padding:11px 14px;display:flex;justify-content:space-between;gap:10px;align-items:center}.nav-group{display:flex;gap:8px}.demo-hint{font-size:11px;color:#9a9fac}
.submit-row{position:fixed;left:0;right:0;bottom:0;background:linear-gradient(180deg,rgba(246,247,251,0),#f6f7fb 35%);padding:28px 24px 16px;display:flex;justify-content:flex-end;z-index:25;pointer-events:none}.submit-row .submit-inner{width:100%;max-width:1540px;margin:0 auto;display:flex;justify-content:flex-end;align-items:center;gap:12px;pointer-events:auto}.submit-hint{font-size:12px;color:#8a6270}.submit-row .btn{padding:11px 18px}.recheck-history{position:fixed;right:24px;bottom:82px;width:min(440px,calc(100vw - 32px));max-height:54vh;overflow-y:auto;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:0 16px 40px rgba(28,31,45,.18);padding:12px 13px;z-index:30;display:none}.recheck-history.open{display:block}.rhi-title{font-size:12px;font-weight:800;color:#3a4053;margin-bottom:8px}.recheck-history-item{border:1px solid #ecedf4;border-radius:10px;padding:9px 10px;margin-bottom:8px}.recheck-history-item:last-child{margin-bottom:0}.rhi-head{display:flex;align-items:center;gap:7px;font-size:11px;color:#666e80}.rhi-badge{font-size:10.5px;padding:2px 7px;border-radius:999px;font-weight:800}.rhi-badge.ok{background:var(--good-soft);color:#167655}.rhi-badge.warn{background:var(--warn-soft);color:#8a5d10}.rhi-latest{font-size:10px;color:var(--brand);font-weight:800}.rhi-meta{font-size:10.5px;color:#9aa0ad;margin-top:4px}.rhi-goal{font-size:11.5px;color:#4c5468;line-height:1.65;margin-top:5px}.rhi-revision{margin-top:7px}
.demo-panel{position:fixed;left:14px;bottom:14px;z-index:60}.demo-panel details{background:#fff;border:1px solid #dfe2ea;border-radius:10px;box-shadow:0 7px 20px rgba(23,27,42,.08);font-size:11px;color:#7d8495}.demo-panel summary{cursor:pointer;padding:7px 10px;font-weight:700;color:#666e80}.demo-panel .demo-body{padding:0 9px 9px;display:flex;gap:6px;flex-wrap:wrap;max-width:260px}
.loading-card{padding:20px 22px}.loading-title{font-size:16px;font-weight:760;margin-bottom:10px}.loading-row{display:flex;align-items:center;gap:9px;padding:7px 0;color:#687084;font-size:12px}.spinner{width:17px;height:17px;border:2px solid #dfe1ff;border-top-color:var(--brand);border-radius:50%;animation:spin .8s linear infinite}.loading-dot{width:8px;height:8px;border-radius:50%;background:#d7dbe6}.loading-row.active{color:#4247c7;font-weight:650}.loading-row.active .loading-dot{background:var(--brand)}.loading-hint{color:#b9770b;font-size:12px;font-weight:600;margin-top:8px}@keyframes spin{to{transform:rotate(360deg)}}
@media(max-width:1180px){.entry-shell{grid-template-columns:1fr 320px}.work-grid{gap:9px;padding-left:12px;padding-right:12px}.work-head{padding-left:14px;padding-right:14px}.pane-head{padding:0 12px}.progress-track{width:100px}.paragraph-switcher{max-width:min(280px,38vw)}}
@media(max-width:900px){.work-grid{grid-template-columns:1fr}.sticky-pane{position:relative;top:auto;max-height:none}.pane-card{min-height:auto}.rewrite-area{min-height:360px}.rewrite-card{height:auto}.rewrite-card .rewrite-area{flex:none}.submit-row{position:relative;padding:0 12px 20px}.work-head-inner{align-items:flex-start}.progress-mini{min-width:0}.demo-panel{display:none}}
@media(max-width:760px){.entry-shell{display:block;height:auto}.essay-pane{padding:24px 18px}.smart-pane{border-top:1px solid var(--line)}.smart-body{padding:24px 18px}.top-actions{display:none}.chat-wrap{padding:20px 12px 120px}.message{grid-template-columns:30px 1fr;gap:8px}.avatar{width:30px;height:30px}.bubble{padding:16px}.user .bubble{max-width:90%}.agent-table{font-size:11px}.work-grid{padding:12px}.work-head{padding:12px}.work-head-inner{display:block}.progress-mini{margin-top:8px;justify-content:flex-start}.progress-track{width:120px}.original-pane-head{height:auto;min-height:58px;flex-wrap:wrap;padding-top:9px;padding-bottom:9px}.paragraph-switcher{margin-left:0;max-width:100%}.pane-body{padding:14px}}
</style>
</head>
<body>
<div id="app">
<header class="topbar">
<div class="crumbs"><span>Student Desk</span><span class="slash">/</span><span>李小娟-Essay Writing</span><span class="slash">/</span><strong>Common App Essay</strong></div>
<div class="top-actions"><span class="chip">Common App · Prompt 1</span><span id="topSaveChip" class="chip good">Saved</span><span class="chip">Draft v1</span><span class="dotmenu">•••</span></div>
</header>
<section id="entryView" class="entry-shell">
<main class="essay-pane">
<div class="essay-inner">
<div class="essay-head-row">
<h1 class="essay-title" id="entryTitle">Beyond the Answer: The Beauty of the Struggle</h1>
<div class="essay-head-actions">
<button id="editEssayBtn" class="btn small">输入我的文书</button>
<button id="loadSampleBtn" class="btn small">载入示例文书</button>
</div>
</div>
<div id="versionSwitch" class="version-switch hidden"><span class="vs-label">当前查看</span><span id="versionBtns"></span></div>
<div id="essayView">
<div class="prompt-card" id="promptCard"></div>
<article class="essay-copy" id="entryEssay"></article>
<div class="word-badge" id="entryWords"></div>
</div>
<div id="essayEdit" class="hidden">
<div class="essay-edit-inputs">
<textarea id="essayEditor" placeholder="在此输入或粘贴你的文书全文,段落之间用空行分隔(会自动分段)。"></textarea>
<div class="essay-edit-row">
<input type="text" id="promptInput" placeholder="Essay Prompt(可留空,留空则跳过题目对应分析)">
<input type="number" id="wordLimitInput" placeholder="Word Limit(可留空)" min="0">
</div>
<div class="essay-edit-row">
<button id="useEssayBtn" class="btn primary small">使用这篇文章分析</button>
<button id="cancelEditBtn" class="btn small">取消</button>
</div>
</div>
</div>
</div>
</main>
<aside class="smart-pane">
<div class="smart-head">智能改稿</div>
<div class="smart-body">
<div class="smart-intro">
<h2>智能改稿</h2>
<p>AI 会先结合题目和整篇文书确认文章实际在表达什么,再分析明显的 AI 写作习惯,并指导你完成逐段人工改写。</p>
<div id="backendBanner" class="backend-banner"></div>
<button id="startAnalysis" class="btn primary">开始分析</button>
</div>
</div>
</aside>
</section>
<section id="agentView" class="agent-shell hidden">
<div class="agent-top"><button id="backToEssay" class="back">← 返回文书</button><span class="agent-title">智能改稿</span></div>
<div id="chat" class="chat-wrap"></div>
<div class="agent-input">
<div class="composer"><textarea id="chatInput" placeholder="可以直接告诉我哪一段理解不对,或补充你希望保留的表达……"></textarea><button id="sendChat"></button></div>
<div id="inputHint" class="input-hint hidden">AI 分析中,请稍候…</div>
</div>
</section>
<section id="workbenchView" class="workbench-shell hidden">
<div class="work-head">
<div class="work-head-inner">
<div>
<div class="work-back-row">
<button id="backToChat" class="back work-back">← 返回对话</button>
</div>
<div class="work-title">逐段人工改写</div>
<div class="work-sub">左侧先看“这段在讲什么 / 重写要做什么 / AI味要注意”,再结合与原文同色的详细批注顺手改;右侧只负责手写。数字按钮可直接切段,随时切全文看上下文。</div>
</div>
<div class="constraint-entry">
<button id="addConstraintBtn" class="btn small">补充改写要求</button>
<div id="constraintInputRow" class="constraint-input-row hidden">
<textarea id="constraintInput" placeholder="补充改写要求,例如:全篇语言更口语化;第三段保留原比喻…提交后重新诊断,各段提示同步更新。"></textarea>
<div class="row-actions">
<button id="constraintCancel" class="btn small">取消</button>
<button id="constraintSubmit" class="btn primary small">提交并重新诊断</button>
</div>
</div>
</div>
<div class="progress-mini"><div class="progress-track"><div id="progressBar"></div></div><span id="progressText" class="progress-txt">已有改写 0 段 · 共 0 段</span></div>
</div>
</div>
<div class="work-grid">
<section class="pane-card sticky-pane">
<div class="pane-head original-pane-head">
<div class="original-pane-title"><span>原文与改写提示</span></div>
<div id="paragraphSwitcher" class="paragraph-switcher" aria-label="手动切换段落"></div>
<div class="view-toggle" aria-label="原文查看模式">
<button id="focusOriginal" class="view-btn active">聚焦</button>
<button id="fullOriginal" class="view-btn">全文</button>
</div>
</div>
<div class="pane-body"><div id="originalFull"></div></div>
</section>
<section class="pane-card rewrite-card sticky-pane">
<div class="pane-head"><span id="rewriteHeading">第一段 · 我的改写</span><span id="writeStatus" class="chip">未改写</span></div>
<div class="pane-body">
<div class="rewrite-intro">基于左侧原意重新表达这一整段,不需要逐句对应原文。</div>
<textarea id="rewriteArea" class="rewrite-area" placeholder="在这里重写当前段……"></textarea>
<div class="rewrite-meta"><span id="saveMeta">自动保存</span><span id="wordMeta" class="word-meta"></span><span id="charCount">0 characters</span></div>
<div class="translate-row"><button id="copyOriginalBtn" class="btn link">复制原文到改写区 →</button><button id="translateBtn" class="btn link">译成中文对照 →</button></div>
<div id="translateBox" class="translate-box"></div>
<div class="rewrite-help">
<div class="help-entry"><span>需要一点帮助?</span><button id="showScaffold" class="btn link">给我一个写作起点 →</button></div>
<div id="scaffoldPanel" class="help-panel">
<div class="help-label">写作起点</div>
<div id="scaffoldBox" class="scaffold"></div>
<div class="reference-trigger"><button id="showReference" class="btn link">还是卡住?看一个参考片段 →</button></div>
<div id="referenceBox" class="reference"></div>
<div id="referenceNote" class="reference-note hidden">参考只用于理解改写动作,不需要沿用同一句式。不要把参考句当作可直接提交的答案。</div>
</div>
</div>
</div>
<div class="write-tools">
<div class="nav-group"><button id="prevPara" class="btn small">← 上一段</button><button id="nextPara" class="btn small">下一段 →</button></div>
<span class="demo-hint">上方数字可直接跳到任意段</span>
</div>
</section>
</div>
<div class="submit-row"><div class="submit-inner"><span id="submitHint" class="submit-hint hidden"></span><button id="recheckHistoryBtn" class="btn small hidden">复检历史 (0)</button><button id="submitRecheck" class="btn primary" disabled>完成全文改写,提交复检 →</button></div></div>
<div class="demo-panel"><details><summary>Demo 工具</summary><div class="demo-body"><button id="mockFillCurrent" class="btn small">填入当前段</button><button id="mockFillAll" class="btn small">填入模拟全文</button><button id="mockFillFail" class="btn small">填入返工案例</button></div></details></div>
</section>
<!-- 复检历史面板:必须挂在视图容器外 —— 放在 workbenchView 内时,
对话页(workbenchView display:none)点击「查看复检历史」面板不渲染(用户实测) -->
<div id="recheckHistoryPanel" class="recheck-history" aria-label="复检历史记录"></div>
</div>
<script>
const SAMPLE_PROMPT = 'Common App Prompt 1: Some students have a background, identity, interest, or talent that is so meaningful they believe their application would be incomplete without it. Share your story.';
const SAMPLE_TITLE = 'Beyond the Answer: The Beauty of the Struggle';
const SAMPLE_ESSAY = [
`The pencil in my hand was still. I sat alone at my desk, staring at a complex equation on a blank sheet of paper. The numbers and symbols spread across the page like a secret code. I wanted to find the answer. I believed there was only one correct answer hiding somewhere in those numbers, and my job was to dig it out. That is how I always thought about math. One problem, one solution, one truth.`,
`But one day, math did not feel so safe anymore. I met a problem that I could not solve right away. I tried every method I knew. I rewrote the equation again and again. I flipped through my notes and textbook. Nothing worked. The answer would not come. At first, I felt frustrated. I was angry at the problem, and I was angry at myself. I thought maybe I was not as good at math as I believed. The comfort I always found in numbers started to crack. For the first time, math was not giving me the certainty I needed. The problem just sat there on the paper, unyielding, staring back at me like it was asking me a question I did not know how to hear.`,
`I did not give up, though. I kept coming back to that problem, day after day. Slowly, something inside me began to change. I stopped looking only for the final answer. Instead, I started paying attention to the process. Each wrong attempt taught me something new. Each failure showed me a path that did not work, which helped me understand the problem better. I learned to be patient. I learned that not knowing the answer right away is not a weakness. It is part of learning. Before this, I thought loving math meant I was good at finding answers quickly. Now I understood that loving math meant I was willing to sit with a hard problem and struggle with it. The struggle was not the opposite of understanding. The struggle was the way to understanding. This changed how I saw not just math, but also myself. I became more resilient. I became more comfortable with uncertainty. I stopped needing everything to be perfect and started to appreciate the messy, difficult journey of figuring things out.`,
`Now, I sit at my desk again. The same pencil is in my hand. A complex equation waits on a blank sheet of paper. But I see it differently. The equation is not just a demand for a solution. It is an invitation. It invites me to think, to explore, to make mistakes, and to grow. Every line I write, even if it leads nowhere, adds something to my understanding. The blank page is not empty. It is full of possibility. Math is no longer just about getting the right answer for me. It is about the beautiful, ongoing process of learning. This love for math is the most meaningful part of who I am. It is the framework I use to face challenges, to keep going when things are hard, and to never stop growing. Without this part of me, my story would be incomplete.`
];
const CN_NUM = ['一','二','三','四','五','六','七','八','九','十','十一','十二','十三','十四','十五','十六','十七','十八','十九','二十'];
const CN_TO_N = {:1,:2,:3,:4,:5,:6,:7,:8,:9,:10};
const KIND_LABEL = {rhetoric:'修辞包装',repeat:'重复 / 工整',growth:'抽象 / 总结',structure:'结构 / 路标'};
const PATTERN_CATEGORY = {P01:'growth',P02:'growth',P03:'rhetoric',P04:'repeat',P05:'rhetoric',P06:'repeat',P07:'growth',P08:'structure',P09:'growth',P10:'growth',P11:'repeat',P12:'rhetoric'};
const SAVE_KEY = 'hvr_session_v11';
const SAVE_DEBOUNCE_MS = 700;
let essayText = SAMPLE_ESSAY.join('\n\n');
let promptText = SAMPLE_PROMPT;
let wordLimit = 650;
let paragraphs = SAMPLE_ESSAY.slice();
let analysis = null;
let diagnosis = null;
let agentStage = 'entry';
let globalConstraints = [];
let paragraphConstraints = {};
let current = 0;
let originalView = 'focus';
let revisionMode = false;
let revisionTarget = null;
let rewrites = [];
let rewriteStatus = [];
let saveStatusByPara = [];
let saveTimers = [];
let referencesShown = [];
let scaffoldsShown = [];
let currentRewriteVersion = 1;
let lastRecheckSnapshot = null;
let lastRecheckResult = null;
// 体验优化:复检汇总建议历史(最新在前,上限 10 条)——支持回看历次结论 +
// 按历史建议再次进入改写(结合该条建议修改)。
let recheckHistory = [];
// 体验优化:改写稿中文对照翻译缓存(按段下标)。rec.text 与当前改写稿不一致
// 时视为过期,需重新翻译(保证译文对应当前版本)。
let translations = [];
let translateOpen = [];
// 体验优化:原文中文对照(逐句)缓存(按段下标)。rec.sentences = [{en,zh}]
// 句子由前端拆分(en 是原文精确文本),命中批注 evidence 的句子在对照里
// 与原文同色 + 同序号,让「需要改写的那句」能直接找到中文。
let origTranslations = [];
let origTranslateOpen = [];
let analysisVersion = 1;
let helpState = {};
let dirtyDuringRecheck = false;
let transitioning = false;
// 对话历史(快照)持久化:刷新后回对话页不丢理解/诊断/复检卡片(用户实测发现 + PM 同意修复)。
// loading/过渡/错误卡为 transient:不记录(error 静默)或由 removeEl 同步删除。
let chatLog = [];
let chatLogSeq = 0;
// 复检通过后生成的成品版本(改写后全文),原版 = 版本 1viewedVersion=null 表示查看原版。
// 用户决定(2026-08-21):通过后 pass 卡提供【查看版本 N】按钮,点击才回文书页切换。
let essayVersions = [];
let viewedVersion = null;
const $ = s => document.querySelector(s);
const chat = $('#chat');
const sleep = ms => new Promise(r => setTimeout(r, ms));
function escapeHtml(s){return String(s||'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));}
function escapeRegExp(s){return String(s||'').replace(/[.*+?^${}()|[\]\\]/g,'\\$&');}
function show(view){['entryView','agentView','workbenchView'].forEach(id=>$('#'+id).classList.toggle('hidden',id!==view));window.scrollTo({top:0,behavior:'smooth'});}
function scrollChat(){setTimeout(()=>window.scrollTo({top:document.body.scrollHeight,behavior:'smooth'}),40)}
function addUser(text, silent){const m=document.createElement('div');m.className='message user';m.innerHTML=`<div class="bubble"><p>${escapeHtml(text)}</p></div>`;chat.appendChild(m);if(!silent){m._logId=++chatLogSeq;chatLog.push({id:m._logId,t:'user',text});}scrollChat();}
function addAgent(html, silent){const m=document.createElement('div');m.className='message';m.innerHTML=`<div class="avatar">AI</div><div class="bubble">${html}</div>`;chat.appendChild(m);if(!silent){m._logId=++chatLogSeq;chatLog.push({id:m._logId,t:'msg',html});}scrollChat();return m;}
function addLoadingCard(title, rows){
const list = (rows||['真实模型生成中,通常需要 1–2 分钟']).map((t,i)=>`<div class="loading-row${i===0?' active':''}"><span class="loading-dot"></span>${escapeHtml(t)}</div>`).join('');
const el = addAgent(`<div class="loading-card"><div class="loading-title"><span class="spinner" style="display:inline-block;vertical-align:-3px;margin-right:8px"></span>${escapeHtml(title)}</div>${list}</div>`);
// 真实模型生成可能超过 1 分钟(同事实测 diagnose 卡 5 分钟误以为死机):
// 45s / 120s 时在卡内追加进度提示,避免"一直在转但没有反馈"
const hint = document.createElement('div');
hint.className = 'loading-hint';
hint.style.display = 'none';
const card = el.querySelector('.loading-card');
if(card) card.appendChild(hint);
const showHint = (msg) => { if(!document.body.contains(el)) return; hint.textContent = msg; hint.style.display = ''; };
setTimeout(()=>showHint('仍在生成中,首次通常需要 1–3 分钟…'), 45000);
setTimeout(()=>showHint('已超过 2 分钟,如长时间无响应可稍后重试。'), 120000);
return el;
}
function removeEl(el){
// transient 卡片(loading/过渡提示/错误重试)从 DOM 移除时同步从历史快照删除,避免刷新后"复活"
if(el && el._logId) chatLog = chatLog.filter(r=>r.id!==el._logId);
if(el && el.parentNode) el.parentNode.removeChild(el);
}
function track(name, payload){ try{ console.debug('[hvr]', name, payload||{}); }catch(e){} }
function formatApiError(err){
if(!err) return '请求失败';
if(typeof err==='string') return err;
if(Array.isArray(err)) return err.map(x=>x.msg||JSON.stringify(x)).join('');
if(typeof err==='object' && err.msg) return err.msg;
return JSON.stringify(err);
}
function backendOfflineMessage(){
if(location.protocol==='file:'){
return '当前是直接打开的 HTML 文件,后端接口不可用。请在项目目录运行 ./local_start.sh,然后打开 http://127.0.0.1:8000';
}
return `连不上本地后端(${location.origin})。请先在项目目录运行 ./local_start.sh,再用浏览器打开 http://127.0.0.1:8000,不要用 Live Preview / 直接双击 HTML。`;
}
async function postJSON(url, body){
let res;
try{
res = await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
}catch(e){
throw new Error(backendOfflineMessage());
}
if(!res.ok){
const err = await res.json().catch(()=>({detail:res.statusText}));
throw new Error(formatApiError(err.detail)||`请求失败(HTTP ${res.status}`);
}
return res.json();
}
async function checkBackend(){
const banner = $('#backendBanner');
const btn = $('#startAnalysis');
if(!banner||!btn) return false;
try{
const res = await fetch('/api/health',{cache:'no-store'});
if(!res.ok) throw new Error('health');
banner.classList.remove('show');
banner.textContent = '';
btn.disabled = !essayText.trim();
return true;
}catch(e){
banner.textContent = backendOfflineMessage();
banner.classList.add('show');
btn.disabled = true;
return false;
}
}
function renderError(msg, retryFn){
// silent:错误卡是异常态,retry 绑定的是闭包无法序列化,不恢复(刷新即摆脱失败态)
const m = addAgent(`<div class="eyebrow">出错了</div><h1>分析生成失败,请重新分析</h1><p class="muted">${escapeHtml(msg)}</p><p class="muted">没有生成错误内容。你可以重试,或返回文书页检查输入。</p><div class="quick-actions"><button class="quick primary" data-action="retry">重试</button><button class="quick ghost" data-action="back-entry">返回文书页</button></div>`, true);
m.querySelector('[data-action=retry]').onclick = () => { removeEl(m); retryFn(); };
m.querySelector('[data-action=back-entry]').onclick = () => show('entryView');
}
function wordCount(t){return String(t||'').trim().split(/\s+/).filter(Boolean).length;}
function splitParagraphs(text){
let parts = String(text||'').split(/\n\s*\n/).map(s=>s.trim()).filter(Boolean);
if(parts.length<=1) parts = String(text||'').split(/\n/).map(s=>s.trim()).filter(Boolean);
return parts.length ? parts : [String(text||'').trim()].filter(Boolean);
}
function pidOf(i){return 'p'+(i+1);}
function paragraphLabel(i){return `第${CN_NUM[i]||(i+1)}段`;}
function hasRewrite(i){return String(rewrites[i]||'').trim().length>0;}
function rewrittenCount(){return rewrites.reduce((n,_,i)=>n+(hasRewrite(i)?1:0),0);}
function ensureParaState(){
const n = paragraphs.length;
while(rewrites.length<n) rewrites.push('');
while(rewriteStatus.length<n) rewriteStatus.push('UNTOUCHED');
while(saveStatusByPara.length<n) saveStatusByPara.push('idle');
while(saveTimers.length<n) saveTimers.push(null);
rewrites = rewrites.slice(0,n);
rewriteStatus = rewriteStatus.slice(0,n);
saveStatusByPara = saveStatusByPara.slice(0,n);
saveTimers = saveTimers.slice(0,n);
while(translations.length<n) translations.push(null);
while(translateOpen.length<n) translateOpen.push(false);
translations = translations.slice(0,n);
translateOpen = translateOpen.slice(0,n);
while(origTranslations.length<n) origTranslations.push(null);
while(origTranslateOpen.length<n) origTranslateOpen.push(false);
origTranslations = origTranslations.slice(0,n);
origTranslateOpen = origTranslateOpen.slice(0,n);
}
function briefFor(pid){return (diagnosis&&diagnosis.paragraph_briefs||[]).find(b=>b.paragraph_id===pid)||null;}
function patternsFor(pid){return (diagnosis&&diagnosis.patterns||[]).filter(p=>(p.affected_paragraphs||[]).includes(pid));}
function meaningOf(i){
const brief = briefFor(pidOf(i));
if(brief&&brief.confirmed_meaning) return brief.confirmed_meaning;
const p = analysis&&analysis.paragraphs&&analysis.paragraphs[i];
return (p&&(p.semantic_anchor||p.natural_meaning_zh))||'';
}
function parseParagraphIndex(text){
const m = String(text||'').match(/第([一二三四五六七八九十\d]+)段|p\s*(\d+)/i);
if(!m) return null;
const n = m[2] ? parseInt(m[2],10) : (CN_TO_N[m[1]] || parseInt(m[1],10));
if(!Number.isFinite(n)||n<1||n>paragraphs.length) return null;
return n-1;
}
function evidenceInText(ev, text){
if(!ev||!text) return false;
const re = new RegExp(escapeRegExp(ev).replace(/\s+/g,'\\s+'), 'i');
return re.test(text);
}
function renderPromptCard(){
if(!promptText){ $('#promptCard').innerHTML = '<strong>未提供 Essay Prompt</strong><span class="prompt-cn">本轮仍可做人味分析,将隐藏题目对应模块。</span>'; return; }
const limit = wordLimit ? ` · ${wordLimit} words max` : '';
$('#promptCard').innerHTML = `<strong>${escapeHtml(promptText)}</strong>${escapeHtml(limit)}`;
}
function renderEntryEssay(){
// 复检通过后文书页可切换「原版 / 版本 N」(viewedVersion 指定;null = 原版)
const v = essayVersions.find(x=>x.version===viewedVersion);
const text = v ? v.text : essayText;
$('#entryTitle').textContent = essayText === SAMPLE_ESSAY.join('\n\n') ? SAMPLE_TITLE : '我的文书';
$('#entryEssay').innerHTML = splitParagraphs(text).map(p=>`<p>${escapeHtml(p)}</p>`).join('');
const words = wordCount(text);
$('#entryWords').innerHTML = wordLimit
? `${words} / ${wordLimit} Words &nbsp;&nbsp;·&nbsp;&nbsp; ${text.length} Characters`
: `${words} Words &nbsp;&nbsp;·&nbsp;&nbsp; ${text.length} Characters`;
$('#startAnalysis').disabled = !essayText.trim();
renderPromptCard();
renderVersionSwitch();
}
function renderVersionSwitch(){
const wrap = $('#versionSwitch');
const box = $('#versionBtns');
if(!essayVersions.length){ wrap.classList.add('hidden'); return; }
wrap.classList.remove('hidden');
box.innerHTML = '';
const mk = (ver, label) => {
const b = document.createElement('button');
const active = (ver===1 && viewedVersion===null) || (ver>1 && viewedVersion===ver);
b.className = 'vs-btn' + (active ? ' active' : '');
b.dataset.ver = String(ver);
b.textContent = label;
b.onclick = () => {
viewedVersion = ver===1 ? null : ver;
persistNow();
renderEntryEssay();
};
box.appendChild(b);
};
mk(1, '原版');
essayVersions.forEach(x=>mk(x.version, `版本 ${x.version}`));
}
function openEditor(){
$('#essayEditor').value = essayText;
$('#promptInput').value = promptText;
$('#wordLimitInput').value = wordLimit || '';
$('#essayView').classList.add('hidden');
$('#essayEdit').classList.remove('hidden');
}
function closeEditor(){
$('#essayView').classList.remove('hidden');
$('#essayEdit').classList.add('hidden');
}
function applyEditedEssay(){
essayText = $('#essayEditor').value.trim();
promptText = $('#promptInput').value.trim();
const wl = parseInt($('#wordLimitInput').value, 10);
wordLimit = Number.isFinite(wl) && wl > 0 ? wl : null;
paragraphs = splitParagraphs(essayText);
// 文书内容变化后,旧版本(改写后全文)失去参照,清空版本切换与复检历史/译文
essayVersions = []; viewedVersion = null;
recheckHistory = []; translations = []; translateOpen = []; origTranslations = []; origTranslateOpen = [];
ensureParaState();
closeEditor();
renderEntryEssay();
persistNow();
}
function loadSample(){
essayText = SAMPLE_ESSAY.join('\n\n');
promptText = SAMPLE_PROMPT;
wordLimit = 650;
paragraphs = SAMPLE_ESSAY.slice();
essayVersions = []; viewedVersion = null;
recheckHistory = []; translations = []; translateOpen = []; origTranslations = []; origTranslateOpen = [];
ensureParaState();
closeEditor();
renderEntryEssay();
persistNow();
}
function persistNow(){
try{
localStorage.setItem(SAVE_KEY, JSON.stringify({
essayText, promptText, wordLimit, paragraphs, analysis, diagnosis,
globalConstraints, paragraphConstraints, rewrites, rewriteStatus,
current, originalView, revisionMode, revisionTarget,
referencesShown, scaffoldsShown, currentRewriteVersion,
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen,
origTranslations, origTranslateOpen
}));
return true;
}catch(e){ return false; }
}
function scheduleSave(i){
saveStatusByPara[i] = 'saving';
renderSaveChip();
clearTimeout(saveTimers[i]);
saveTimers[i] = setTimeout(()=>flushSave(i), SAVE_DEBOUNCE_MS);
}
function flushSave(i){
clearTimeout(saveTimers[i]);
saveTimers[i] = null;
const area = $('#rewriteArea');
if(area && i===current) rewrites[i] = area.value;
saveStatusByPara[i] = 'saving';
renderSaveChip();
const ok = persistNow();
saveStatusByPara[i] = ok ? 'saved' : 'failed';
if(!ok) track('rewrite_save_failed', {paragraph_id: pidOf(i)});
if(hasRewrite(i)) rewriteStatus[i] = 'REWRITTEN';
else rewriteStatus[i] = 'UNTOUCHED';
renderSaveChip();
persistNow();
return ok;
}
function restoreSession(){
try{
const raw = localStorage.getItem(SAVE_KEY);
if(!raw) return null;
const s = JSON.parse(raw);
if(!s||!Array.isArray(s.paragraphs)||!Array.isArray(s.rewrites)) return null;
essayText = s.essayText || essayText;
promptText = s.promptText || promptText;
wordLimit = s.wordLimit ?? wordLimit;
paragraphs = s.paragraphs;
rewrites = s.rewrites.slice(0, paragraphs.length);
rewriteStatus = (s.rewriteStatus||[]).slice(0, paragraphs.length);
analysis = s.analysis || null;
diagnosis = s.diagnosis || null;
globalConstraints = s.globalConstraints || [];
paragraphConstraints = s.paragraphConstraints || {};
current = Number.isFinite(s.current) ? s.current : 0;
originalView = s.originalView === 'full' ? 'full' : 'focus';
revisionMode = !!s.revisionMode;
revisionTarget = s.revisionTarget || null;
referencesShown = s.referencesShown || [];
scaffoldsShown = s.scaffoldsShown || [];
currentRewriteVersion = s.currentRewriteVersion || 1;
lastRecheckSnapshot = s.lastRecheckSnapshot || null;
lastRecheckResult = s.lastRecheckResult || null;
analysisVersion = s.analysisVersion || 1;
chatLog = Array.isArray(s.chatLog) ? s.chatLog : [];
chatLogSeq = chatLog.length ? Math.max(...chatLog.map(r=>r.id||0)) : 0;
essayVersions = Array.isArray(s.essayVersions) ? s.essayVersions : [];
viewedVersion = essayVersions.some(v=>v.version===s.viewedVersion) ? s.viewedVersion : null;
recheckHistory = Array.isArray(s.recheckHistory) ? s.recheckHistory.slice(0,10) : [];
translations = Array.isArray(s.translations) ? s.translations : [];
translateOpen = Array.isArray(s.translateOpen) ? s.translateOpen : [];
origTranslations = Array.isArray(s.origTranslations) ? s.origTranslations : [];
origTranslateOpen = Array.isArray(s.origTranslateOpen) ? s.origTranslateOpen : [];
ensureParaState();
if(essayText !== SAMPLE_ESSAY.join('\n\n')) openEditor();
else renderEntryEssay();
// 回写验收点「刷新后已保存的改写与进度恢复」:改写态刷新后回到 Workbench,
// 数据不丢;「刷新后回到对话页」:理解/诊断/复检结果态刷新后回对话页,历史卡由 chatLog
// 完整重建、按钮置灰按当前轮次重应用(验收点 #28/#40;修复前对话页刷新会掉回文书页)。
// 复检结果已返回(recheck:pass 卡/返工卡在对话页)刷新留在对话页(用户实测 2026-08-21
// 修复前会跳到改写页);复检请求进行中(recheckloading)无法续跑,回 Workbench 可重新
// 提交复检。分析请求进行中(loading)与失败后的 idle(错误卡不持久化,无内容可恢复)
// 按回写 §6 回进入页重新开始
if(s.agentStage==='workbench'||s.agentStage==='recheckloading'){
agentStage='workbench';
restoreChatHistory();
show('workbenchView');
renderWorkbench();
}else if(s.agentStage==='understanding'||s.agentStage==='diagnosis'||s.agentStage==='diagloading'||s.agentStage==='recheck'){
// 对话页各态必须恢复对应 agentStage:置灰规则与发送分支都按 stage 判断
// (修复前不恢复 = entry 态:置灰错位,且理解纠偏/诊断重生成发送全部失效)
agentStage = s.agentStage === 'diagloading' ? 'diagnosis' : s.agentStage; // 诊断请求无法续跑,落在诊断态
restoreChatHistory();
show('agentView');
}
return s;
}catch(e){ return null; }
}
function restoreChatHistory(){
renderRecheckHistory();
if(!chatLog || !chatLog.length) return;
chat.innerHTML = '';
// silent 重放:只建 DOM 不重复入日志;按钮由 bindQuickActions 统一重绑,
// 置灰规则由 disableCompletedActions 按当前 agentStage 重新应用
chatLog.forEach(rec=>{
if(rec.t==='user') addUser(rec.text, true);
else if(rec.t==='msg') addAgent(rec.html, true);
});
bindQuickActions();
disableCompletedActions();
}
function renderUnderstanding(){
const a = analysis;
const rows = (a.paragraphs||[]).map((p,i)=>`<tr><td>${escapeHtml(paragraphLabel(i))}</td><td>${escapeHtml(p.natural_meaning_zh)}</td></tr>`).join('');
const align = a.prompt_alignment || {};
const showAlign = !!(promptText && (align.prompt_intent||align.current_alignment));
const html = `
<div class="eyebrow">AI 初审</div>
<h1>我先确认一下,对这篇文章的理解有没有偏差</h1>
<div class="status-line"><span class="pill">${escapeHtml(promptText ? promptText.split(':')[0] : '无 Prompt')}</span><span class="pill">${wordCount(essayText)} words</span></div>
<h2>整篇我理解的是</h2>
<div class="summary">${escapeHtml(a.essay_summary)}</div>
${showAlign?`
<h2>和题目的对应</h2>
${align.prompt_intent?`<p><strong>题目期望:</strong>${escapeHtml(align.prompt_intent)}</p>`:''}
${align.current_alignment?`<p><strong>当前贴合:</strong>${escapeHtml(align.current_alignment)}</p>`:''}
${align.optional_opportunity?`<div class="inline-note">${escapeHtml(align.optional_opportunity)} <strong>这是 Optional,不阻塞本轮人味改写。</strong></div>`:''}`:''}
<h2>${(a.paragraphs||[]).length} 段我分别理解为</h2>
<table class="agent-table"><thead><tr><th style="width:90px">段落</th><th>我理解这段主要在说什么</th></tr></thead><tbody>${rows}</tbody></table>
<p class="muted">这是我目前对文章的理解。有没有哪一段和你的理解不一样?可以直接在下方输入框告诉我。</p>
<div class="quick-actions"><button class="quick primary" data-action="confirm-understanding">理解准确,继续分析</button><button class="quick ghost" data-action="adjust-understanding">有一段理解不准确</button><button class="quick ghost" data-action="prompt-add">补充我的理解</button></div>`;
addAgent(html); bindQuickActions();
}
async function runAnalyze(){
paragraphs = splitParagraphs(essayText);
if(!paragraphs[0] || !essayText.trim()){alert('文本为空,无法开始分析');return;}
if(agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading') return;
if(wordCount(essayText)<40 && !confirm('当前内容较短,可能不足以进行稳定的全文分析。仍要继续?')) return;
// PM 已确认:重新分析一律清空已有改写(原 sameEssay 保留逻辑移除)
rewrites = paragraphs.map(()=>'');
rewriteStatus = paragraphs.map((_,i)=>hasRewrite(i)?'REWRITTEN':'UNTOUCHED');
saveStatusByPara = paragraphs.map(()=>'idle');
current = 0; originalView = 'focus'; revisionMode = false; revisionTarget = null;
globalConstraints = []; paragraphConstraints = {}; referencesShown = []; scaffoldsShown = [];
analysis = null; diagnosis = null; lastRecheckResult = null; lastRecheckSnapshot = null;
currentRewriteVersion = 1; analysisVersion += 1; helpState = {};
viewedVersion = null; // 新一轮以原版为工作底稿(已完成的版本 N 仍保留可切换)
ensureParaState();
track('human_voice_analysis_start', {paragraphs: paragraphs.length});
show('agentView'); chat.innerHTML=''; chatLog=[]; agentStage='loading';
// 统一走 addLoadingCard:带 45s/120s 超时提示(真实模型生成可能超过 1 分钟)
const loading = addLoadingCard('正在读懂这篇文书…', ['正在结合题目理解全文','正在确认每一段实际表达的内容','正在整理需要与你确认的理解']);
const rows = loading.querySelectorAll('.loading-row');
const t1 = setTimeout(()=>{rows[0].classList.remove('active');rows[1].classList.add('active')},650);
const t2 = setTimeout(()=>{rows[1].classList.remove('active');rows[2].classList.add('active')},1300);
try{
const [resp] = await Promise.all([
postJSON('/api/analyze',{prompt:promptText, word_limit:wordLimit, paragraphs, constraints:[]}),
sleep(900)
]);
clearTimeout(t1);clearTimeout(t2);
removeEl(loading);
analysis = resp;
agentStage='understanding';
persistNow();
renderUnderstanding();
}catch(e){
clearTimeout(t1);clearTimeout(t2);
removeEl(loading);
agentStage='idle';
renderError(e.message, runAnalyze);
}
}
async function requestDiagnosis(){
const resp = await postJSON('/api/diagnose',{
prompt:promptText, word_limit:wordLimit, paragraphs,
confirmed_anchors: (analysis.paragraphs||[]).map(p=>p.semantic_anchor||p.natural_meaning_zh||''),
global_constraints: globalConstraints,
paragraph_constraints: paragraphConstraints,
initial_analysis: analysis
});
return resp;
}
async function runDiagnose(opts){
// 防重入:诊断请求进行中忽略重复触发(用户实测连点【理解准确,继续分析】——
// 修复前两次并发请求会各渲染一张诊断卡并白花一次模型调用,见 Deviations #23
if(agentStage==='diagloading') return;
const regen = !!(opts && opts.regen);
agentStage = 'diagloading';
const loading = regen
? addLoadingCard('正在根据你的补充重新诊断…', ['正在合并你新确认的要求','正在重新生成各段批注与改写方向'])
: addLoadingCard('正在分析明显的 AI 写作习惯…', ['正在对照已确认的语义锚点','正在识别实际存在的生成式写作模式','正在整理各段改写方向']);
try{
const resp = await requestDiagnosis();
removeEl(loading);
diagnosis = resp;
agentStage='diagnosis';
persistNow();
renderDiagnosis();
}catch(e){
removeEl(loading);
// 重诊断失败时约束已记入,保留 diagnosis 状态可再次重试
agentStage = regen ? 'diagnosis' : 'understanding';
renderError(e.message, ()=>runDiagnose(opts));
}
}
function renderDiagnosis(){
const d = diagnosis;
const patRows = (d.patterns||[]).map(p=>{
const where = [...(p.affected_paragraphs||[]).map(id=>escapeHtml(String(id).toUpperCase())), ...(p.evidence||[]).map(e=>`<code>${escapeHtml(e)}</code>`)].join('<br>');
return `<tr><td>${escapeHtml(p.name||p.pattern_id)}</td><td>${where}</td><td>${escapeHtml(p.why_ai_like)}</td><td><strong>${escapeHtml(p.transformation_rule)}</strong>${p.human_impact?`<br><span class="muted">${escapeHtml(p.human_impact)}</span>`:''}</td></tr>`;
}).join('');
const briefRows = (d.paragraph_briefs||[]).map(b=>`<tr><td>${escapeHtml(String(b.paragraph_id||'').toUpperCase())}</td><td>${escapeHtml(b.ai_focus||'')}</td><td>${escapeHtml(b.rewrite_goal||'')}</td></tr>`).join('');
const cons = [...globalConstraints, ...Object.entries(paragraphConstraints).flatMap(([pid,arr])=>(arr||[]).map(x=>`${pid.toUpperCase()}${x}`))];
const consHtml = cons.length?`<div class="constraint-box"><strong>已记录你的额外要求(会直接融进各段改写提示):</strong><br>${cons.map(x=>'• '+escapeHtml(x)).join('<br>')}</div>`:'';
const html = `
<div class="eyebrow">Human Voice Diagnosis</div>
<h1>好,下面只看这一轮“怎么把 AI 味改掉”</h1>
<p>${escapeHtml(d.overall_diagnosis)}</p>
<h2>主要命中的 AI 写作习惯</h2>
<table class="agent-table"><thead><tr><th style="width:17%">AI 写作习惯</th><th style="width:24%">本文表现</th><th style="width:30%">为什么像 AI</th><th>人工怎么改</th></tr></thead><tbody>${patRows||'<tr><td colspan="4" class="muted">本轮未命中明显 Pattern</td></tr>'}</tbody></table>
<h2>各段重写重点</h2>
<table class="agent-table"><thead><tr><th style="width:70px">段落</th><th>当前主要问题</th><th>本轮重写重点</th></tr></thead><tbody>${briefRows}</tbody></table>
${d.optional_suggestion?`<h2>如果想再进一层(可选)</h2><p>${escapeHtml(d.optional_suggestion)}</p>`:''}
${consHtml}
<p class="muted">以上 ${paragraphs.length} 段的改写方向是否准确?有没有某些表达必须保留,或者学生本身有明确语言风格?确认以后进入逐段改写。</p>
<div class="quick-actions"><button class="quick primary" data-action="start-rewrite">确认方向,进入逐段改写 →</button><button class="quick ghost" data-action="prompt-add-regen">补充改写要求</button></div>`;
addAgent(html); bindQuickActions(); disableCompletedActions();
}
function bindQuickActions(){chat.querySelectorAll('[data-action]').forEach(btn=>btn.onclick=()=>handleAction(btn.dataset.action));}
function disableCompletedActions(){
// PM 已确认:已完成步骤的按钮置灰;【继续分析】保留可点(重复点击 = 重新生成诊断,G3 依赖)
const stage = agentStage;
chat.querySelectorAll('[data-action]').forEach(btn=>{
const a = btn.dataset.action;
let disable = false;
if(a==='start-rewrite') disable = stage==='workbench'||stage==='recheck'||stage==='recheckloading';
else if(a==='adjust-understanding'||a==='prompt-add') disable = stage==='diagnosis'||stage==='workbench'||stage==='recheck'||stage==='recheckloading';
// 诊断卡的【补充改写要求】(当前轮可点,供补充约束→重新诊断);
// 离开诊断轮次(进 Workbench 或复检)后才是"已完成步骤"→ 置灰。
// 注意与理解卡的 prompt-add(补充我的理解)拆开:两者曾共用 action 导致当前轮被误灰(用户实测)
else if(a==='prompt-add-regen') disable = stage==='workbench'||stage==='recheck'||stage==='recheckloading';
// return-(返工):仅当当前存在返工目标且该按钮就是目标段时才可点(当前返工卡);
// 复检通过后(revisionTarget=null)或旧返工卡(目标段已换)→ 置灰。
// T6 初期曾全灰导致返工路径不可用,本轮收紧为按 revisionTarget 判定(用户实测 2
else if(a.startsWith('return-')) disable = !revisionTarget || String(revisionTarget.paragraph_id||'').toLowerCase() !== a.slice(7).toLowerCase();
btn.disabled = disable;
});
}
function handleAction(action){
// 复检卡上的「查看复检历史」:两处(通过卡/返工卡)共用,面板 fixed 定位,
// 对话页与 Workbench 都能显示。
if(action==='view-recheck-history'){ renderRecheckHistory(); toggleRecheckHistory(); return; }
if(action==='confirm-understanding'){
// 防重入:诊断请求进行中忽略连点(用户实测连点——修复前两次并发请求 +
// 两个确认气泡 + 两张诊断卡,见 Deviations #23);runDiagnose 内另有同守卫兜底
if(agentStage==='diagloading') return;
addUser('理解准确,继续。');track('understanding_confirmed');runDiagnose();
}
if(action==='adjust-understanding'){const input=$('#chatInput');input.placeholder='例如:第三段重点不是接受不确定,而是更关注推理过程……';input.focus();}
// 每个按钮给出自己的占位提示再聚焦(只 focus 会把其它按钮的提示残留下来——用户实测):
// 补充我的理解 → 通用纠偏提示;补充改写要求 → 改写要求示例(与 Workbench 补充改写要求输入框的提示一致)
if(action==='prompt-add'){const input=$('#chatInput');input.placeholder='可以直接告诉我哪一段理解不对,或补充你希望保留的表达……';input.focus();}
if(action==='prompt-add-regen'){const input=$('#chatInput');input.placeholder='补充改写要求,例如:全篇语言更口语化;第三段保留原比喻…提交后重新诊断,各段提示同步更新。';input.focus();}
if(action==='start-rewrite'){
if(transitioning) return;
transitioning = true;
addUser('方向没问题,开始改写。');
track('diagnosis_confirmed');track('rewrite_started');
const tip = addAgent(`<div class="loading-card"><div class="loading-title"><span class="spinner" style="display:inline-block;vertical-align:-3px;margin-right:8px"></span>方向已确认,正在准备逐段改写面板…</div></div>`);
setTimeout(()=>{ removeEl(tip); transitioning = false; enterWorkbench(); }, 800);
}
if(action && action.startsWith('return-')){
const pid=action.slice(7);
enterRevision(pid);
}
// pass 卡【查看版本 N】:回文书页查看改写后全文(版本切换,用户决定 2026-08-21
if(action==='view-version2'){
flushSave(current);
agentStage = 'entry';
persistNow();
show('entryView');
renderEntryEssay();
}
}
function applyUnderstandingCorrection(text){
const idx = parseParagraphIndex(text);
if(idx===null || !analysis || !analysis.paragraphs[idx]){
globalConstraints.push(text);
return {scope:'global', label:'文章理解的补充'};
}
analysis.paragraphs[idx].natural_meaning_zh = text;
analysis.paragraphs[idx].semantic_anchor = text;
const pid = pidOf(idx);
(paragraphConstraints[pid]=paragraphConstraints[pid]||[]).push(text);
return {scope:pid, label:`${paragraphLabel(idx)} 的理解`};
}
function applyDiagnosisConstraint(text){
const raw = text.trim();
const idx = parseParagraphIndex(raw);
if(/secret code/i.test(raw)){
(paragraphConstraints.p1=paragraphConstraints.p1||[]);
if(!paragraphConstraints.p1.includes('P1 保留 secret code')) paragraphConstraints.p1.push('P1 保留 secret code');
return 'P1 保留 secret code';
}
if(/直接|克制|不要太文学|不太文学/.test(raw)){
const c='整体语言更直接、克制';
if(!globalConstraints.includes(c)) globalConstraints.push(c);
return c;
}
if(idx!==null){
const pid=pidOf(idx);
(paragraphConstraints[pid]=paragraphConstraints[pid]||[]);
if(!paragraphConstraints[pid].includes(raw)) paragraphConstraints[pid].push(raw);
return `${paragraphLabel(idx)}${raw}`;
}
if(!globalConstraints.includes(raw)) globalConstraints.push(raw);
return raw;
}
function showInputHint(){
const hint=$('#inputHint');
if(!hint)return;
hint.classList.remove('hidden');
clearTimeout(hint._t);
hint._t=setTimeout(()=>hint.classList.add('hidden'),3000);
}
function processChat(text){
if(!text.trim())return;
if(agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){
showInputHint(); // 分析中:输入框内显示红色提示,已输入内容保留不清除
return;
}
addUser(text.trim());$('#chatInput').value='';
if(agentStage==='understanding'){
const applied = applyUnderstandingCorrection(text.trim());
track('understanding_corrected', applied);
persistNow();
addAgent(`<p>明白,我把这点记作<strong>${escapeHtml(applied.label)}</strong>。后面的 Diagnosis / Rewrite Guidance / Recheck 都会以你确认后的版本为准。</p><div class="summary">${escapeHtml(text.trim())}</div><p>如果各段整体理解没有其它问题,可以继续做人味分析。</p><div class="quick-actions"><button class="quick primary" data-action="confirm-understanding">确认,继续分析 →</button></div>`);
bindQuickActions();
}else if(agentStage==='diagnosis'){
const normalized = applyDiagnosisConstraint(text.trim());
persistNow();
track('diagnosis_re_generated', {constraint: normalized});
addAgent(`<p>收到,已记入:</p><div class="constraint-box">${escapeHtml(normalized)}</div><p>正在按新要求重新生成诊断,批注与各段提示将同步更新…</p>`);
runDiagnose({regen:true});
}else if(agentStage==='recheck'){
addAgent(`<p>复检结论已经给出。你可以返回对应段落继续修改后再次提交全文复检。</p>`);
}else{
addAgent(`<p>请先点击「开始分析」,或返回文书页。</p>`);
}
}
function fusedAiFocus(i){
const brief = briefFor(pidOf(i));
let focus = (brief&&brief.ai_focus)||'';
const pc = paragraphConstraints[pidOf(i)]||[];
if(pc.some(x=>/secret code/i.test(x)) && i===0) focus += ' 你已确认保留 secret code,因此其它同义比喻优先收掉,不再把这一处本身作为删除目标。';
else if(pc.length) focus += ' 同时遵守你对该段的补充:'+pc.join('')+'。';
if(globalConstraints.some(x=>/直接|克制/.test(x))) focus += ' 同时按你确认的风格,整段尽量直接、克制,不主动增加新的文学化修辞。';
else if(globalConstraints.length) focus += ' 同时遵守你确认的全文要求:'+globalConstraints.join('')+'。';
return focus;
}
function fusedGoal(i){
const brief = briefFor(pidOf(i));
return (brief&&brief.rewrite_goal)||'基于已确认原意重写这一段,打破当前最明显的生成式写法。';
}
function annotationsFor(i){
const text = paragraphs[i]||'';
const brief = briefFor(pidOf(i));
let notes = (brief&&brief.annotations||[]).filter(n=>(n.evidence||[]).some(ev=>evidenceInText(ev,text)));
if(notes.length) return notes.slice(0,3).map((n,idx)=>({
...n,
category: KIND_LABEL[n.category] ? n.category : (PATTERN_CATEGORY[n.pattern_id]||'rhetoric'),
kind_label: n.kind_label || KIND_LABEL[n.category] || '修辞包装',
id: n.annotation_id || String(idx)
}));
return patternsFor(pidOf(i)).slice(0,3).map((p,idx)=>({
pattern_id:p.pattern_id, category:p.category||PATTERN_CATEGORY[p.pattern_id]||'rhetoric',
kind_label:KIND_LABEL[p.category||PATTERN_CATEGORY[p.pattern_id]]||'修辞包装',
title:p.name||p.pattern_id, evidence:(p.evidence||[]).filter(ev=>evidenceInText(ev,text)),
observation:p.human_impact||'', rewrite_action:p.transformation_rule||'', why_ai_like:p.why_ai_like||'',
id:String(idx)
})).filter(n=>n.evidence.length);
}
function contextHintOf(i){
const brief = briefFor(pidOf(i));
if(brief&&brief.context_hint) return brief.context_hint;
const prev = i>0 ? meaningOf(i-1) : '';
const next = i<paragraphs.length-1 ? meaningOf(i+1) : '';
const parts = [];
if(prev) parts.push('上一段已经完成:'+prev);
parts.push('当前段只需要完成:'+meaningOf(i));
if(next) parts.push('下一段才应该发生:'+next);
return parts.join(' ');
}
function renderAnnotatedText(text, annotations){
const spans = [];
annotations.forEach((note, noteIndex)=>{
(note.evidence||[]).forEach(ev=>{
if(!ev) return;
const re = new RegExp(escapeRegExp(ev).replace(/\s+/g,'\\s+'), 'gi');
let m;
while((m = re.exec(text)) !== null){
spans.push({start:m.index, end:m.index+m[0].length, text:m[0], noteIndex});
if(m[0].length===0) re.lastIndex++;
}
});
});
spans.sort((a,b)=>a.start-b.start || (b.end-a.end));
const kept = [];
let lastEnd = 0;
spans.forEach(s=>{ if(s.start>=lastEnd){ kept.push(s); lastEnd=s.end; } });
let html = '', cursor = 0;
kept.forEach(s=>{
html += escapeHtml(text.slice(cursor, s.start));
const cat = annotations[s.noteIndex].category || 'rhetoric';
html += `<span class="annotation-anchor note-${escapeHtml(cat)}" data-note-id="${s.noteIndex}" tabindex="0">${escapeHtml(s.text)}<sup>${s.noteIndex+1}</sup></span>`;
cursor = s.end;
});
html += escapeHtml(text.slice(cursor));
return html;
}
function bindAnnotationInteractions(wrap){
const activate = id => {
wrap.querySelectorAll('.annotation-anchor,.annotation-card,.ot-line').forEach(el=>el.classList.remove('active'));
wrap.querySelectorAll(`.annotation-anchor[data-note-id="${id}"]`).forEach(el=>el.classList.add('active'));
wrap.querySelectorAll(`.ot-line[data-note-id="${id}"]`).forEach(el=>el.classList.add('active'));
const card = wrap.querySelector(`.annotation-card[data-note-id="${id}"]`);
if(card){ card.classList.add('active'); card.scrollIntoView({block:'nearest',behavior:'smooth'}); }
track('annotation_anchor_opened', {paragraph_id: pidOf(current), annotation_id: id});
};
wrap.querySelectorAll('.annotation-anchor').forEach(el=>{
el.onclick = () => activate(el.dataset.noteId);
el.onkeydown = e => { if(e.key==='Enter'||e.key===' '){ e.preventDefault(); activate(el.dataset.noteId); } };
});
wrap.querySelectorAll('.annotation-card').forEach(el=>el.onclick=e=>{ if(!e.target.closest('.why-toggle')) activate(el.dataset.noteId); });
// 原文中文对照(逐句):对照行与批注同色同序号 —— 点击对照行可联动高亮原文句
wrap.querySelectorAll('[data-role="orig-translate"]').forEach(btn=>{ btn.onclick = toggleOrigTranslate; });
wrap.querySelectorAll('.ot-line[data-note-id]').forEach(el=>{ el.onclick = () => activate(el.dataset.noteId); });
wrap.querySelectorAll('.why-toggle').forEach(btn=>btn.onclick=e=>{
e.stopPropagation();
const card = btn.closest('.annotation-card');
card.classList.toggle('why-open');
btn.textContent = card.classList.contains('why-open') ? '收起原因 ↑' : '为什么这样会显 AI ↓';
if(card.classList.contains('why-open')) track('annotation_why_opened', {paragraph_id: pidOf(current)});
});
}
function annotationCardsHtml(notes){
return notes.map((note,i)=>`<div class="annotation-card note-${escapeHtml(note.category||'rhetoric')}" data-note-id="${i}">
<div class="annotation-head"><span class="annotation-num">${i+1}</span><span class="annotation-kind">${escapeHtml(note.kind_label||KIND_LABEL[note.category]||'问题')}</span><span class="annotation-title">${escapeHtml(note.title||note.pattern_id||'')}</span></div>
<div class="annotation-observation">${escapeHtml(note.observation||'')}</div>
<div class="annotation-action"><strong>重写时:</strong>${escapeHtml(note.rewrite_action||'')}</div>
<button class="why-toggle">为什么这样会显 AI ↓</button>
<div class="annotation-why">${escapeHtml(note.why_ai_like||'')}</div>
</div>`).join('');
}
function isRevisionTarget(i){
return revisionMode && revisionTarget && String(revisionTarget.paragraph_id||'').toLowerCase() === pidOf(i);
}
function renderFocusOriginal(wrap){
const notes = annotationsFor(current);
const blockingNotes = isRevisionTarget(current) && revisionTarget
? (revisionTarget.evidence||[]).filter(ev=>evidenceInText(ev, paragraphs[current]))
: [];
let banner = '';
if(isRevisionTarget(current) && revisionTarget){
const ev = (revisionTarget.evidence||[]).map(x=>'• '+escapeHtml(x)).join('<br>');
banner = `<div class="revision-banner"><div class="eyebrow">本次返工目标</div><h3>这次只改一件事</h3><p><strong>Blocking Issue</strong>${escapeHtml(revisionTarget.blocking_issue||'')}</p>${ev?`<div class="constraint-box">${ev}</div>`:''}<div class="summary"><strong>单一返工目标:</strong>${escapeHtml(revisionTarget.single_revision_goal||'')}</div></div>`;
}
const fullDirection = `
<div class="rewrite-direction">
<div class="direction-head"><strong>${paragraphLabel(current)}</strong><span>先理解这段,再重写</span></div>
<div class="direction-line meaning"><span class="direction-chip meaning">这段在讲什么</span><div class="direction-text">${escapeHtml(meaningOf(current))}</div></div>
<div class="direction-line action"><span class="direction-chip action">重写要做什么</span><div class="direction-text">${escapeHtml(fusedGoal(current))}</div></div>
<div class="direction-line ai"><span class="direction-chip ai">AI味要注意</span><div class="direction-text">${escapeHtml(fusedAiFocus(current))}</div></div>
</div>`;
const otRec = origTranslations[current];
const otFresh = otRec && otRec.text === String(paragraphs[current]||'').trim();
const otOpen = !!(otFresh && origTranslateOpen[current]);
const paper = `
<div class="annotated-paper">
<div class="paper-title"><strong>原文</strong><span>${notes.length} 类重写提醒 · 颜色区分问题类型</span></div>
<p class="annotated-copy">${renderAnnotatedText(paragraphs[current]||'', notes)}</p>
<div class="orig-translate-row"><button class="btn link" data-role="orig-translate">${otOpen ? '收起中文对照 ↑' : otFresh ? '查看原文中文对照 →' : '原文中文对照 →'}</button></div>
${otOpen ? `<div class="orig-translate-box">${origTranslateLinesHtml(otRec)}</div>` : ''}
<div class="annotation-list">${annotationCardsHtml(notes)}</div>
</div>`;
const visibleMeaning = `<div class="rewrite-direction">
<div class="direction-head"><strong>${paragraphLabel(current)}</strong><span>返工时仍要守住原意</span></div>
<div class="direction-line meaning"><span class="direction-chip meaning">这段在讲什么</span><div class="direction-text">${escapeHtml(meaningOf(current))}</div></div>
</div>`;
const body = isRevisionTarget(current)
? `${visibleMeaning}<details class="prior-guidance"><summary>查看之前的写作建议</summary>${fullDirection}${paper}</details>`
: fullDirection + paper;
wrap.className = 'original-workspace';
wrap.innerHTML = `${banner}${body}<div class="context-hint"><div class="context-label">和上下文怎么接</div><div class="context-copy">${escapeHtml(contextHintOf(current))}</div></div>`;
bindAnnotationInteractions(wrap);
if(isRevisionTarget(current) && blockingNotes.length){
wrap.querySelectorAll('.annotation-anchor').forEach(el=>{
if(blockingNotes.some(ev=>el.textContent.toLowerCase().includes(String(ev).toLowerCase()))) el.classList.add('active');
});
}
}
function renderFullOriginal(wrap){
wrap.className='full-original-list';
wrap.innerHTML='<p class="mode-note">全文用于快速看上下文。点击任意段切换右侧改写;点“查看提醒”进入该段聚焦模式。</p>';
paragraphs.forEach((text,i)=>{
const notes = annotationsFor(i);
const item=document.createElement('div');
item.className='full-paragraph '+(i===current?'active':'');
const state = i===current?'当前':hasRewrite(i)?'已有改写':rewriteStatus[i]==='EDITING'?'正在改写':'';
item.innerHTML=`<div class="full-paragraph-head"><span class="full-paragraph-label">${paragraphLabel(i)}</span><span class="full-paragraph-state">${state}</span></div><p class="full-paragraph-copy">${escapeHtml(text)}</p><div class="full-paragraph-foot"><button class="full-issues-button">${notes.length} 类重写提醒 · 查看</button></div>`;
item.onclick=e=>{
const toFocus = !!e.target.closest('.full-issues-button');
switchParagraph(i, {view: toFocus ? 'focus' : 'full'});
};
wrap.appendChild(item);
});
}
function renderParagraphSwitcher(){
const wrap = $('#paragraphSwitcher');
wrap.innerHTML = paragraphs.map((_,i)=>`<button class="para-tab${i===current?' active':''}${hasRewrite(i)?' has-copy':''}" data-para-index="${i}" title="${paragraphLabel(i)}">${i+1}</button>`).join('');
wrap.querySelectorAll('[data-para-index]').forEach(btn=>btn.onclick=()=>switchParagraph(Number(btn.dataset.paraIndex)));
$('#focusOriginal').classList.toggle('active', originalView==='focus');
$('#fullOriginal').classList.toggle('active', originalView==='full');
}
// 轻量更新段号按钮状态(active / has-copy),不重建 DOM
// 改写区 blur(点段号的 mousedown 触发)若重建按钮,mouseup 时按钮已被替换,
// 第一次点击会丢失(用户实测:改完字点段号要连点两次)。
function updateParagraphSwitcherState(){
const wrap = $('#paragraphSwitcher');
if(!wrap) return;
wrap.querySelectorAll('[data-para-index]').forEach(btn=>{
const i = Number(btn.dataset.paraIndex);
btn.classList.toggle('active', i===current);
btn.classList.toggle('has-copy', hasRewrite(i));
});
}
function renderOriginalPane(){
const wrap=$('#originalFull');
wrap.innerHTML='';
if(originalView==='focus') renderFocusOriginal(wrap); else renderFullOriginal(wrap);
renderParagraphSwitcher();
}
function updateProgress(){
const n = rewrittenCount();
const total = paragraphs.length;
// 体验优化:全文词数提示(未改写段按原文计)——对照 word_limit 控制总长度;
// 超出题目字数限制时标红(复检的 word_limit 检查项同源)。
const words = essayWordTotal();
const pt = $('#progressText');
pt.textContent = `已有改写 ${n} 段 · 共 ${total} 段 · 全文 ${words}${wordLimit?` / ${wordLimit}`:''} 词`;
pt.classList.toggle('over', !!(wordLimit && words>wordLimit));
$('#progressBar').style.width = `${total? n/total*100 : 0}%`;
// PRD §29.1:复检通过 = 本轮结束(状态流转"通过 → 结束",用户实测"通过后
// 仍可再提交复检")。通过后不再提供提交复检入口——再次复检会以
// previous_recheck=pass 无返工目标地再跑一轮,与 Deviations #9 堵住的入口同类;
// 重新分析(runAnalyze 清空 lastRecheckResult)后恢复可点。
const passed = lastRecheckResult && lastRecheckResult.status === 'pass';
const ready = n===total && total>0;
$('#submitRecheck').disabled = !ready || passed;
const hint = $('#submitHint');
if(passed){
hint.classList.remove('hidden');
hint.textContent = '本轮复检已通过,流程已结束。如需开启新一轮:点左上角「返回对话」回对话页,再点「返回文书」回文书页,然后点「开始分析」。';
}else if(!ready && total){
hint.classList.remove('hidden');
hint.textContent = `还有 ${total-n} 段尚未完成改写。`;
}else{
hint.classList.add('hidden');
hint.textContent = '';
}
}
function renderSaveChip(){
const chip = $('#writeStatus');
const status = saveStatusByPara[current] || 'idle';
if(!hasRewrite(current) && rewriteStatus[current]!=='EDITING'){
chip.textContent='未改写'; chip.className='chip'; chip.onclick=null; return;
}
if(status==='saving'){ chip.textContent='保存中'; chip.className='chip warn'; chip.onclick=null; return; }
if(status==='failed'){
chip.textContent='保存失败 · 重试';
chip.className='chip bad';
chip.onclick=()=>{ track('rewrite_save_retried',{paragraph_id:pidOf(current)}); flushSave(current); };
return;
}
chip.textContent='已保存';
chip.className='chip good';
chip.onclick=null;
}
// 体验优化:每段改写字数控制提示 —— 原文段词数 vs 当前改写词数;偏差过大
// 时黄色提醒(改写应基于原意重新表达,长度通常接近原文;防止信息丢失或膨胀)。
// 全文词数在 updateProgress 里连同 word_limit 一起提示。
function renderWordMeta(){
const el = $('#wordMeta'); if(!el) return;
const orig = wordCount(paragraphs[current]||'');
const cur = wordCount(rewrites[current]||'');
let text = `原文 ${orig} 词 · 当前 ${cur} 词`;
let warn = false;
if(cur>0 && orig>0){
const ratio = cur/orig;
if(ratio>1.4 || ratio<0.6){ warn = true; text += ' · 与原文长度相差较大'; }
}
el.textContent = text;
el.classList.toggle('warn', warn);
}
function essayWordTotal(){
return paragraphs.reduce((n,p,i)=>n+wordCount(hasRewrite(i)?rewrites[i]:p),0);
}
// 体验优化:改写稿中文对照翻译 —— 顾问自查「改写后原意/细节是否保留」。
// 译文缓存按段保存,改写稿变化即过期(renderTranslateBox 里自动收起)。
function renderTranslateBtn(){
const btn = $('#translateBtn'); if(!btn) return;
const rec = translations[current];
const fresh = rec && rec.text === String(rewrites[current]||'').trim();
if(fresh && translateOpen[current]) btn.textContent = '收起译文 ↑';
else if(fresh) btn.textContent = '查看中文对照 →';
else btn.textContent = '译成中文对照 →';
}
function renderTranslateBox(){
const box = $('#translateBox'); if(!box) return;
const rec = translations[current];
const fresh = rec && rec.text === String(rewrites[current]||'').trim();
if(!fresh || !translateOpen[current]){ box.classList.remove('open'); box.innerHTML=''; return; }
box.innerHTML = `<span class="translate-label">中文对照(用于自查改写后原意是否保留)</span>${escapeHtml(rec.translation)}`;
box.classList.add('open');
}
async function toggleTranslate(){
const i = current;
const area = $('#rewriteArea');
if(area) rewrites[i] = area.value;
const text = String(rewrites[i]||'').trim();
const btn = $('#translateBtn');
const rec = translations[i];
const fresh = rec && rec.text === text;
if(fresh){
translateOpen[i] = !translateOpen[i];
persistNow(); renderTranslateBtn(); renderTranslateBox();
return;
}
if(!text){
btn.textContent = '先写下你的改写,再翻译 ↗';
setTimeout(renderTranslateBtn, 1800);
return;
}
btn.disabled = true; btn.textContent = '翻译中…';
try{
const resp = await postJSON('/api/translate',{text, paragraph_id: pidOf(i)});
translations[i] = {text, translation: resp.translation||''};
translateOpen[i] = true;
track('rewrite_translated', {paragraph_id: pidOf(i)});
persistNow();
}catch(e){
btn.disabled = false; btn.textContent = '翻译失败,点击重试';
setTimeout(renderTranslateBtn, 2200);
return;
}
btn.disabled = false;
renderTranslateBtn(); renderTranslateBox();
}
// 体验优化:把当前段原文一键复制到改写区 —— 顾问以原文为起点手动改写
// (改写区已有内容时二次确认,避免覆盖已写的工作)。
function copyOriginalToRewrite(){
const area = $('#rewriteArea');
const text = String(paragraphs[current]||'').trim();
if(!area || !text) return;
if(String(area.value||'').trim() && !confirm('改写区已有内容,确定用原文覆盖吗?')) return;
area.value = text;
area.focus();
onRewriteInput({target: area}); // 复用输入处理:状态/字数提示/译文过期/保存调度
track('original_copied_to_rewrite', {paragraph_id: pidOf(current)});
}
// 体验优化:原文中文对照(逐句)—— 顾问读英文吃力时,特别是被高亮标注
// 「需要改写」的句子,需要它的中文意思才能判断怎么改。
// 句子由前端拆分(en 为原文精确文本,服务端原样回填),命中批注 evidence 的
// 句子在对照里与原文同色 + 同序号,可直接对上。
function splitSentences(text){
const raw = String(text||'').trim();
if(!raw) return [];
const parts = raw.match(/[^.!?]+(?:[.!?]+["'”’]?|$)/g) || [raw];
return parts.map(s=>s.trim()).filter(Boolean);
}
function noteHitForSentence(sentence, notes){
const norm = s=>String(s||'').toLowerCase().replace(/\s+/g,' ').trim();
const target = norm(sentence);
for(let i=0;i<notes.length;i++){
for(const ev of (notes[i].evidence||[])){
const needle = norm(ev);
if(needle && target.includes(needle)) return {index:i, category: notes[i].category||'rhetoric'};
}
}
return null;
}
function origTranslateLinesHtml(rec){
const notes = annotationsFor(current);
return (rec.sentences||[]).map(s=>{
const hit = noteHitForSentence(s.en, notes);
return `<div class="ot-line${hit?` note-${escapeHtml(hit.category)}`:''}"${hit?` data-note-id="${hit.index}"`:''}>${hit?`<sup>${hit.index+1}</sup>`:''}<span class="ot-zh">${escapeHtml(s.zh)}</span></div>`;
}).join('');
}
async function toggleOrigTranslate(){
const i = current;
const text = String(paragraphs[i]||'').trim();
const rec = origTranslations[i];
const fresh = rec && rec.text === text;
if(fresh){
origTranslateOpen[i] = !origTranslateOpen[i];
persistNow(); renderOriginalPane();
return;
}
const sentences = splitSentences(text);
if(!sentences.length) return;
const btn = document.querySelector('[data-role="orig-translate"]');
if(btn){ btn.disabled = true; btn.textContent = '翻译中…'; }
try{
const resp = await postJSON('/api/translate',{sentences, paragraph_id: pidOf(i)});
origTranslations[i] = {text, sentences: resp.sentences||[]};
origTranslateOpen[i] = true;
track('original_translated', {paragraph_id: pidOf(i), sentences: sentences.length});
persistNow();
}catch(e){
if(btn){ btn.disabled = false; btn.textContent = '翻译失败,点击重试'; }
return;
}
renderOriginalPane();
}
function resetHelpPanels(){
const key = pidOf(current);
const st = helpState[key] || {scaffold:false, reference:false};
$('#scaffoldPanel').classList.toggle('open', !!st.scaffold);
$('#showScaffold').textContent = st.scaffold ? '收起写作起点 ↑' : '给我一个写作起点 →';
$('#referenceBox').classList.toggle('open', !!st.reference);
$('#referenceNote').classList.toggle('hidden', !st.reference);
$('#showReference').textContent = st.reference ? '收起参考 ↑' : '还是卡住?看一个参考片段 →';
$('#scaffoldBox').textContent = st.scaffoldText || '';
$('#referenceBox').textContent = st.referenceText || '';
}
function renderWorkbench(){
show('workbenchView');
ensureParaState();
$('#rewriteHeading').textContent = `${paragraphLabel(current)} · 我的改写`;
$('#rewriteArea').value = rewrites[current] || '';
$('#charCount').textContent = `${(rewrites[current]||'').length} characters`;
$('#prevPara').disabled = current===0;
$('#nextPara').disabled = current===paragraphs.length-1;
renderOriginalPane();
renderSaveChip();
renderWordMeta();
renderTranslateBtn();
renderTranslateBox();
resetHelpPanels();
updateProgress();
renderRecheckHistory();
}
async function submitConstraint(){
const input = $('#constraintInput');
const raw = (input.value||'').trim();
if(!raw) return;
const normalized = applyDiagnosisConstraint(raw);
input.value = '';
track('workbench_constraint_added', {constraint: normalized});
const btn = $('#constraintSubmit');
const opener = $('#addConstraintBtn');
btn.disabled = true; btn.textContent = '重新诊断中…';
opener.disabled = true;
try{
const resp = await requestDiagnosis();
diagnosis = resp;
agentStage = 'workbench';
helpState = {}; // 旧方向生成的写作起点/参考失效,需重新获取
persistNow();
renderOriginalPane();
updateProgress();
btn.textContent = '✓ 已更新全部提示';
setTimeout(()=>{ btn.disabled = false; btn.textContent = '提交并重新诊断'; opener.disabled = false; }, 1500);
}catch(e){
input.value = raw; // 失败保留输入,可直接重试
btn.disabled = false; btn.textContent = '提交并重新诊断';
opener.disabled = false;
alert('重新诊断失败:' + e.message);
}
}
function toggleConstraintEntry(){
const row = $('#constraintInputRow');
row.classList.toggle('hidden');
if(!row.classList.contains('hidden')) $('#constraintInput').focus();
}
function enterWorkbench(){
agentStage = 'workbench';
originalView = originalView || 'focus';
ensureParaState();
persistNow();
renderWorkbench();
disableCompletedActions();
}
function enterRevision(pid){
const idx = Math.max(0, parseInt(String(pid).replace(/\D/g,''),10)-1);
current = Number.isFinite(idx) && idx < paragraphs.length ? idx : 0;
originalView = 'focus';
revisionMode = true;
agentStage = 'workbench';
track('revision_target_viewed', {paragraph_id: pidOf(current)});
persistNow();
renderWorkbench();
}
function switchParagraph(nextIndex, opts){
if(!Number.isFinite(nextIndex) || nextIndex<0 || nextIndex>=paragraphs.length) return;
const area = $('#rewriteArea');
if(area) rewrites[current] = area.value;
flushSave(current);
const from = current;
current = nextIndex;
if(opts && opts.view) originalView = opts.view;
if(hasRewrite(current)) rewriteStatus[current] = rewriteStatus[current]==='UNTOUCHED' ? 'REWRITTEN' : rewriteStatus[current];
track('rewrite_paragraph_switched', {from: pidOf(from), to: pidOf(current)});
persistNow();
renderWorkbench();
}
function setOriginalView(mode){
originalView = mode;
track(mode==='focus' ? 'original_focus_viewed' : 'original_full_viewed', {paragraph_id: pidOf(current)});
persistNow();
renderOriginalPane();
}
function onRewriteInput(e){
rewrites[current] = e.target.value;
$('#charCount').textContent = `${e.target.value.length} characters`;
renderWordMeta();
renderTranslateBtn();
renderTranslateBox(); // 改写稿变化 → 旧译文过期自动收起(缓存按文本比对)
if(!e.target.value.trim()){
rewriteStatus[current] = 'UNTOUCHED';
}else{
rewriteStatus[current] = 'EDITING';
if(lastRecheckSnapshot && lastRecheckSnapshot.version === `rv_${currentRewriteVersion}`){
currentRewriteVersion += 1;
dirtyDuringRecheck = true;
}
}
scheduleSave(current);
updateParagraphSwitcherState();
updateProgress();
}
function onRewriteBlur(){
const area = $('#rewriteArea');
if(area) rewrites[current] = area.value;
if(hasRewrite(current)) rewriteStatus[current] = 'REWRITTEN';
else rewriteStatus[current] = 'UNTOUCHED';
flushSave(current);
updateParagraphSwitcherState(); // 不重建按钮:避免点击段号时 mousedown→blur 重建导致第一次点击丢失
updateProgress();
}
function recordExposure(kind, text){
if(!text) return;
const rec = {paragraph_id: pidOf(current), text, shown_at: Date.now(), analysis_version: analysisVersion, rewrite_version: currentRewriteVersion};
if(kind==='scaffold'){
if(!scaffoldsShown.some(x=>x.paragraph_id===rec.paragraph_id && x.text===text)) scaffoldsShown.push(rec);
}else if(!referencesShown.some(x=>x.paragraph_id===rec.paragraph_id && x.text===text)){
referencesShown.push(rec);
}
persistNow();
}
async function toggleScaffold(){
const key = pidOf(current);
helpState[key] = helpState[key] || {};
if(helpState[key].scaffold){
helpState[key].scaffold = false;
resetHelpPanels();
return;
}
const brief = briefFor(key);
let text = (brief && brief.scaffold) || helpState[key].scaffoldText || '';
if(!text){
$('#showScaffold').textContent = '生成中…';
try{
const resp = await postJSON('/api/scaffold',{
paragraph_id:key,
original_text:paragraphs[current],
semantic_anchor:meaningOf(current),
rewrite_goal:fusedGoal(current),
global_constraints:globalConstraints,
paragraph_constraints:paragraphConstraints[key]||[]
});
text = resp.scaffold || '';
}catch(e){
text = '生成失败:'+e.message;
}
}
helpState[key].scaffold = true;
helpState[key].scaffoldText = text;
$('#scaffoldBox').textContent = text;
recordExposure('scaffold', text);
track('rewrite_scaffold_opened', {paragraph_id:key});
resetHelpPanels();
}
async function toggleReference(){
const key = pidOf(current);
helpState[key] = helpState[key] || {};
if(helpState[key].reference){
helpState[key].reference = false;
resetHelpPanels();
return;
}
const brief = briefFor(key);
let text = (brief && brief.reference_snippet) || helpState[key].referenceText || '';
if(!text){
$('#showReference').textContent = '生成中…';
try{
const resp = await postJSON('/api/reference',{
paragraph_id:key,
original_text:paragraphs[current],
semantic_anchor:meaningOf(current),
rewrite_goal:fusedGoal(current),
primary_goal:fusedGoal(current),
global_constraints:globalConstraints,
paragraph_constraints:paragraphConstraints[key]||[]
});
text = resp.reference_snippet || resp.starter || '';
}catch(e){
text = '生成失败:'+e.message;
}
}
helpState[key].reference = true;
helpState[key].referenceText = text;
$('#referenceBox').textContent = text;
recordExposure('reference', text);
track('rewrite_reference_opened', {paragraph_id:key});
resetHelpPanels();
}
const CHECK_LABELS = {
semantic_preservation:'原意保持',
voice_consistency:'全文 Voice',
pattern_reduction:'原主要 AI Pattern',
new_pattern:'新模板表达',
coherence:'上下文',
reference_copying:'是否明显套用已展开的参考',
word_limit:'字数限制'
};
async function runRecheck(){
flushSave(current);
const snapshot = rewrites.map(r=>r||'');
const version = `rv_${currentRewriteVersion}`;
lastRecheckSnapshot = {version, paragraphs: snapshot.slice()};
dirtyDuringRecheck = false;
persistNow();
agentStage='recheckloading';
const loading = addLoadingCard('正在全文复检…', ['正在对照已确认语义锚点','正在检查原 Pattern 是否真正被打破','正在判断是否只需要返工一件事']);
track('recheck_submitted', {rewrite_version: version, paragraphs: paragraphs.length});
try{
const resp = await postJSON('/api/recheck',{
prompt:promptText, word_limit:wordLimit,
original_paragraphs:paragraphs,
confirmed_anchors:(analysis.paragraphs||[]).map(p=>p.semantic_anchor||p.natural_meaning_zh||''),
global_constraints:globalConstraints,
paragraph_constraints:paragraphConstraints,
rewrite_paragraphs:snapshot,
diagnosis,
references_shown: referencesShown.map(x=>x.text||x),
scaffolds_shown: scaffoldsShown.map(x=>x.text||x),
rewrite_version: version,
previous_recheck: lastRecheckResult,
previous_revision_target: revisionTarget
});
if(!resp.checked_rewrite_version) resp.checked_rewrite_version = version;
removeEl(loading);
lastRecheckResult = resp;
pushRecheckHistory(resp);
agentStage='recheck';
persistNow();
renderRecheck(resp);
}catch(e){
removeEl(loading);
agentStage='workbench';
renderError(e.message, ()=>{ show('agentView'); addUser('重新提交全文复检。'); runRecheck(); });
}
}
// 体验优化:复检汇总建议历史 —— 此前复检结果看一次就只剩最新一份(lastRecheckResult),
// 无法回看历次建议。这里每次复检入历史(状态/检查项/返工目标/绑定版本),
// 历史条目可直接「按此建议再次改写」回到对应段、带着当时的建议修改。
function pushRecheckHistory(r){
const rec = {
at: Date.now(),
status: r.status==='pass' ? 'pass' : 'revision_required',
version: r.checked_rewrite_version || '',
checks: r.global_checks || {},
target: r.status==='pass' ? null : ((r.revision_targets||[])[0] || null),
pass_version: null
};
recheckHistory.unshift(rec);
if(recheckHistory.length>10) recheckHistory.length = 10;
persistNow();
return rec;
}
function failedCheckLabels(checks){
return Object.keys(CHECK_LABELS).filter(k=>k!=='word_limit'||wordLimit).filter(k=>{
const v = (checks||{})[k];
return !(v==='pass'||v===undefined||v===null);
}).map(k=>CHECK_LABELS[k]);
}
function renderRecheckHistory(){
const btn = $('#recheckHistoryBtn');
if(!btn) return;
btn.classList.toggle('hidden', recheckHistory.length===0);
btn.textContent = `复检历史 (${recheckHistory.length})`;
const panel = $('#recheckHistoryPanel');
if(!panel) return;
if(!recheckHistory.length){ panel.classList.remove('open'); panel.innerHTML=''; return; }
panel.innerHTML = `<div class="rhi-title">复检历史(最新在前,点建议可再次进入改写)</div>` + recheckHistory.map((rec,idx)=>{
const ok = rec.status==='pass';
const d = new Date(rec.at);
const pad = n=>String(n).padStart(2,'0');
const time = `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
const tIdx = rec.target ? Math.max(0, parseInt(String(rec.target.paragraph_id||'p1').replace(/\D/g,''),10)-1) : -1;
const badge = ok ? '<span class="rhi-badge ok">✓ 通过</span>' : `<span class="rhi-badge warn">需返工 ${tIdx>=0?paragraphLabel(tIdx):''}</span>`;
const fails = failedCheckLabels(rec.checks);
const meta = [rec.version?`Rewrite Version ${escapeHtml(rec.version)}`:'', rec.pass_version?`生成版本 ${rec.pass_version}`:'', fails.length?`未通过检查:${fails.join('、')}`:''].filter(Boolean).join(' · ');
return `<div class="recheck-history-item">
<div class="rhi-head"><strong>${time}</strong>${badge}${idx===0?'<span class="rhi-latest">最新</span>':''}</div>
${meta?`<div class="rhi-meta">${meta}</div>`:''}
${rec.target&&rec.target.single_revision_goal?`<div class="rhi-goal"><strong>建议:</strong>${escapeHtml(rec.target.single_revision_goal)}</div>`:''}
${rec.target?`<button class="btn small rhi-revision" data-hidx="${idx}">按此建议再次改写 →</button>`:''}
</div>`;
}).join('');
panel.querySelectorAll('.rhi-revision').forEach(b=>{ b.onclick=()=>enterRevisionFromHistory(Number(b.dataset.hidx)); });
}
function toggleRecheckHistory(force){
const panel = $('#recheckHistoryPanel');
if(!panel || !recheckHistory.length) return;
const open = typeof force==='boolean' ? force : !panel.classList.contains('open');
panel.classList.toggle('open', open);
if(open){ renderRecheckHistory(); track('recheck_history_opened', {count: recheckHistory.length}); }
}
function enterRevisionFromHistory(hidx){
const rec = recheckHistory[hidx];
if(!rec || !rec.target) return;
// 选中历史建议 = 当前返工目标:左栏返工 banner / 对话页 return 按钮都按它工作
revisionTarget = rec.target;
revisionMode = true;
toggleRecheckHistory(false);
track('recheck_history_revision_entered', {paragraph_id: rec.target.paragraph_id});
enterRevision(rec.target.paragraph_id);
}
function renderRecheck(r){
const checks=r.global_checks||{};
const keys = Object.keys(CHECK_LABELS).filter(k=> k!=='word_limit' || wordLimit);
const rows=keys.map(k=>{
const v=checks[k];
const pass = v==='pass'||v===undefined||v===null;
let mark = pass ? '✓' : '需要处理';
if(k==='pattern_reduction' && pass) mark = '✓ 已明显降低';
if(k==='new_pattern' && pass) mark = '✓ 未发现明显问题';
return `<tr><td>${CHECK_LABELS[k]}</td><td style="width:190px"><strong style="color:${pass?'#13966d':'#c8463a'}">${mark}</strong></td></tr>`;
}).join('');
const ver = r.checked_rewrite_version ? `<p class="muted">本次复检绑定 Rewrite Version${escapeHtml(r.checked_rewrite_version)}</p>` : '';
if(r.status==='pass'){
revisionMode = false;
revisionTarget = null;
persistNow();
track('recheck_passed', {rewrite_version: r.checked_rewrite_version});
track('rewrite_completed');
// 复检通过 = 生成成品版本(原版=版本1,本轮改写后全文 = 下一版本)。
// 用户决定(2026-08-21):pass 卡提供【查看版本 N】按钮,点击才回文书页切换,不自动跳转。
const fullText = paragraphs.map((p,i)=>String(rewrites[i]||'').trim()||p).join('\n\n');
const nextVersion = essayVersions.length + 2;
essayVersions.push({version: nextVersion, text: fullText});
viewedVersion = nextVersion;
if(recheckHistory[0]) recheckHistory[0].pass_version = nextVersion; // 历史条目关联成品版本
persistNow();
addAgent(`<div class="eyebrow">Human Voice Recheck</div><h1>本轮 Human Voice Rewrite 已完成。</h1>${ver}<p>我把 ${paragraphs.length} 段放回完整文章重新读了一遍。本次只做最终验收,没有重新开启一轮问题盘点。</p><table class="agent-table"><thead><tr><th>检查项</th><th style="width:190px">状态</th></tr></thead><tbody>${rows}</tbody></table><div class="inline-note"><strong>可以结束这一轮 Human Voice Rewrite。</strong>剩余问题主要属于个人风格选择,不建议继续为了“更不像 AI”大面积修改。</div><p class="muted">本次改写后的全文已生成 <strong>版本 ${nextVersion}</strong>。点击下方按钮回文书页,可在文书页顶部切换「原版 / 版本 ${nextVersion}」对比。</p><div class="quick-actions"><button class="quick primary" data-action="view-version2">查看版本 ${nextVersion}(修改后的全文)→</button><button class="quick ghost" data-action="view-recheck-history">查看复检历史</button></div>`);
bindQuickActions();
disableCompletedActions(); // 通过后收紧:历史返工卡按钮按 revisionTarget=null 置灰(用户实测 2
}else{
const t=(r.revision_targets||[])[0]||{paragraph_id:'p1',blocking_issue:'需要返工',evidence:[],single_revision_goal:'重读当前段改写并修正最明显的问题'};
revisionTarget = t;
persistNow();
const idx=Math.max(0, parseInt(String(t.paragraph_id||'p1').replace(/\D/g,''),10)-1);
const ev=t.evidence&&t.evidence.length?`<div class="constraint-box"><strong>Evidence</strong><br>${t.evidence.map(x=>'• '+escapeHtml(x)).join('<br>')}</div>`:'';
track('recheck_revision_required', {paragraph_id: t.paragraph_id});
addAgent(`<div class="eyebrow">Human Voice Recheck</div><h1>全文复检:${escapeHtml(paragraphLabel(idx))} 还需要返工一次</h1>${ver}<p>这次不重复首轮的 Pattern 教学,只看本轮重写有没有真正生效、上下文是否统一。</p><table class="agent-table"><thead><tr><th>检查项</th><th style="width:190px">状态</th></tr></thead><tbody>${rows}</tbody></table><h2>这次只返工 ${escapeHtml(paragraphLabel(idx))}</h2><p>${escapeHtml(t.blocking_issue)}</p>${ev}<div class="summary"><strong>本次只返工一件事:</strong>${escapeHtml(t.single_revision_goal)}</div><div class="quick-actions"><button class="quick primary" data-action="return-${escapeHtml(t.paragraph_id)}">返回 ${escapeHtml(paragraphLabel(idx))} 重写 →</button><button class="quick ghost" data-action="view-recheck-history">查看复检历史</button></div>`);
bindQuickActions();
disableCompletedActions();
}
renderRecheckHistory();
}
const MOCKS=[
`What I liked most about math was the certainty. Even when a page looked messy, I trusted that there was an answer somewhere in it. I liked working toward the point where the numbers finally settled into something clear. For a long time, I thought that was what being good at math meant: if I stayed with a problem long enough, I should be able to find the answer.`,
`Then I hit a problem I could not solve. I rewrote the equation, went back through my notes, and tried the methods I knew. Nothing changed. At first I was annoyed, but after enough failed attempts I started to wonder whether the problem was not the problem at all. Maybe I was simply not as good at math as I had thought. That thought bothered me more than the blank space where the answer should have been.`,
`I kept coming back to the problem. After a while, I started paying more attention to where each attempt went wrong than to how quickly I could get to the answer. A failed step was not useless if it showed me something about the problem I had missed. I still wanted the final answer, but I stopped treating the time before it arrived as proof that I was failing. Being stuck no longer felt like a verdict on whether I belonged in math.`,
`I still sit at my desk with a pencil in my hand and a problem in front of me. I still want the answer. What has changed is the urgency I used to attach to finding it. I can stay with a problem longer now, follow a line of thought that may fail, and keep going without deciding too quickly what that failure says about me. That is the part of math I did not understand before, and the part I now value most.`
];
const GENERIC_MOCK=`I kept coming back to the problem. Each time I failed, I noticed something new about where I had gone wrong. I still wanted the answer, but I started caring more about understanding why.`;
function mockFor(i){return essayText===SAMPLE_ESSAY.join('\n\n') ? (MOCKS[i]||GENERIC_MOCK) : GENERIC_MOCK;}
function fillRewrite(i, text){
rewrites[i]=text;
rewriteStatus[i]=text.trim()?'REWRITTEN':'UNTOUCHED';
saveStatusByPara[i]='saved';
}
renderEntryEssay();
restoreSession();
checkBackend();
$('#startAnalysis').onclick=async ()=>{
const ok = await checkBackend();
if(!ok) return;
runAnalyze();
};
$('#editEssayBtn').onclick=openEditor;
$('#loadSampleBtn').onclick=loadSample;
$('#useEssayBtn').onclick=applyEditedEssay;
$('#cancelEditBtn').onclick=()=>{closeEditor();renderEntryEssay();};
$('#backToEssay').onclick=()=>{ flushSave(current); show('entryView'); };
// Workbench 返回对话:回对话页看历史卡片;agentStage 回到 diagnosis 轮次,
// 诊断卡【确认方向】【补充改写要求】恢复可点(disableCompletedActions 按轮次重算),可再进改写
$('#backToChat').onclick=()=>{
agentStage='diagnosis';
flushSave(current);
show('agentView');
disableCompletedActions();
};
$('#sendChat').onclick=()=>processChat($('#chatInput').value);
// 中文输入法组合期(拼音还没上屏)回车是在选候选词,不是发送 —— 此前无守卫导致
// 打字打到一半就被发出去(用户实测)。keyCode 229 兼容部分输入法组合结束那一下。
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing&&e.keyCode!==229){e.preventDefault();processChat(e.target.value);}});
$('#chatInput').addEventListener('input',()=>{const h=$('#inputHint');if(h)h.classList.add('hidden');});
$('#focusOriginal').onclick=()=>setOriginalView('focus');
$('#fullOriginal').onclick=()=>setOriginalView('full');
$('#rewriteArea').addEventListener('input', onRewriteInput);
$('#rewriteArea').addEventListener('blur', onRewriteBlur);
$('#showScaffold').onclick=toggleScaffold;
$('#showReference').onclick=toggleReference;
$('#copyOriginalBtn').onclick=copyOriginalToRewrite;
$('#translateBtn').onclick=toggleTranslate;
$('#recheckHistoryBtn').onclick=()=>toggleRecheckHistory();
// 点击复检历史面板与开关按钮以外的区域自动收起
document.addEventListener('click', e=>{
const panel = $('#recheckHistoryPanel');
if(!panel || !panel.classList.contains('open')) return;
if(panel.contains(e.target)) return;
if(e.target.closest && e.target.closest('#recheckHistoryBtn, [data-action="view-recheck-history"]')) return;
toggleRecheckHistory(false);
});
$('#prevPara').onclick=()=>{ if(current>0) switchParagraph(current-1); };
$('#nextPara').onclick=()=>{ if(current<paragraphs.length-1) switchParagraph(current+1); };
$('#addConstraintBtn').onclick=toggleConstraintEntry;
$('#constraintSubmit').onclick=submitConstraint;
$('#constraintCancel').onclick=()=>$('#constraintInputRow').classList.add('hidden');
$('#mockFillCurrent').onclick=()=>{fillRewrite(current, mockFor(current));persistNow();renderWorkbench();};
$('#mockFillAll').onclick=()=>{paragraphs.forEach((_,i)=>fillRewrite(i, mockFor(i)));persistNow();renderWorkbench();};
$('#mockFillFail').onclick=()=>{
paragraphs.forEach((_,i)=>fillRewrite(i, mockFor(i)));
const idx = paragraphs.length>2 ? 2 : 0;
fillRewrite(idx, `I kept returning to the problem, and I gradually came to realize that each failed attempt had something to teach me. This helped me understand that uncertainty is an important part of growth, and I began to appreciate the process more than the answer itself. I still wanted to solve the problem, but I came to understand that struggle could make me more resilient.`);
persistNow();
renderWorkbench();
};
$('#submitRecheck').onclick=()=>{
if(rewrittenCount()<paragraphs.length) return;
flushSave(current);
show('agentView');
addUser('全文已经重写完,提交全文复检。');
if(revisionMode) track('revision_resubmitted', {paragraph_id: revisionTarget&&revisionTarget.paragraph_id});
runRecheck();
};
window.addEventListener('beforeunload', ()=>{ try{ const area=$('#rewriteArea'); if(area) rewrites[current]=area.value; persistNow(); }catch(e){} });
</script>
</body>
</html>