feat: 改写工作台体验优化 — 中文对照翻译 + 字数控制提示 + 复检历史/按建议再次改写 + 输入法组合期不误发送

- 新增 POST /api/translate(build_translate_prompt): 英文改写稿忠实直译成中文,供顾问自查改写后原意是否保留
- 改写区「译成中文对照」按钮 + 译文展开/收起;改写稿变化后旧译文自动过期(缓存按文本比对)
- 每段字数控制提示: rewrite-meta 显示「原文 X 词 · 当前 Y 词」,偏差过大黄色提醒;
  顶部进度加「全文 X / wordLimit 词」,超出题目字数限制标红
- 复检汇总建议历史: 每次复检入历史(状态/未通过检查项/返工目标/绑定版本,上限 10 条);
  Workbench 提交区 + 复检卡两处入口;历史条目「按此建议再次改写」回到目标段并显示返工 banner
- 对话输入框: 中文输入法组合期(isComposing/keyCode 229)回车不再误发送
- 测试: 新增 /api/translate 3 个 hermetic 测试(31 passed);浏览器实测 18/18 通过
This commit is contained in:
LuminousRuoxi
2026-09-10 14:09:31 +08:00
parent 36b7e601e7
commit 85a2eb720a
5 changed files with 269 additions and 11 deletions
+197 -11
View File
@@ -54,7 +54,7 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
.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-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}
@@ -82,8 +82,8 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
.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:690px}.rewrite-intro{font-size:12px;color:#737b8d;margin-bottom:12px}.rewrite-area{width:100%;min-height:500px;border:1px solid #e2e4ed;border-radius:11px;outline:0;resize:vertical;padding:15px 16px;font:15px/1.8 Georgia,"Times New Roman",serif;color:#2f3443;background:#fff;transition:.15s}.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}.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}
.rewrite-card{min-height:690px}.rewrite-intro{font-size:12px;color:#737b8d;margin-bottom:12px}.rewrite-area{width:100%;min-height:500px;border:1px solid #e2e4ed;border-radius:11px;outline:0;resize:vertical;padding:15px 16px;font:15px/1.8 Georgia,"Times New Roman",serif;color:#2f3443;background:#fff;transition:.15s}.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}.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)}}
@@ -192,7 +192,9 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
<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="charCount">0 characters</span></div>
<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="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">
@@ -210,7 +212,8 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
</div>
</section>
</div>
<div class="submit-row"><div class="submit-inner"><span id="submitHint" class="submit-hint hidden"></span><button id="submitRecheck" class="btn primary" disabled>完成全文改写,提交复检 →</button></div></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 id="recheckHistoryPanel" class="recheck-history" aria-label="复检历史记录"></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>
</div>
@@ -253,6 +256,13 @@ let scaffoldsShown = [];
let currentRewriteVersion = 1;
let lastRecheckSnapshot = null;
let lastRecheckResult = null;
// 体验优化:复检汇总建议历史(最新在前,上限 10 条)——支持回看历次结论 +
// 按历史建议再次进入改写(结合该条建议修改)。
let recheckHistory = [];
// 体验优化:改写稿中文对照翻译缓存(按段下标)。rec.text 与当前改写稿不一致
// 时视为过期,需重新翻译(保证译文对应当前版本)。
let translations = [];
let translateOpen = [];
let analysisVersion = 1;
let helpState = {};
let dirtyDuringRecheck = false;
@@ -367,6 +377,10 @@ function ensureParaState(){
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);
}
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));}
@@ -447,8 +461,9 @@ function applyEditedEssay(){
const wl = parseInt($('#wordLimitInput').value, 10);
wordLimit = Number.isFinite(wl) && wl > 0 ? wl : null;
paragraphs = splitParagraphs(essayText);
// 文书内容变化后,旧版本(改写后全文)失去参照,清空版本切换
// 文书内容变化后,旧版本(改写后全文)失去参照,清空版本切换与复检历史/译文
essayVersions = []; viewedVersion = null;
recheckHistory = []; translations = []; translateOpen = [];
ensureParaState();
closeEditor();
renderEntryEssay();
@@ -460,6 +475,7 @@ function loadSample(){
wordLimit = 650;
paragraphs = SAMPLE_ESSAY.slice();
essayVersions = []; viewedVersion = null;
recheckHistory = []; translations = []; translateOpen = [];
ensureParaState();
closeEditor();
renderEntryEssay();
@@ -474,7 +490,7 @@ function persistNow(){
current, originalView, revisionMode, revisionTarget,
referencesShown, scaffoldsShown, currentRewriteVersion,
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
chatLog, essayVersions, viewedVersion
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen
}));
return true;
}catch(e){ return false; }
@@ -531,6 +547,9 @@ function restoreSession(){
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 : [];
ensureParaState();
if(essayText !== SAMPLE_ESSAY.join('\n\n')) openEditor();
else renderEntryEssay();
@@ -558,6 +577,7 @@ function restoreSession(){
}
function restoreChatHistory(){
renderRecheckHistory();
if(!chatLog || !chatLog.length) return;
chat.innerHTML = '';
// silent 重放:只建 DOM 不重复入日志;按钮由 bindQuickActions 统一重绑,
@@ -713,6 +733,9 @@ function disableCompletedActions(){
});
}
function handleAction(action){
// 复检卡上的「查看复检历史」:两处(通过卡/返工卡)共用,面板 fixed 定位,
// 对话页与 Workbench 都能显示。
if(action==='view-recheck-history'){ renderRecheckHistory(); toggleRecheckHistory(true); return; }
if(action==='confirm-understanding'){
// 防重入:诊断请求进行中忽略连点(用户实测连点——修复前两次并发请求 +
// 两个确认气泡 + 两张诊断卡,见 Deviations #23);runDiagnose 内另有同守卫兜底
@@ -987,7 +1010,12 @@ function renderOriginalPane(){
function updateProgress(){
const n = rewrittenCount();
const total = paragraphs.length;
$('#progressText').textContent = `已有改写 ${n} 段 · 共 ${total}`;
// 体验优化:全文词数提示(未改写段按原文计)——对照 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:复检通过 = 本轮结束(状态流转"通过 → 结束",用户实测"通过后
// 仍可再提交复检")。通过后不再提供提交复检入口——再次复检会以
@@ -1025,6 +1053,76 @@ function renderSaveChip(){
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 resetHelpPanels(){
const key = pidOf(current);
const st = helpState[key] || {scaffold:false, reference:false};
@@ -1046,8 +1144,12 @@ function renderWorkbench(){
$('#nextPara').disabled = current===paragraphs.length-1;
renderOriginalPane();
renderSaveChip();
renderWordMeta();
renderTranslateBtn();
renderTranslateBox();
resetHelpPanels();
updateProgress();
renderRecheckHistory();
}
async function submitConstraint(){
const input = $('#constraintInput');
@@ -1122,6 +1224,9 @@ function setOriginalView(mode){
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{
@@ -1259,6 +1364,7 @@ async function runRecheck(){
if(!resp.checked_rewrite_version) resp.checked_rewrite_version = version;
removeEl(loading);
lastRecheckResult = resp;
pushRecheckHistory(resp);
agentStage='recheck';
persistNow();
renderRecheck(resp);
@@ -1268,6 +1374,72 @@ async function runRecheck(){
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);
@@ -1292,8 +1464,9 @@ function renderRecheck(r){
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></div>`);
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{
@@ -1303,10 +1476,11 @@ function renderRecheck(r){
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></div>`);
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=[
@@ -1345,7 +1519,9 @@ $('#backToChat').onclick=()=>{
disableCompletedActions();
};
$('#sendChat').onclick=()=>processChat($('#chatInput').value);
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();processChat(e.target.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');
@@ -1353,6 +1529,16 @@ $('#rewriteArea').addEventListener('input', onRewriteInput);
$('#rewriteArea').addEventListener('blur', onRewriteBlur);
$('#showScaffold').onclick=toggleScaffold;
$('#showReference').onclick=toggleReference;
$('#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;
+19
View File
@@ -20,6 +20,7 @@ from prompts import (
build_recheck_prompt,
build_reference_prompt,
build_scaffold_prompt,
build_translate_prompt,
normalize_diagnose_payload,
)
from schemas import (
@@ -35,6 +36,8 @@ from schemas import (
RevisionTarget,
ScaffoldRequest,
ScaffoldResponse,
TranslateRequest,
TranslateResponse,
)
app = FastAPI(title="Human Voice Rewrite Demo", version="1.1.0")
@@ -198,6 +201,22 @@ def reference(req: ReferenceRequest) -> ReferenceResponse:
return resp
@app.post("/api/translate", response_model=TranslateResponse)
def translate(req: TranslateRequest) -> TranslateResponse:
"""体验优化:改写区「译成中文对照」——顾问自查改写后原意/细节是否保留。"""
if not req.text.strip():
raise HTTPException(status_code=422, detail="没有可翻译的文本")
system, user = build_translate_prompt(req.text)
data = run_json("翻译", system, user)
translation = data.get("translation")
if isinstance(translation, list): # 模型偶发把字符串字段返回成数组
translation = "".join(str(x) for x in translation)
resp = TranslateResponse(paragraph_id=req.paragraph_id, translation=str(translation or "").strip())
if not resp.translation:
raise HTTPException(status_code=502, detail="翻译结果为空,请重试")
return resp
@app.get("/")
def index() -> FileResponse:
return FileResponse(HERE / "index.html", media_type="text/html")
+15
View File
@@ -307,6 +307,21 @@ def build_reference_prompt(
return system, user
# --------------------------------------------------------------------------
# 体验优化:中文对照翻译(顾问在改写区自查「改写后原意有没有跑偏」)
def build_translate_prompt(text: str) -> tuple[str, str]:
system = """你是 Human Voice Rewrite 工作流的中文对照翻译器。
要求:
- 把用户给的英文文本逐句翻译成自然中文,供顾问核对「改写后是否守住了原意」。
- 忠实优先:保留细节、语气与情绪强度;不润色、不省略、不补写原文没有的信息。
- 不做评价、不建议、不加注释;只输出译文本身。
- 只输出 JSON{"translation": ""}"""
user = f"英文文本:\n{text}\n\n请输出 JSON。"
return system, user
def _as_text(value, sep="") -> str:
"""模型偶发把字符串字段返回成数组(如 ai_focus 给了 1–3 类问题的列表,
2026-08-21 用户实测 4 段全中)——统一规整为字符串,避免 pydantic
+12
View File
@@ -167,3 +167,15 @@ class ReferenceResponse(BaseModel):
paragraph_id: str = ""
starter: str = "" # 1-2 句英文局部参考,非整段
reference_snippet: str = ""
# ------------------------------------------------------- 体验优化:中文对照翻译
class TranslateRequest(BaseModel):
text: str = Field(min_length=1)
# 段落上下文(可选):帮助模型稳定语气,不参与翻译输出
paragraph_id: str = ""
class TranslateResponse(BaseModel):
paragraph_id: str = ""
translation: str = ""
+26
View File
@@ -510,6 +510,32 @@ def test_api_scaffold_shape():
assert "liked" in r.json()["scaffold"]
def test_api_translate_shape():
payload = {"translation": "我不断回到这个问题上。"}
r = api_client(payload).post(
"/api/translate",
json={"text": "I kept coming back to the problem.", "paragraph_id": "p3"},
)
assert r.status_code == 200
assert r.json()["translation"].startswith("我不断回到")
assert r.json()["paragraph_id"] == "p3"
def test_api_translate_coerces_list_translation():
"""模型偶发把 translation 返回成数组 —— 归并为一个字符串,不 502。"""
r = api_client({"translation": ["第一句。", "第二句。"]}).post(
"/api/translate", json={"text": "One. Two."}
)
assert r.status_code == 200
assert r.json()["translation"] == "第一句。第二句。"
def test_api_translate_empty_translation_502():
r = api_client({"translation": " "}).post("/api/translate", json={"text": "Hello."})
assert r.status_code == 502
assert "翻译结果为空" in r.json()["detail"]
def test_complete_json_paragraph_count_mismatch_retries_once():
"""语义校验:段落数不匹配视为一次失败并自动重试,重试成功返回完整结果。
真实 LLM 偶发返回合法 JSON 但段落条目不全(AI 初审"4 段只显示 1 段"),