feat: 对话层 Agent 化(对话底座 + 自研 skill)

把对话页从固定状态机换成 skill 驱动的 Agent:顾问说什么都原样发给 agent,
由 SKILL.md + 工具决定该聊还是该调工具。工作台(workbenchView)未改动。

后端
- llm.py:新增 stream_chat()(流式 + tools),并带 reasoning 端点自动降级
  (gemini-3.7-flash 拒绝 effort:none,首次 400 后锁定重试)
- agent.py:最小对话底座内核——skill 注入 + 工具循环 + SSE 事件
- tools.py:8 个工具包住既有能力(analyze/diagnose/scaffold/reference/
  translate/recheck/record_note/confirm),延迟 import main 复用已验证的
  endpoint 处理器与测试接缝,prompts.py/schemas.py/evidence.py 未改一行
- main.py:POST /api/chat/stream(SSE)

skill
- skills/hvr-rewrite/SKILL.md:分诊规则、首轮盘点、AI 味清单对齐
  blader/humanizer 的 A–E 分类(含强度校准与反误报)、诚实性硬约束

前端
- 流式正文 + 工具卡片 + 选项按钮;工具 payload 直接喂既有渲染函数
  (renderUnderstanding/renderDiagnosis/renderRecheck),没有第二套 UI
- 删除随 agent 化失效的 runDiagnose / applyUnderstandingCorrection 死代码

验证(真实模型,非 mock)
- 65 单测全绿(test_agent.py 30 + test_demo.py 35)
- 逐轮实跑:盘点 → 确认 → 诊断 → 复检,SSE 事件序列与 payload kind 均符合契约
- 前端 readSSE 用真实响应字节按 7/64/全量三种切块回放,事件序列一致
- 实测修掉两个只在真跑时暴露的问题:模型调完分析直接调确认工具导致正文为空
  (SKILL.md 补「正文先行」硬规则);伪标题 `**N. 标题**` 让列表判定失败、
  短横线漏成字面字符(前端改逐行分组渲染)

