diff --git a/index.html b/index.html index e0c5b59..a5582d0 100644 --- a/index.html +++ b/index.html @@ -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
基于左侧原意重新表达这一整段,不需要逐句对应原文。
-
自动保存0 characters
+
自动保存0 characters
+
+
需要一点帮助?
@@ -210,7 +212,8 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
-
+
+
Demo 工具
@@ -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.lengthb.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 = `中文对照(用于自查改写后原意是否保留)${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 = `
复检历史(最新在前,点建议可再次进入改写)
` + 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 ? '✓ 通过' : `需返工 ${tIdx>=0?paragraphLabel(tIdx):''}`; + 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 `
+
${time}${badge}${idx===0?'最新':''}
+ ${meta?`
${meta}
`:''} + ${rec.target&&rec.target.single_revision_goal?`
建议:${escapeHtml(rec.target.single_revision_goal)}
`:''} + ${rec.target?``:''} +
`; + }).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(`
Human Voice Recheck

本轮 Human Voice Rewrite 已完成。

${ver}

我把 ${paragraphs.length} 段放回完整文章重新读了一遍。本次只做最终验收,没有重新开启一轮问题盘点。

${rows}
检查项状态
可以结束这一轮 Human Voice Rewrite。剩余问题主要属于个人风格选择,不建议继续为了“更不像 AI”大面积修改。

本次改写后的全文已生成 版本 ${nextVersion}。点击下方按钮回文书页,可在文书页顶部切换「原版 / 版本 ${nextVersion}」对比。

`); + addAgent(`
Human Voice Recheck

本轮 Human Voice Rewrite 已完成。

${ver}

我把 ${paragraphs.length} 段放回完整文章重新读了一遍。本次只做最终验收,没有重新开启一轮问题盘点。

${rows}
检查项状态
可以结束这一轮 Human Voice Rewrite。剩余问题主要属于个人风格选择,不建议继续为了“更不像 AI”大面积修改。

本次改写后的全文已生成 版本 ${nextVersion}。点击下方按钮回文书页,可在文书页顶部切换「原版 / 版本 ${nextVersion}」对比。

`); 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?`
Evidence:
${t.evidence.map(x=>'• '+escapeHtml(x)).join('
')}
`:''; track('recheck_revision_required', {paragraph_id: t.paragraph_id}); - addAgent(`
Human Voice Recheck

全文复检:${escapeHtml(paragraphLabel(idx))} 还需要返工一次

${ver}

这次不重复首轮的 Pattern 教学,只看本轮重写有没有真正生效、上下文是否统一。

${rows}
检查项状态

这次只返工 ${escapeHtml(paragraphLabel(idx))}

${escapeHtml(t.blocking_issue)}

${ev}
本次只返工一件事:${escapeHtml(t.single_revision_goal)}
`); + addAgent(`
Human Voice Recheck

全文复检:${escapeHtml(paragraphLabel(idx))} 还需要返工一次

${ver}

这次不重复首轮的 Pattern 教学,只看本轮重写有没有真正生效、上下文是否统一。

${rows}
检查项状态

这次只返工 ${escapeHtml(paragraphLabel(idx))}

${escapeHtml(t.blocking_issue)}

${ev}
本次只返工一件事:${escapeHtml(t.single_revision_goal)}
`); 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 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") diff --git a/prompts.py b/prompts.py index aed29f8..34dccfc 100644 --- a/prompts.py +++ b/prompts.py @@ -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 diff --git a/schemas.py b/schemas.py index aabc547..24c99b8 100644 --- a/schemas.py +++ b/schemas.py @@ -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 = "" diff --git a/test_demo.py b/test_demo.py index 518ed65..87ea1db 100644 --- a/test_demo.py +++ b/test_demo.py @@ -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 段"),