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:
+260
-71
@@ -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)){
|
||||
|
||||
Reference in New Issue
Block a user