未验证:浏览器人工走查(无浏览器自动化环境),仅到「真实 HTTP + 真实字节回放」这一层。
This commit is contained in:
LuminousRuoxi
2026-09-11 10:25:12 +08:00
parent 493c7f6416
commit 94a26227c1
7 changed files with 2170 additions and 73 deletions
+260 -71
View File
@@ -46,6 +46,20 @@ button,input,textarea{font:inherit}button{cursor:pointer}.hidden{display:none!im
.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 对话(SSE 流式):正文逐字出 + 工具卡片 + 选项按钮 */
.stream-body p{margin:0 0 10px}.stream-body p:last-child{margin-bottom:0}
.stream-body ul.agent-list{margin:0 0 10px;padding-left:18px}.stream-body ul.agent-list li{margin-bottom:4px}
.stream-error{color:#c0392b;font-size:13px;margin:8px 0 0}
.tool-cards{display:flex;flex-direction:column;gap:6px;margin-top:10px}
.tool-card{display:flex;align-items:center;gap:8px;background:#f6f7fc;border:1px solid #e6e8f4;border-radius:8px;padding:7px 11px;font-size:12px;color:#555c70}
.tool-card .tool-name{font-weight:650;color:#4f5668;white-space:nowrap;flex:none}
.tool-card .tool-sum{color:#7a8095;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tool-card.running{border-style:dashed}
.tool-card.failed{background:#fff6f6;border-color:#f2d5d5}
.tool-card.failed .tool-name{color:#a94442}.tool-card.failed .tool-sum{color:#c0392b;white-space:normal}
.tool-dot{width:7px;height:7px;border-radius:50%;background:#3bb273;flex:none}
.tool-card.failed .tool-dot{background:#e05c5c}
.choice-q{margin:0;font-size:13px;color:#4f5668;font-weight:650;flex:0 0 100%}
.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}
@@ -505,7 +519,7 @@ function persistNow(){
referencesShown, scaffoldsShown, currentRewriteVersion,
lastRecheckSnapshot, lastRecheckResult, analysisVersion, agentStage,
chatLog, essayVersions, viewedVersion, recheckHistory, translations, translateOpen,
origTranslations, origTranslateOpen
origTranslations, origTranslateOpen, chatSessionId
}));
return true;
}catch(e){ return false; }
@@ -559,6 +573,9 @@ function restoreSession(){
lastRecheckResult = s.lastRecheckResult || null;
analysisVersion = s.analysisVersion || 1;
chatLog = Array.isArray(s.chatLog) ? s.chatLog : [];
// 续接服务端会话:不恢复的话刷新后同一条历史里发出的消息会落到新开的空会话,
// agent 会忘记刚才聊过什么(服务端进程重启同样会落到这条兜底路径,行为一致)
chatSessionId = typeof s.chatSessionId === 'string' ? s.chatSessionId : null;
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;
@@ -646,29 +663,12 @@ async function runAnalyze(){
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);
}
show('agentView'); chat.innerHTML=''; chatLog=[]; chatSessionId=null;
agentStage='loading';
// 首轮不再直接打 /api/analyze:交给 agent 按 SKILL.md「首轮盘点」自己调 analyze_essay
// 盘点结论 + 理解卡一起给出,并由它用 let_user_confirm 问顾问下一步。
// 改造前这里是一条固定流水线(分析 → 等确认 → 诊断),现在由 skill 驱动。
streamAgent('请开始首轮盘点。');
}
async function requestDiagnosis(){
@@ -681,29 +681,10 @@ async function requestDiagnosis(){
});
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));
}
}
// 原先这里有一条对话页专用的 runDiagnose(带 loading 卡、跑 /api/diagnose 再渲染诊断卡)。
// Agent 化之后诊断由 agent 调 diagnose_essay 工具触发,载荷经 applyToolPayload 走同一条
// renderDiagnosis,这个函数已无调用点,删掉避免两套并行的诊断路径各自漂移。
// 工作台的 submitConstraint 仍直接调 requestDiagnosis —— 那是工作台自己的路径,未改动。
function renderDiagnosis(){
const d = diagnosis;
@@ -754,10 +735,14 @@ function handleAction(action){
// 对话页与 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();
// 防重入:这一轮 agent 还没跑完就忽略连点(原先防的是两次并发 /api/diagnose
// 连点会各渲染一张诊断卡;现在连点会各起一条 SSE 流,等价问题)
if(agentBusy||agentStage==='diagloading') return;
track('understanding_confirmed');
addUser('理解准确,继续。');
// 不再直接调 runDiagnose():由 agent 自己决定下一步并调 diagnose_essay
// 它要能在这条消息里同时处理「确认 + 顺带的补充要求」
streamAgent('顾问确认理解准确,请继续。');
}
if(action==='adjust-understanding'){const input=$('#chatInput');input.placeholder='例如:第三段重点不是接受不确定,而是更关注推理过程……';input.focus();}
// 每个按钮给出自己的占位提示再聚焦(只 focus 会把其它按钮的提示残留下来——用户实测):
@@ -826,30 +811,232 @@ function showInputHint(){
clearTimeout(hint._t);
hint._t=setTimeout(()=>hint.classList.add('hidden'),3000);
}
/* ===== Agent 对话(SSE 流式)=====
原先是按 agentStage 分支回复的固定状态机(理解态收补充→要求再点确认、诊断态收补充→
直接重跑…)。现在用户说什么都原样发给 /api/chat/stream,由 skill 驱动的 agent 自己
判断该聊还是该调工具。
agentStage 保留下来了,但语义变了:它不再是「回复分支的依据」,而是「哪些工具跑过」
的结果——工作台入口与按钮置灰仍按它判断(renderDiagnosis 的【进入逐段改写】、
disableCompletedActions 的置灰规则都依赖它)。*/
let chatSessionId = null;
let agentBusy = false;
const TOOL_LABEL = {
analyze_essay:'读懂全文', diagnose_essay:'诊断 AI 味',
get_writing_scaffold:'给写作起点', get_reference_snippet:'给参考片段',
translate_to_chinese:'中文对照', recheck_rewrite:'全文复检',
record_advisor_note:'记下你的要求', let_user_confirm:'等你确认',
};
// 这三个工具不是流式出字的(走 complete_json 保住校验重试),调用期间只有工具卡片在转,
// 长文跑 1 分钟以上很正常——不写清楚顾问会以为卡死了
const SLOW_TOOLS = {analyze_essay:'正在读懂全文,长文可能要 1 分钟以上…',
diagnose_essay:'正在逐段诊断,可能要 1 分钟以上…',
recheck_rewrite:'正在全文复检,可能要 1 分钟以上…'};
function toolLabel(name){ return TOOL_LABEL[name] || name; }
// 只认模型真正常用的那三种(粗体/行内代码/标题)。输入已经过 escapeHtml
// 这里插进去的标签全是我们自己拼的,模型的 <> 早变成实体了,不会二次注入。
function mdInline(s){
return s.replace(/\*\*([^*\n]+)\*\*/g,'<strong>$1</strong>').replace(/`([^`\n]+)`/g,'<code>$1</code>');
}
function renderAgentText(escaped){
// 入参已 escapeHtml——流式渲染拼 DOM 时必须先转义再拼,模型输出等同用户输入
return String(escaped).split(/\n{2,}/).map(block=>{
// 逐行分组而不是「整块要么是列表要么是段落」:模型常写成
// 「**2. 最像 AI 写的地方**」这种伪标题 + 紧跟几行短横线,整块判定会把 '-' 漏成字面字符
const out = []; let para = []; let items = [];
const flushPara = ()=>{ if(para.length){ out.push(`<p>${mdInline(para.join('<br>'))}</p>`); para=[]; } };
const flushList = ()=>{ if(items.length){ out.push('<ul class="agent-list">'+items.map(x=>`<li>${mdInline(x)}</li>`).join('')+'</ul>'); items=[]; } };
block.split('\n').map(l=>l.trim()).filter(Boolean).forEach(line=>{
const item = line.match(/^[-•*]\s+(.*)$/);
const head = line.match(/^#{1,6}\s+(.*)$/);
if(item){ flushPara(); items.push(item[1]); return; }
if(head){ flushList(); flushPara(); out.push(`<h2>${mdInline(head[1])}</h2>`); return; }
flushList(); para.push(line);
});
flushPara(); flushList();
return out.join('');
}).join('');
}
// POST 用不了 EventSource,手工解 SSE 帧:`event: X\ndata: {...}\n\n`
async function readSSE(resp, onEvent){
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '';
for(;;){
const {done, value} = await reader.read();
if(done) break;
buf += dec.decode(value, {stream:true}).replace(/\r\n/g,'\n');
let i;
while((i = buf.indexOf('\n\n')) >= 0){
const frame = buf.slice(0,i); buf = buf.slice(i+2);
let name = 'message', data = '';
frame.split('\n').forEach(line=>{
if(line.startsWith('event:')) name = line.slice(6).trim();
else if(line.startsWith('data:')) data += line.slice(5).trim();
});
if(data){ try{ onEvent(name, JSON.parse(data)); }catch(e){} }
}
}
}
function agentPayload(message){
// 业务上下文每轮重发:顾问可能刚在工作台改完稿。服务端 session 只留分析/诊断结果。
const base = {
session_id: chatSessionId, message,
prompt: promptText, word_limit: wordLimit, paragraphs: paragraphs.slice(),
constraints: globalConstraints.slice(),
rewrite_paragraphs: paragraphs.map((_,i)=>rewrites[i]||''),
confirmed_anchors: ((analysis&&analysis.paragraphs)||[]).map(p=>p.semantic_anchor||p.natural_meaning_zh||''),
paragraph_constraints: paragraphConstraints,
};
// 新会话第一轮才补上分析/诊断:刷新或服务端重启后 ctx 是空的,不带的话
// 复检会丢掉语义锚点与 Pattern 对照;拿到 session_id 之后服务端自己留着。
if(!chatSessionId){ base.analysis = analysis; base.diagnosis = diagnosis; }
return base;
}
function applyToolPayload(payload, pending){
// 工具返回的是与旧 endpoint 完全相同的结构,所以这里直接喂给已有的渲染函数——
// 复检卡、工作台入口、置灰规则、版本推进全部复用,没有第二套 UI。
const data = payload.data || {};
const pid = data.paragraph_id || '';
if(payload.kind==='analysis'){
analysis = data; analysisVersion += 1; agentStage = 'understanding';
pending.push(()=>renderUnderstanding());
}else if(payload.kind==='diagnosis'){
diagnosis = data; agentStage = 'diagnosis';
pending.push(()=>renderDiagnosis());
}else if(payload.kind==='recheck'){
// 与工作台 runRecheck 同一套收尾:先落结果与历史,再由 renderRecheck 决定
// 通过(推进版本)还是返工(设 revisionTarget)。
lastRecheckResult = data;
if(!data.checked_rewrite_version) data.checked_rewrite_version = `rv_${currentRewriteVersion}`;
pushRecheckHistory(data);
agentStage = 'recheck';
pending.push(()=>renderRecheck(data));
}else if(payload.kind==='constraint'){
// 后端把顾问要求记进 ctx 了,前端本地副本同步一份——下一轮会重发上下文,
// 两边不同步就会互相覆盖
if(pid){
const bucket = (paragraphConstraints[pid] = paragraphConstraints[pid]||[]);
if(!bucket.includes(data.note)) bucket.push(data.note);
}else if(!globalConstraints.includes(data.note)) globalConstraints.push(data.note);
}else if(payload.kind==='scaffold'){
// 走 recordExposure 而不是自己 push:复检的「有没有套用已展开的参考」
// 读的是这条记录(含段号+版本),格式不一致等于没记
if(data.scaffold){
const key = pid || pidOf(current);
helpState[key] = helpState[key] || {};
helpState[key].scaffold = true;
helpState[key].scaffoldText = data.scaffold;
recordExposure('scaffold', data.scaffold, key);
}
}else if(payload.kind==='reference'){
const text = data.reference_snippet || data.starter || '';
if(text){
const key = pid || pidOf(current);
helpState[key] = helpState[key] || {};
helpState[key].reference = true;
helpState[key].referenceText = text;
recordExposure('reference', text, key);
}
}else if(payload.kind==='translation'){
const t = data.translation || '';
// 只渲染到对话里,不写 translations[] 缓存:那份缓存按「改写稿文本相同」判新鲜,
// 而 agent 翻的可能是原文,混进去会让工作台把原文译文当成改写稿对照显示。
if(t) pending.push(()=>addAgent(`<div class="eyebrow">中文对照</div><p class="muted">${escapeHtml(pid||'')}</p><div class="summary">${escapeHtml(t)}</div>`));
}
}
function renderChoices(container, data){
const wrap = document.createElement('div');
wrap.className = 'quick-actions';
wrap.innerHTML = `<p class="choice-q">${escapeHtml(data.question||'')}</p>` +
(data.choices||[]).map((c,i)=>`<button class="quick${i===0?' primary':''}" data-choice="${escapeHtml(c)}">${escapeHtml(c)}</button>`).join('');
container.appendChild(wrap);
wrap.querySelectorAll('[data-choice]').forEach(b=>b.onclick=()=>{
wrap.querySelectorAll('button').forEach(x=>{x.disabled=true;});
addUser(b.dataset.choice);
streamAgent(b.dataset.choice);
});
scrollChat();
}
function streamAgent(message){
if(agentBusy) return;
agentBusy = true;
const m = document.createElement('div');
m.className = 'message';
m.innerHTML = '<div class="avatar">AI</div><div class="bubble"><div class="stream-body"></div><div class="tool-cards"></div></div>';
chat.appendChild(m); scrollChat();
const body = m.querySelector('.stream-body');
const cards = m.querySelector('.tool-cards');
const toolEls = {}; const pending = [];
let text = '', failed = '';
const commit = () => {
agentBusy = false;
if(failed) body.insertAdjacentHTML('beforeend', `<p class="stream-error">${escapeHtml(failed)}</p>`);
if(!text && !failed && !cards.children.length && !pending.length){ removeEl(m); return; }
// agentStage 不能停在 loading*:那三个值会锁住输入框,而这一轮已经结束了。
// 工具跑过的话 applyToolPayload 已经把它推到 understanding/diagnosis/recheck。
if(agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){
agentStage = analysis ? (diagnosis ? 'diagnosis' : 'understanding') : 'idle';
}
// 流结束才进历史快照:中途刷新 = 这轮作废(半截话留着反而误导)
m._logId = ++chatLogSeq;
chatLog.push({id:m._logId, t:'msg', html:m.querySelector('.bubble').innerHTML});
pending.forEach(fn=>{ try{ fn(); }catch(e){ track('tool_payload_render_failed',{msg:e.message}); } });
bindQuickActions(); disableCompletedActions(); persistNow(); scrollChat();
};
(async () => {
try{
const resp = await fetch('/api/chat/stream', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(agentPayload(message||'')),
});
if(!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
await readSSE(resp, (name, data)=>{
if(name==='session_id') chatSessionId = data.session_id;
else if(name==='token'){
text += data.text; body.innerHTML = renderAgentText(escapeHtml(text)); scrollChat();
}
// thinking 不渲染:顾问要的是结论,不是模型的草稿纸
else if(name==='tool_call'){
const el = document.createElement('div');
el.className = 'tool-card running';
el.innerHTML = `<span class="spinner"></span><span class="tool-name">${escapeHtml(toolLabel(data.tool))}</span><span class="tool-sum">${escapeHtml(SLOW_TOOLS[data.tool]||'')}</span>`;
cards.appendChild(el); toolEls[data.tool_call_id] = el; scrollChat();
// 复检跑起来的时候就打快照(与工作台 runRecheck 一致):复检要几十秒,
// 这段时间顾问可能切到工作台改稿。onRewriteInput 拿 lastRecheckSnapshot.version
// 比对来决定要不要给 currentRewriteVersion +1——快照晚打就会把改过的稿当成已检稿。
if(data.tool==='recheck_rewrite'){
lastRecheckSnapshot = {version:`rv_${currentRewriteVersion}`, paragraphs: rewrites.map(r=>r||'')};
dirtyDuringRecheck = false;
}
}
else if(name==='tool_result'){
const el = toolEls[data.tool_call_id];
if(el){
el.className = 'tool-card' + (data.ok?'':' failed');
el.innerHTML = `<span class="tool-dot"></span><span class="tool-name">${escapeHtml(toolLabel(data.tool))}</span><span class="tool-sum">${escapeHtml(data.result_summary||'')}</span>`;
}
if(data.ok && data.payload) applyToolPayload(data.payload, pending);
scrollChat();
}
else if(name==='tool_request_clarify') renderChoices(cards, data);
else if(name==='error') failed = data.msg || '出了点问题,请重试';
});
}catch(e){ failed = e.message || '网络异常'; }
commit();
})();
}
function processChat(text){
if(!text.trim())return;
if(agentStage==='loading'||agentStage==='diagloading'||agentStage==='recheckloading'){
if(agentBusy||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>`);
}
streamAgent(text.trim());
}
function fusedAiFocus(i){
@@ -1369,9 +1556,11 @@ function onRewriteBlur(){
updateParagraphSwitcherState(); // 不重建按钮:避免点击段号时 mousedown→blur 重建导致第一次点击丢失
updateProgress();
}
function recordExposure(kind, text){
function recordExposure(kind, text, pid){
if(!text) return;
const rec = {paragraph_id: pidOf(current), text, shown_at: Date.now(), analysis_version: analysisVersion, rewrite_version: currentRewriteVersion};
// pid 省略 = 工作台当前段(原调用点行为)。agent 在对话里要的支架/参考可能不是当前段,
// 记错段号会让复检的「有没有套用已展开的参考」按错误的段去比对。
const rec = {paragraph_id: pid || 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)){