156 lines
20 KiB
JavaScript
156 lines
20 KiB
JavaScript
'use strict';
|
||
const $ = s => document.querySelector(s);
|
||
const $$ = s => Array.from(document.querySelectorAll(s));
|
||
const token = $('meta[name="workbench-token"]').content;
|
||
let overview = null, settings = {}, currentRun = null, currentPlan = null, fileData = null, currentCard = null, poll = null;
|
||
const signals = {repeat:'反复提问',risk:'风险 / 事故线索',rework:'重复踩坑线索',future:'后续复用线索'};
|
||
const fields = {source_id:'消息 ID', conversation:'会话名称', conversation_id:'会话 ID', kind:'会话类型', sender:'发送人名称', sender_id:'发送人 ID', timestamp:'时间', content:'内容', message_type:'消息类型', quote:'引用'};
|
||
const titles = {connect:'连接 Mac 微信',overview:'看见对话中的知识需求',import:'导入你的聊天记录',analyze:'发现值得沉淀的知识',settings:'模型接口与隐私设置'};
|
||
|
||
function el(tag, text, className) { const n = document.createElement(tag); if(text !== undefined) n.textContent = text; if(className) n.className=className; return n; }
|
||
function notify(text, error=false) { const n=$('#notice'); n.textContent=text; n.hidden=false; n.classList.toggle('error',error); }
|
||
async function api(path, body) {
|
||
const opts=body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json','X-Workbench-Token':token},body:JSON.stringify(body)};
|
||
const response=await fetch(path,opts); const data=await response.json(); if(!response.ok)throw Error(data.error||'请求失败'); return data;
|
||
}
|
||
async function busy(button, work) {button.disabled=true;try{await work();}catch(e){notify(e.message,true);}finally{button.disabled=button.id.startsWith("mac-")&&button.id!=="mac-refresh"&&macRunning;}}
|
||
function page(name) {if(!titles[name])name='overview';$$('.page').forEach(n=>n.hidden=n.id!=='page-'+name);$$('nav button').forEach(n=>n.classList.toggle('active',n.dataset.page===name));$('#page-title').textContent=titles[name];location.hash=name;}
|
||
function displayTime(time) {
|
||
if(!time)return '—'; const offset=settings.utc_offset||'+08:00'; const sign=offset[0]==='-'?-1:1;
|
||
const minutes=sign*(Number(offset.slice(1,3))*60+Number(offset.slice(4,6)));
|
||
const dt=new Date(new Date(time).getTime()+minutes*60000);
|
||
return dt.toISOString().slice(0,19).replace('T',' ');
|
||
}
|
||
function selected(select) {return Array.from(select.selectedOptions).map(n=>n.value);}
|
||
function filters() {
|
||
const start=$('#start').value,end=$('#end').value;
|
||
if(start&&end&&start>end)throw Error('开始时间不能晚于结束时间');
|
||
return {start,end,kind:$('#filter-kind').value,conversations:selected($('#include-conversations')),exclude:selected($('#exclude-conversations')),search:$('#filter-search').value.trim(),utc_offset:settings.utc_offset||'+08:00'};
|
||
}
|
||
function invalidatePlan() {currentPlan=null;$('#preview-panel').hidden=true;}
|
||
|
||
async function refresh() {
|
||
overview=await api('/api/overview');
|
||
const stats=$('#stats');stats.replaceChildren();
|
||
const metrics=[['已导入消息',overview.count,'可重复导入,自动去重'],['会话',overview.conversations.length,'群聊与私聊'],['群聊',overview.conversations.filter(c=>c.kind==='group').length,'可跨群汇总相似问题'],['分析记录',overview.runs.length,'保留审核与修正']];
|
||
for(const [name,num,note] of metrics){const node=el('div',undefined,'stat');node.append(el('small',name),el('strong',num.toLocaleString()),el('span',note));stats.append(node);}
|
||
$('#conversations').replaceChildren();$('#empty').hidden=overview.count>0;
|
||
for(const c of overview.conversations){const tr=el('tr');for(const text of [c.conversation,c.kind==='group'?'群聊':'私聊',c.count,c.media,displayTime(c.first),displayTime(c.last)])tr.append(el('td',text));$('#conversations').append(tr);}
|
||
for(const selector of ['#include-conversations','#exclude-conversations']){const select=$(selector),old=new Set(selected(select));select.replaceChildren();for(const c of overview.conversations){const opt=el('option',`${c.conversation} · ${c.kind==='group'?'群':'私聊'} · ${c.count} 条`);opt.value=c.conversation_id;opt.selected=old.has(opt.value);select.append(opt);}}
|
||
const history=$('#history');history.replaceChildren();
|
||
for(const r of overview.runs){const b=el('button');b.append(el('span',`${displayTime(r.created)} · ${r.mode==='api'?'模型分析':'本地规则'}`),el('small',`${r.coverage.messages} 条记录 · ${r.coverage.conversations} 个会话 · ${{complete:'已完成',running:'进行中',failed:'失败'}[r.status]}`));b.addEventListener('click',()=>loadRun(r.id).catch(e=>notify(e.message,true)));history.append(b);}
|
||
if(!overview.runs.length)history.append(el('p','还没有分析记录。','muted'));
|
||
}
|
||
|
||
async function loadSettings() {
|
||
settings=await api('/api/settings');$('#api-endpoint').value=settings.api.endpoint||'';$('#api-model').value=settings.api.model||'';$('#custom-words').value=(settings.custom||[]).join('\n');$('#utc-offset').value=settings.utc_offset||'+08:00';$('#key-state').textContent=settings.has_key?'当前运行进程中已配置密钥;留空保存会保留它。':'当前没有内存密钥;本地免密接口可留空。';$('#timezone-label').textContent='筛选与显示时区:UTC'+settings.utc_offset+'。不填时间代表全部已导入记录。';
|
||
}
|
||
|
||
async function loadFile(file) {
|
||
fileData=null;$('#import-button').disabled=true;$('#sqlite-options').hidden=true;
|
||
if(!file)return;
|
||
if(file.size>45*1024*1024)throw Error('单个文件不能超过 45 MB,请拆分后导入');
|
||
const bytes=new Uint8Array(await file.arrayBuffer());let binary='';for(let i=0;i<bytes.length;i+=16384)binary+=String.fromCharCode(...bytes.subarray(i,i+16384));
|
||
fileData={filename:file.name,data:btoa(binary)};$('#file-info').textContent=`${file.name} · ${(file.size/1024).toFixed(1)} KB`;
|
||
if(/\.(db|sqlite|sqlite3)$/i.test(file.name)){
|
||
const schema=await api('/api/schema',fileData);$('#sqlite-table').replaceChildren();
|
||
for(const table of schema){const opt=el('option',table.table+' · '+table.columns.join(', '));opt.value=table.table;$('#sqlite-table').append(opt);}
|
||
if(!schema.length)throw Error('数据库中没有可导入的表');$('#sqlite-options').hidden=false;
|
||
}
|
||
$('#import-button').disabled=false;
|
||
}
|
||
|
||
async function loadRun(id) {
|
||
clearTimeout(poll); currentRun=await api('/api/run?id='+encodeURIComponent(id));page('analyze');$('#results').hidden=false;
|
||
const r=currentRun,c=r.coverage;$('#result-meta').textContent=`${r.mode==='api'?'模型提炼':'本地规则'} · ${c.messages} 条记录 · ${c.conversations} 个会话 · ${c.events} 个讨论候选 · ${displayTime(c.first)} 至 ${displayTime(c.last)}`;
|
||
$('#export-csv').href='/api/export?id='+r.id+'&format=csv';$('#export-md').href='/api/export?id='+r.id+'&format=md';
|
||
$('#export-csv').hidden=$('#export-md').hidden=r.status!=='complete';
|
||
const status=$('#run-status');status.hidden=r.status==='complete';status.textContent=r.status==='running'?'模型正在分析。分批串行发送;任务失败不会自动重试。可以留在页面等待。':r.error;
|
||
renderCards();if(r.status==='running')poll=setTimeout(()=>loadRun(id).catch(e=>notify(e.message,true)),2500);
|
||
}
|
||
|
||
function renderCards() {
|
||
const container=$('#cards');container.replaceChildren();if(!currentRun)return;
|
||
const search=$('#card-search').value.toLowerCase(),signal=$('#card-signal').value;
|
||
const cards=currentRun.cards.filter(c=>(!search||(c.title+' '+c.summary).toLowerCase().includes(search))&&(!signal||c.signals.includes(signal)));
|
||
for(const c of cards){
|
||
const n=el('article',undefined,'card'),top=el('div',undefined,'card-top'),label=el('label'),check=el('input');check.type='checkbox';check.value=c.id;check.className='card-select';check.setAttribute('aria-label','选择主题:'+c.title);label.append(check,el('h3',c.title));top.append(label,el('span','优先级 '+c.priority,'score'));n.append(top);
|
||
const tags=el('div',undefined,'tags');for(const s of c.signals)tags.append(el('span',signals[s],'tag '+s));tags.append(el('span',c.strategy,'tag'));n.append(tags,el('p',c.summary),el('div',`${c.event_count} 个独立讨论候选 · ${c.conversations.length} 个会话 · ${c.participants.length} 位参与人`,'card-meta'));
|
||
if(c.responders.length)n.append(el('div','经验提供者候选:'+c.responders.join('、'),'card-meta'));
|
||
const bottom=el('div',undefined,'card-bottom'),b=el('button','查看证据与审核 ↗');b.addEventListener('click',()=>openCard(c.id).catch(e=>notify(e.message,true)));bottom.append(el('small',(c.reviewed?'已人工审核':'待人工审核')+' · '+c.status),b);n.append(bottom);container.append(n);
|
||
}
|
||
if(!cards.length)container.append(el('div',currentRun.status==='complete'?'没有匹配的候选主题。当前规则可能未识别出问题,也可以扩大时间范围。':'任务尚未生成完整结果。','empty'));
|
||
}
|
||
|
||
async function openCard(id) {
|
||
currentCard=currentRun.cards.find(c=>c.id===id);if(!currentCard)return;
|
||
const card=currentCard;$('#detail-title').textContent=card.title;const content=$('#detail-content');content.replaceChildren();
|
||
const grid=el('div',undefined,'review-grid');
|
||
for(const [field,name] of [['title','主题'],['summary','摘要'],['answer','经验候选(尚需验证)'],['boundaries','适用边界与证据缺口'],['notes','人工备注']]){const label=el('label',name),input=el(field==='title'?'input':'textarea');input.id='edit-'+field;input.value=card[field]||'';if(input.tagName==='TEXTAREA')input.rows=3;label.append(input);grid.append(label);}
|
||
const strategyLabel=el('label','处理策略'),strategy=el('select');strategy.id='edit-strategy';for(const v of ['正式资产','轻量记录','专家带教','暂不投入']){const op=el('option',v);op.value=v;op.selected=v===card.strategy;strategy.append(op);}strategyLabel.append(strategy);grid.append(strategyLabel);
|
||
const label=el('label',undefined,'check'),reviewed=el('input');reviewed.type='checkbox';reviewed.id='edit-reviewed';reviewed.checked=card.reviewed;label.append(reviewed,document.createTextNode('已人工审核(不代表经过实践验证)'));grid.append(label);
|
||
const save=el('button','保存修正','primary');save.addEventListener('click',()=>busy(save,async()=>{const body={run_id:currentRun.id,ids:[id],action:'edit',strategy:strategy.value,reviewed:reviewed.checked};for(const field of ['title','summary','answer','boundaries','notes'])body[field]=$('#edit-'+field).value;await api('/api/card',body);await loadRun(currentRun.id);$('#detail-dialog').close();notify('已保存人工修正。');}));grid.append(save);content.append(grid);
|
||
content.append(el('h3','原始讨论与证据','detail-evidence-title'),el('p','下方展示候选事件的全部已收集上下文。★ 为该卡引用的消息。事件窗口可能遗漏延迟回复或混入其他话题。经验提供者仅按他人的较长非问句回复识别,不代表专家认定。','muted'));
|
||
const loading=el('p','正在读取本地原文…','muted');content.append(loading);$('#detail-dialog').showModal();
|
||
const events=await api('/api/context?run_id='+currentRun.id+'&card_id='+id);loading.remove();
|
||
const splitButton=el('button','将勾选的讨论拆成新主题');splitButton.disabled=events.length<2;const splitChecks=[];
|
||
for(const event of events){const box=el('div',undefined,'event'),l=el('label'),cb=el('input');cb.type='checkbox';cb.value=event.id;splitChecks.push(cb);l.append(cb,document.createTextNode(`${event.id} · ${event.conversation} · ${displayTime(event.messages[0].timestamp)}`));box.append(l);
|
||
for(const m of event.messages){const msg=el('div',undefined,'message');msg.append(el('div',`${card.evidence_ids.includes(m.id)?'★ ':''}#${m.id} · ${m.sender} · ${displayTime(m.timestamp)} · ${m.message_type}`,'msg-meta'),el('p',m.content||'[非文字消息,无文字内容]'));if(m.quote)msg.append(el('blockquote',m.quote));box.append(msg);}content.append(box);
|
||
}
|
||
splitButton.addEventListener('click',()=>busy(splitButton,async()=>{const ids=splitChecks.filter(c=>c.checked).map(c=>c.value);await api('/api/card',{run_id:currentRun.id,ids:[id],action:'split',event_ids:ids});await loadRun(currentRun.id);$('#detail-dialog').close();notify('已拆分主题,请重新审核。');}));content.append(splitButton);
|
||
}
|
||
|
||
for(const [field,label] of Object.entries(fields)){const l=el('label',label),input=el('input');input.dataset.mapping=field;input.placeholder='自动匹配';l.append(input);$('#mapping-fields').append(l);}
|
||
$$('[data-page]').forEach(n=>n.addEventListener('click',()=>page(n.dataset.page)));
|
||
$$('[data-go]').forEach(n=>n.addEventListener('click',()=>page(n.dataset.go)));
|
||
window.addEventListener('hashchange',()=>page(location.hash.slice(1)));
|
||
$$('[data-action="refresh"]').forEach(n=>n.addEventListener('click',()=>busy(n,refresh)));
|
||
$$('[data-action="demo"]').forEach(n=>n.addEventListener('click',()=>busy(n,async()=>{const r=await api('/api/demo',{});await refresh();notify(`已导入虚构样例 ${r.added} 条,重复 ${r.duplicates} 条。可直接进入“发现知识需求”。`);})));
|
||
$('#import-file').addEventListener('change',e=>loadFile(e.target.files[0]).catch(error=>{fileData=null;$('#import-button').disabled=true;notify(error.message,true);}));
|
||
$('#import-button').addEventListener('click',e=>busy(e.target,async()=>{if(!fileData)throw Error('请先选择文件');const mapping={};$$('[data-mapping]').forEach(n=>{if(n.value.trim())mapping[n.dataset.mapping]=n.value.trim();});const options={conversation:$('#default-conversation').value.trim(),kind:$('#default-kind').value,utc_offset:settings.utc_offset||'+08:00',mapping,table:$('#sqlite-table').value};const result=await api('/api/import',{...fileData,options});$('#import-result').hidden=false;$('#import-result').textContent=JSON.stringify(result,null,2);invalidatePlan();await refresh();notify(`导入完成:新增 ${result.added},重复 ${result.duplicates},拒绝 ${result.rejected}。${result.rejected?'请查看下方具体行号和原因。':''}`);}));
|
||
$('#save-settings').addEventListener('click',e=>busy(e.target,async()=>{await api('/api/settings',{api:{endpoint:$('#api-endpoint').value.trim(),model:$('#api-model').value.trim()},key:$('#api-key').value,clear_key:$('#clear-key').checked,utc_offset:$('#utc-offset').value.trim(),custom:$('#custom-words').value.split('\n').map(x=>x.trim()).filter(Boolean)});$('#api-key').value='';$('#clear-key').checked=false;invalidatePlan();await loadSettings();await refresh();notify('配置已保存,密钥未写入磁盘。');}));
|
||
$('#analyze-local').addEventListener('click',e=>busy(e.target,async()=>{const r=await api('/api/analyze',{filters:filters(),cross:$('#cross').checked});await loadRun(r.id);await refresh();notify('本地分析完成。请查看原文,核对主题与适用条件。');}));
|
||
$('#preview-api').addEventListener('click',e=>busy(e.target,async()=>{invalidatePlan();const p=await api('/api/preview',{filters:filters(),cross:$('#cross').checked});currentPlan=p.plan_id;$('#preview-summary').textContent=`目标:${p.endpoint} · ${p.batches} 批 · 约 ${p.characters.toLocaleString()} 字符 · ${p.coverage.messages} 条原始记录 / ${p.coverage.events} 个讨论候选`;$('#preview-text').value=JSON.stringify(p.requests,null,2);$('#preview-panel').hidden=false;$('#preview-panel').scrollIntoView({behavior:'smooth',block:'start'});}));
|
||
$('#send-api').addEventListener('click',e=>busy(e.target,async()=>{if(!currentPlan)throw Error('请重新生成预览');const id=currentPlan;invalidatePlan();const r=await api('/api/send',{plan_id:id});await loadRun(r.id);await refresh();notify('已开始发送预览中的脱敏请求。请保持本地服务运行。');}));
|
||
$('#close-preview').addEventListener('click',invalidatePlan);
|
||
['start','end','filter-kind','include-conversations','exclude-conversations','filter-search','cross'].forEach(id=>$('#'+id).addEventListener('change',invalidatePlan));
|
||
$('#card-search').addEventListener('input',renderCards);$('#card-signal').addEventListener('change',renderCards);
|
||
$('#close-detail').addEventListener('click',()=>$('#detail-dialog').close());
|
||
$('#merge-cards').addEventListener('click',()=>{const ids=$$('.card-select:checked').map(n=>n.value);if(ids.length<2)return notify('请至少勾选两张主题卡片。',true);$('#merge-title').value=currentRun.cards.find(c=>c.id===ids[0]).title;$('#merge-dialog').showModal();});
|
||
$('#cancel-merge').addEventListener('click',()=>$('#merge-dialog').close());
|
||
$('#confirm-merge').addEventListener('click',e=>busy(e.target,async()=>{await api('/api/card',{run_id:currentRun.id,ids:$$('.card-select:checked').map(n=>n.value),action:'merge',title:$('#merge-title').value.trim()});await loadRun(currentRun.id);$('#merge-dialog').close();notify('已合并主题,原摘要保留在备注中,请重新审核。');}));
|
||
let macTimer = null, lastMacJob = null, macRunning = false;
|
||
async function macStatus(){
|
||
clearTimeout(macTimer);
|
||
const state=await api('/api/mac/status');
|
||
$('#mac-platform').textContent=state.available?`本地服务运行于 Mac · ${state.architecture}。扫描和读取只操作这台 Mac。`:'当前服务运行于非 Mac 环境。请把工具复制到安装微信的 Mac 上启动,再在那台 Mac 的浏览器打开此页面。';
|
||
const running=state.job?.status==='running';
|
||
macRunning=running;
|
||
for(const id of ['mac-scan','mac-folder','mac-capture','mac-capture-admin','mac-dependencies','mac-import'])$('#'+id).disabled=!state.available||running;
|
||
$('#mac-key-file').disabled=running;
|
||
const select=$('#mac-account'),previous=select.value;select.replaceChildren();$('#mac-accounts').replaceChildren();
|
||
for(const account of state.accounts){
|
||
const option=el('option',account.name);option.value=account.id;option.selected=account.id===previous;select.append(option);
|
||
const node=el('div',undefined,'mac-account-info');node.append(el('strong',account.name),el('p',`${account.databases} 个核心数据库 · ${(account.bytes/1024**2).toFixed(1)} MiB · 已可读或密钥匹配 ${account.readable}/${account.databases}`),el('p',account.path,'muted'));$('#mac-accounts').append(node);
|
||
}
|
||
if(!state.accounts.length){const option=el('option','请先扫描或选择数据文件夹');option.value='';select.append(option);}
|
||
$('#mac-key-state').textContent=state.keys_ready?'候选密钥已在运行内存。账号卡上的匹配数量决定实际可读范围。重启知微后需重新读取。':'尚未读取或导入密钥;未加密的可读数据库可直接进入第 3 步。';
|
||
const job=state.job;$('#mac-job-panel').hidden=!job;
|
||
if(job){$('#mac-job-stage').textContent=job.stage;$('#mac-job-error').hidden=job.status!=='failed';$('#mac-job-error').textContent=job.error||'';$('#mac-job-result').hidden=!job.result;$('#mac-job-result').textContent=job.result?JSON.stringify(job.result,null,2):'';
|
||
if(job.status==='complete'&&lastMacJob!==job.id){lastMacJob=job.id;await refresh();notify(job.result?.message||'接入操作已完成');}
|
||
if(running)macTimer=setTimeout(()=>macStatus().catch(e=>notify(e.message,true)),1800);
|
||
}
|
||
return state;
|
||
}
|
||
async function macStart(action,extra={}){await api('/api/mac/start',{action,...extra});await macStatus();$('#mac-job-panel').scrollIntoView({behavior:'smooth',block:'center'});}
|
||
$('#mac-scan').addEventListener('click',e=>busy(e.target,async()=>{await api('/api/mac/scan',{});await macStatus();}));
|
||
$('#mac-folder').addEventListener('click',e=>busy(e.target,async()=>{await api('/api/mac/scan',{choose_folder:true});await macStatus();}));
|
||
$('#mac-refresh').addEventListener('click',e=>busy(e.target,macStatus));
|
||
$('#mac-capture').addEventListener('click',e=>busy(e.target,()=>macStart('capture')));
|
||
$('#mac-capture-admin').addEventListener('click',e=>busy(e.target,()=>macStart('capture',{administrator:true})));
|
||
$('#mac-dependencies').addEventListener('click',e=>busy(e.target,()=>macStart('dependencies')));
|
||
$('#mac-import').addEventListener('click',e=>busy(e.target,()=>macStart('import',{account:$('#mac-account').value,include_biz:$('#mac-include-biz').checked})));
|
||
$('#mac-key-file').addEventListener('change',async e=>{try{const file=e.target.files[0];if(!file)return;if(file.size>1024**2)throw Error('密钥文件最多 1 MB');await api('/api/mac/keys',{keys:JSON.parse(await file.text())});await macStatus();notify('密钥文件已在本机内存处理,不会发送给模型。');}catch(error){notify(error.message,true);}finally{e.target.value='';}});
|
||
async function init(){await loadSettings();await refresh();page(location.hash.slice(1)||'overview');await macStatus();}
|
||
init().catch(e=>notify(e.message,true));
|