dsh-form-fill-agent 0.2.27 → 0.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AI填表智能体
2
2
 
3
- > 0.2.27:突出已填副本预览与下载,任务报告改为次要链接;侧栏仍为可选依赖。
3
+ > 0.2.29:统一简洁首页、稳定菜单与可恢复品牌标题,明确任务状态;侧栏仍为可选依赖。
4
4
 
5
5
  > 统一侧栏适配需要 Better Sidebar `>=0.17.1 <0.19.0`。五个流程入口打开同一 Session Tab;侧栏展开、收起和关闭由宿主管理。缺少依赖时会显示安装升级提示。
6
6
 
@@ -11,7 +11,7 @@ AI填表智能体:支持自动填表、表格填充、表格补全、Excel填
11
11
  ### 基础安装(不需要侧栏)
12
12
 
13
13
  ```sh
14
- dsh plugin --profile web add dsh-form-fill-agent@0.2.27
14
+ dsh plugin --profile web add dsh-form-fill-agent@0.2.29
15
15
  ```
16
16
 
17
17
  只需 Node.js >=22、完整 DSH 与主包自动安装的内核/Provider。Better Sidebar 和 dsh-context 都是可选项,不会由基础安装强装。
@@ -102,7 +102,7 @@ dsh plugin --profile web add dsh-form-fill-agent@latest
102
102
 
103
103
  ## 使用边界与数据保存
104
104
 
105
- 补充要求为模型可读文本;字段或工作表范围请通过向导/工作台修改,不能仅改文字后假定执行范围已同步。任务 schema 5 可读取 schema 1–5;回退旧版本请使用升级前目录副本或独立任务目录。主包为 0.2.27,共享内核为 0.2.10,Provider 为 0.2.2,共享字段契约为 0.1.0。
105
+ 补充要求为模型可读文本;字段或工作表范围请通过向导/工作台修改,不能仅改文字后假定执行范围已同步。任务 schema 5 可读取 schema 1–5;回退旧版本请使用升级前目录副本或独立任务目录。主包为 0.2.29,共享内核为 0.2.10,Provider 为 0.2.2,共享字段契约为 0.1.0。
106
106
 
107
107
  支持企业完整登记名称或 18 位信用代码;简称、主体不一致或多候选不会自动猜选。仅有信用代码的表格也可作为查询锚点。连接候选检索工具时可检索简称,仍需人工确认;缺少工具时降级人工输入。字段映射采用确定性规则及人工确认,不让模型编造事实。支持普通内部公式、固定区域下拉、基础条件格式和普通表格对象;复杂 Excel 结构和 Word 暂不支持,保真边界见下方安全说明。
108
108
 
package/lib/brand.css CHANGED
@@ -3,7 +3,7 @@
3
3
  .ff-ui[data-ff-theme=light]{--brand:#128bed;--action:#0875d1;--hover:#0666b7;--soft:#e6f4ff;--head:#f2f9fc;--page:#f6f8fa;--surface:#fff;--text:#202c3b;--muted:#626f80;--line:#dce4ec;--success:#12805c;--success-bg:#edf8f2;--review:#946000;--review-bg:#fff7e5;--error:#b42318;color-scheme:light}
4
4
  .ff-ui[data-ff-theme=dark]{--brand:#55adff;--action:#82c3ff;--hover:#acd7ff;--soft:#173449;--head:#172c3b;--page:#101820;--surface:#18232e;--text:#e7eef6;--muted:#a2b1c2;--line:#344657;--success:#78d8b3;--success-bg:#193a30;--review:#f3c66c;--review-bg:#3d321d;--error:#ffaaa5;color-scheme:dark}
5
5
  .ff-ui *{box-sizing:border-box}.ff-ui button,.ff-ui input,.ff-ui select,.ff-ui textarea{font:inherit}.ff-ui button,.ff-ui a.download{border:1px solid var(--line);border-radius:6px;padding:8px 12px;background:var(--surface);color:var(--text);min-height:36px;cursor:pointer;text-decoration:none}.ff-ui button:hover:not(:disabled),.ff-ui a.download:hover{border-color:var(--action);color:var(--action)}.ff-ui button:disabled{opacity:.5;cursor:not-allowed}.ff-ui :is(button,a,input,textarea,select):focus-visible{outline:2px solid var(--action);outline-offset:3px}.ff-ui .primary,.ff-ui a.download{background:var(--action);border-color:var(--action);color:var(--surface)}.ff-ui .primary:hover:not(:disabled),.ff-ui a.download:hover{background:var(--hover);color:var(--surface)}.ff-ui input:not([type=checkbox]):not([type=file]),.ff-ui textarea,.ff-ui select{border:1px solid var(--line);border-radius:6px;padding:8px;color:var(--text);background:var(--surface);max-width:100%;min-width:0}.ff-ui input[type=checkbox]{accent-color:var(--action);width:16px;height:16px}.ff-ui [hidden]{display:none!important}
6
- .ff-ui .ff-icon{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}.ff-ui .ff-mark{width:40px;height:40px;display:inline-grid;place-items:center;background:var(--soft);color:var(--action);border-radius:8px;flex-shrink:0}.ff-ui .ff-mark .ff-icon{width:26px;height:26px}.ff-ui .ff-brand{display:flex;align-items:center;gap:12px;min-width:0}.ff-ui .ff-brand h1,.ff-ui .ff-brand h2{margin:0;line-height:1.4}.ff-ui .ff-hero-brand{justify-content:center}.ff-ui.ff-hero{text-align:center;margin-bottom:12px}.ff-ui.ff-hero h1{font-size:28px;font-weight:600}.ff-ui.ff-hero p{color:var(--muted);margin:12px 0 0}.ff-ui.ff-hero .ff-mark{width:48px;height:48px}
6
+ .ff-ui .ff-icon{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}.ff-ui .ff-mark{width:40px;height:40px;display:inline-grid;place-items:center;background:var(--soft);color:var(--action);border-radius:8px;flex-shrink:0}.ff-ui .ff-mark .ff-icon{width:26px;height:26px}.ff-ui .ff-brand{display:flex;align-items:center;gap:12px;min-width:0}.ff-ui .ff-brand h1,.ff-ui .ff-brand h2{margin:0;line-height:1.4}.ff-ui .ff-hero-brand{justify-content:center}.ff-ui.ff-hero{text-align:center;margin-bottom:12px}.ff-ui.ff-hero h1{font-size:28px;font-weight:600}.ff-ui.ff-hero .ff-mark{width:48px;height:48px}
7
7
  .ff-ui.ff-modal-host{background:transparent;padding:0;overflow:hidden}.ff-ui.ff-modal-host> :not(dialog):not(script):not(link){visibility:hidden}html:has(>body.ff-ui.ff-modal-host){background:transparent;color-scheme:normal}.ff-ui #wizard-message{color:var(--error);padding:0 20px;margin:4px 0;flex-shrink:0}
8
8
  [data-composer-card]:has(.ff-ui .ff-prompt){padding-top:48px}.ff-ui .ff-prompt{margin:6px 10px}.ff-ui dialog>.tabs{padding:8px 20px;flex-shrink:0}
9
9
  .ff-ui .ff-launcher{display:flex;align-items:center;gap:8px;color:var(--text);padding:10px 8px;text-decoration:none;border-radius:6px;width:100%}.ff-ui .ff-launcher:hover,.ff-ui .ff-launcher[aria-current=page]{background:var(--soft);color:var(--action)}.ff-ui .ff-launcher .ff-icon{color:var(--brand)}.ff-ui.ff-top{width:100%;min-width:0;padding:4px var(--dsh-sidebar-inline-padding,12px) 4px 0}.ff-ui .ff-shortcuts{display:flex;justify-content:center;gap:6px;flex-wrap:wrap;padding:12px 0}.ff-ui .ff-shortcuts button{display:flex;flex-direction:column;align-items:center;gap:6px;border-color:transparent;background:transparent;color:var(--muted);font-size:12px;min-width:72px}.ff-ui .ff-shortcuts button[aria-pressed=true]{background:var(--soft);color:var(--action)}.ff-ui .ff-prompt{color:var(--action);background:var(--soft);border-color:transparent;display:inline-flex;align-items:center;gap:6px;font-size:13px}
package/lib/client.js CHANGED
@@ -20,7 +20,7 @@ window.__ModuleLoader__.load({
20
20
  return state;
21
21
  });
22
22
  };
23
- const unregister=service.registerTab({id:SIDEBAR_TAB,title:'AI填表',icon,order:20,single:true,hidden:true,component});
23
+ const unregister=service.registerTab({id:SIDEBAR_TAB,title:'AI 填表',icon,order:20,single:true,hidden:true,component});
24
24
  const unsubscribe=service.subscribeState(flush);
25
25
  return {
26
26
  open(scope){if(disposed)throw Error(SIDEBAR_HELP);if(!service.isTabEnabled(SIDEBAR_TAB))throw Error('AI填表 Tab 已禁用,请在 Better Sidebar 设置中启用。');opened.set(scope.sessionId,scope);pending.add(scope.sessionId);service.openTab({type:SIDEBAR_TAB},scope);flush();},
@@ -64,9 +64,9 @@ window.__ModuleLoader__.load({
64
64
  if(result!==id)throw Error('宿主不支持独立业务会话');
65
65
  await ctx.sessions.open(id);
66
66
  }
67
- const launcher=()=>h('a',{className:'ff-launcher',href:'/form-fill/',target:'_blank',rel:'noopener noreferrer','aria-label':'AI填表',onClick:async e=>{
67
+ const launcher=()=>h('a',{className:'ff-launcher',href:'/form-fill/',target:'_blank',rel:'noopener noreferrer','aria-label':'AI 填表',onClick:async e=>{
68
68
  if(!ctx.sessions?.create)return;e.preventDefault();try{await start()}catch{window.open('/form-fill/','_blank','noopener')}
69
- }},icon(),h('span',null,'AI填表'));
69
+ }},icon(),h('span',null,'AI 填表'));
70
70
  function Sidebar(){
71
71
  const [mount,setMount]=React.useState(null);
72
72
  React.useEffect(()=>{
@@ -100,19 +100,36 @@ window.__ModuleLoader__.load({
100
100
  const [mount,setMount]=React.useState(null);
101
101
  React.useEffect(()=>{
102
102
  if(!owned(sessionId))return;
103
- let mount,original,previous;
103
+ let mount,original,previous,disposed=false;
104
+ const release=()=>{
105
+ mount?.remove();mount=undefined;
106
+ if(original?.style.display==='none')original.style.display=previous;
107
+ original=undefined;setMount(null);
108
+ };
104
109
  const sync=()=>{
105
- if(mount?.isConnected)return;
110
+ if(disposed)return;
111
+ if(!activePlugin||current()!==sessionId){if(mount||original)release();return;}
106
112
  const marker=[...document.querySelectorAll('[data-form-fill-session]')].find(n=>n.dataset.formFillSession===sessionId);
107
113
  const hero=marker?.closest('[data-phase="hero"]'),title=hero?.querySelector('[class*="headlineText"]');
108
- if(!title||!['探索未至之境','Into the Unknown'].includes(title.textContent.trim()))return;
114
+ // This only replaces the host welcome brand, never a user-owned Session title.
115
+ if(!title||!['探索未至之境','Into the Unknown'].includes(title.textContent.trim())){if(mount||original)release();return;}
116
+ if(mount?.isConnected&&original===title.parentElement)return;
117
+ if(mount||original)release();
109
118
  original=title.parentElement;previous=original.style.display;original.style.display='none';
110
119
  mount=document.createElement('div');mount.className='ff-ui ff-hero';mount.dataset.ffTheme=theme();original.before(mount);setMount(mount);
111
120
  };
112
- sync();const timer=setInterval(sync,200);return()=>{clearInterval(timer);mount?.remove();if(original?.style.display==='none')original.style.display=previous;setMount(null)};
121
+ const marker=[...document.querySelectorAll('[data-form-fill-session]')].find(n=>n.dataset.formFillSession===sessionId);
122
+ const scope=marker?.closest('[data-slot="conversation"]')||marker?.closest('[data-phase="hero"]');
123
+ const observer=new MutationObserver(sync);
124
+ if(scope)observer.observe(scope,{childList:true,subtree:true,characterData:true});
125
+ const unsubscribe=ctx.sessions.list.subscribe?.(sync);
126
+ const dispose=()=>{if(disposed)return;disposed=true;observer.disconnect();if(typeof unsubscribe==='function')unsubscribe();release();};
127
+ disposers.push(dispose);sync();
128
+ return()=>{dispose();const index=disposers.indexOf(dispose);if(index>=0)disposers.splice(index,1);};
113
129
  },[sessionId]);
114
- return mount?portal(h(React.Fragment,null,h('div',{className:'ff-brand ff-hero-brand'},mark(),h('h1',null,'AI填表智能体')),h('p',null,'上传已有表格,核对字段映射,确认后生成新副本。')),mount):null;
130
+ return mount?portal(h('div',{className:'ff-brand ff-hero-brand'},mark(),h('h1',null,'AI 填表智能体')),mount):null;
115
131
  }
132
+
116
133
  function Menu({sessionId}){
117
134
  const ref=React.useRef(null),[mount,setMount]=React.useState(null),[error,setError]=React.useState('');
118
135
  React.useEffect(()=>{const report=message=>setError(message||'');viewListeners.add(report);return()=>viewListeners.delete(report)},[]);
@@ -134,7 +151,7 @@ window.__ModuleLoader__.load({
134
151
  function WorkbenchTab({scope,store,tab,visible}){
135
152
  const id=scope.sessionId,frame=React.useRef(null),ready=React.useRef(false);
136
153
  const [view,setView]=React.useState(()=>views.get(id)||{id,step:'import'});
137
- const [src]=React.useState(()=>'/form-fill/?session='+encodeURIComponent(id)+(sessionTasks.has(id)?'#task='+sessionTasks.get(id):''));
154
+ const [src]=React.useState(()=>'/form-fill/?workspace='+encodeURIComponent(ctx.workspaces?.list?.getSnapshot?.().items?.find(w=>w.sessionIds?.includes(id))?.workspaceId||'')+'&session='+encodeURIComponent(id)+(sessionTasks.has(id)?'#task='+sessionTasks.get(id):''));
138
155
  React.useEffect(()=>{
139
156
  const update=()=>{const next=views.get(id);if(next)setView(next)};viewListeners.add(update);
140
157
  const detach=sidebar?.attach(scope,store,tab);return()=>{viewListeners.delete(update);detach?.()};
@@ -148,6 +165,7 @@ window.__ModuleLoader__.load({
148
165
  React.useEffect(()=>{
149
166
  const receive=async event=>{
150
167
  if(event.origin!==location.origin||event.source!==frame.current?.contentWindow)return;
168
+ if(event.data?.type==='ff-open-origin'){const target=event.data.sessionId;const known=ctx.workspaces?.list?.getSnapshot?.().items?.some(w=>w.sessionIds?.includes(target));try{if(current()!==id||!owned(target)||!known||typeof ctx.sessions.open!=='function')throw Error('来源会话不可用');await ctx.sessions.open(target)}catch{frame.current?.contentWindow?.postMessage({type:'ff-origin-unavailable'},location.origin)}return}
151
169
  if(event.data?.type==='ff-capabilities'){frame.current.contentWindow.postMessage({type:'ff-capabilities-result',mappingDraft:2},location.origin);return}
152
170
  if(event.data?.type==='ff-view'&&ready.current&&['import','rules','identity','preview','download','history'].includes(event.data.step)){views.set(view.id,{id:view.id,step:event.data.step});return}
153
171
  if(event.data?.type==='ff-wizard-state'){if(!event.data.open&&event.data.focusComposer)setTimeout(()=>{if(current()===view.id)document.querySelector('[data-composer-card] textarea, [data-composer-card] [contenteditable=true]')?.focus()},0);return}
package/lib/http.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { providerOutcome } from './provider-outcome.js';
1
2
  import { randomUUID, createHash } from 'node:crypto';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { applyChangeSet, parseWorkbook } from 'form-fill-core';
@@ -19,7 +20,8 @@ export function createFormFillHandler({ basePath = '', getPort, now = Date.now,
19
20
  const controllers=new Map();
20
21
  const validOwner = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
21
22
  const visible = (task, owner) => task && (!task.owner || task.owner === owner);
22
- const metadata = (id, task) => ({ id, presentation:taskPresentation(task), filename: task.filename ?? '未命名表格.xlsx', revision: task.revision ?? 1, state: task.state ?? (task.result ? 'completed' : 'preview_ready'), created: task.created, updatedAt: task.updatedAt ?? task.created, expiresAt: task.created + ttlMs, sessionId: task.sessionId, confirmed: !!task.result });
23
+ const inSession=(task,session)=>task && (task.originSessionId||task.sessionId||'')===(session||'');
24
+ const metadata = (id, task) => ({ id, presentation:taskPresentation(task,now()), filename: task.filename ?? '未命名表格.xlsx', revision: task.revision ?? 1, state: task.state ?? (task.result ? 'completed' : 'preview_ready'), created: task.created, updatedAt: task.updatedAt ?? task.created, expiresAt: task.created + ttlMs, sessionId: task.sessionId, originSessionId:task.originSessionId||task.sessionId||null, originWorkspaceId:task.originWorkspaceId||null, confirmed: !!task.result });
23
25
  const settings = task => ({
24
26
  mappingProtocol: 2,
25
27
  diagnostics: diagnostics(task.preview),
@@ -56,20 +58,23 @@ export function createFormFillHandler({ basePath = '', getPort, now = Date.now,
56
58
  const url = new URL(request.url, origin);
57
59
  if (url.pathname !== basePath && !url.pathname.startsWith(basePath + '/')) return send(404, { message: '路径不存在' });
58
60
  const path = url.pathname.slice(basePath.length) || '/';
61
+ const session=request.headers['x-form-fill-session']||url.searchParams.get('session')||'';
62
+ const accessible=task=>visible(task,owner)&&inSession(task,session);
59
63
  if (request.method === 'GET' && path === '/') return send(200, await readFile(new URL('./ui.html', import.meta.url)), 'text/html; charset=utf-8');
60
64
  if (request.method === 'GET' && path === '/brand.css') return send(200, await readFile(new URL('./brand.css', import.meta.url)), 'text/css; charset=utf-8');
61
65
  if (request.method === 'GET' && path === '/health') return send(200, { plugin: 'form-fill-agent', product: 'AI填表', version: '0.1.0-alpha.7', provider: 'mock-only', qccAvailable: !!getQccStatus(), companionRequired: false, taskStorage: taskDirectory ? 'disk' : 'memory' });
62
- if (request.method === 'GET' && path === '/tasks') {
66
+ if (request.method === 'GET' && ['/tasks','/history'].includes(path)) {
63
67
  if (!validOwner(owner)) return send(403, { message: '需要本地任务访问凭据' });
64
- return send(200, [...tasks].filter(([, t]) => t.owner === owner).map(([id,t]) => metadata(id,t)).sort((a,b) => b.updatedAt-a.updatedAt));
68
+ const list=[...tasks].filter(([,t])=>t.owner===owner&&(path==='/history'||inSession(t,session))).map(([id,t])=>{const m=metadata(id,t);return path==='/history'?{id:m.id,filename:m.filename,title:m.presentation.title,status:m.presentation.status,created:m.created,updatedAt:m.updatedAt,expiresAt:m.expiresAt,originSessionId:m.originSessionId,originWorkspaceId:m.originWorkspaceId,confirmed:m.confirmed,readOnly:true}:m}).sort((a,b)=>b.updatedAt-a.updatedAt);
69
+ return send(200,list);
65
70
  }
66
71
  if (request.method === 'GET' && path.startsWith('/task/')) {
67
72
  const id = path.slice(6), task = tasks.get(id);
68
- if (!visible(task, owner)) return send(404, { message: '任务不存在或已过期' });
73
+ if (!accessible(task)) return send(404, { message: '任务不存在或已过期' });
69
74
  return send(200, { ...task.preview, ...metadata(id,task), ...settings(task) });
70
75
  }
71
76
  if(request.method==='GET'&&path.startsWith('/grid/')){
72
- const task=tasks.get(path.slice(6));if(!visible(task,owner))return send(404,{message:'任务不存在或已过期'});
77
+ const task=tasks.get(path.slice(6));if(!accessible(task))return send(404,{message:'任务不存在或已过期'});
73
78
  return send(200,gridPage(task,url.searchParams));
74
79
  }
75
80
  if (request.method === 'GET' && path.startsWith('/fixture/')) {
@@ -79,7 +84,7 @@ export function createFormFillHandler({ basePath = '', getPort, now = Date.now,
79
84
  }
80
85
  if (request.method === 'GET' && path.startsWith('/download/')) {
81
86
  const parts = path.split('/'), [, , id, kind] = parts, task = tasks.get(id);
82
- if (parts.length !== 4 || !visible(task, owner) || !task?.result) return send(404, { message: '请先确认写回,或预览已过期' });
87
+ if (parts.length !== 4 || !accessible(task) || !task?.result) return send(404, { message: '请先确认写回,或预览已过期' });
83
88
  if(kind==='report')return send(200,reportWorkbook(task),XLSX_TYPE);
84
89
  if(kind==='report-preview')return send(200,{rows:reportRows(task)});
85
90
  if (kind === 'xlsx') return send(200, task.result.bytes, XLSX_TYPE);
@@ -109,12 +114,13 @@ export function createFormFillHandler({ basePath = '', getPort, now = Date.now,
109
114
  if (disposed) return send(503, { message: '插件已卸载' });
110
115
  const filename = typeof body.filename === 'string' ? body.filename.replace(/[\\/\x00-\x1f]/g,'').slice(0,180) : '未命名表格.xlsx';
111
116
  if (owner && !validOwner(owner)) return send(400, { message: '任务访问凭据无效' });
112
- const task = { bytes, preview, analyzeOnly: !!body.analyzeOnly, created: now(), updatedAt: now(), revision: 1, state: 'preview_ready', owner, filename, sessionId: typeof body.sessionId === 'string' ? body.sessionId.slice(0,160) : undefined };
117
+ if((body.sessionId||'')!==session)return send(403,{message:'来源会话与当前请求不一致'});
118
+ const task = { originSessionId:session||undefined,originWorkspaceId:typeof body.originWorkspaceId==='string'?body.originWorkspaceId.slice(0,160):undefined, bytes, preview, analyzeOnly: !!body.analyzeOnly, created: now(), updatedAt: now(), revision: 1, state: 'preview_ready', owner, filename, sessionId: typeof body.sessionId === 'string' ? body.sessionId.slice(0,160) : undefined };
113
119
  tasks.set(id, task);
114
120
  return send(200, { ...preview, ...metadata(id,task), ...settings(task) });
115
121
  }
116
122
  const task = tasks.get(body.id);
117
- if (!visible(task, owner)) return send(404, { message: '预览已过期,请重新上传' });
123
+ if (!accessible(task)) return send(404, { message: '预览已过期,请重新上传' });
118
124
  if ((task.owner || body.expectedRevision !== undefined) && body.expectedRevision !== (task.revision ?? 1)) return send(409, { code: 'REVISION_CONFLICT', message: '任务已更新,请刷新后重试' });
119
125
  if(path==='/cancel'){
120
126
  const controller=controllers.get(body.id);if(!controller)return send(409,{message:'任务当前未运行'});
@@ -163,29 +169,34 @@ export function createFormFillHandler({ basePath = '', getPort, now = Date.now,
163
169
  };
164
170
  return {
165
171
  handler,
166
- async enrich(id, provider, expectedRevision, {retryOnly=false}={}) {
172
+ async enrich(id, provider, expectedRevision, {retryOnly=false,sessionId}={}) {
167
173
  sweep();
168
174
  const task = tasks.get(id);
169
175
  if (disposed || !task || task.result || running.has(id)) throw Error('任务不存在、已确认或正在执行');
176
+ if((task.originSessionId||task.sessionId)&&!inSession(task,sessionId))throw Error('来源会话不匹配,不能跨会话执行');
170
177
  if ((task.owner || expectedRevision !== undefined) && expectedRevision !== (task.revision ?? 1)) throw Error('REVISION_CONFLICT:任务已更新');
171
178
  running.add(id);
172
179
  const controller=new AbortController();controllers.set(id,controller);
173
- let active = { ...task, state: 'enriching', progress:{completed:0,total:0}, updatedAt: now(), revision: (task.revision ?? 1) + 1 };
180
+ let active = { ...task, state: 'enriching', progress:{completed:0,total:0,startedAt:now(),currentAction:'准备填写计划',outcomes:{},elapsedMs:0}, updatedAt: now(), revision: (task.revision ?? 1) + 1 };
174
181
  const excluded=new Set(allCandidates(task).changes.filter(c=>!task.preview.changeSet.changes.some(s=>s.id===c.id)).map(c=>c.id));
175
182
  const update=(preview,state,progress)=>{
176
183
  if(disposed||tasks.get(id)!==active)throw Error('任务已过期或被替换');
177
184
  const baseChangeSet=preview.changeSet;
178
185
  const selected=selectCandidates(baseChangeSet,baseChangeSet.changes.filter(c=>!excluded.has(c.id)).map(c=>c.id));
179
- active={...active,baseChangeSet,preview:{...preview,changeSet:selected},state,progress,updatedAt:now(),revision:active.revision+1};tasks.set(id,active);
186
+ active={...active,baseChangeSet,preview:{...preview,changeSet:selected},state,progress:{...active.progress,...progress,elapsedMs:Math.max(0,now()-active.progress.startedAt),...(state==='enriching'?{}:{currentAction:'处理结束',endedAt:now()})},updatedAt:now(),revision:active.revision+1};tasks.set(id,active);
180
187
  };
181
188
  try {
182
189
  tasks.set(id, active);
183
- const preview = await previewBytes(task.bytes, { provider, confirmPaidCalls: true, configuration: task.configuration, selectedFields: task.selectedFields,signal:controller.signal,retryOnly,previousChangeSet:retryOnly?allCandidates(task):undefined,onProgress:async({analysis,plan,changeSet,completed,total})=>update({analysis,plan,changeSet},'enriching',{completed,total}) });
190
+ const observed={...provider,lookup:async(request,options)=>{
191
+ active={...active,progress:{...active.progress,currentAction:'查询企业数据',recentItem:String(request.anchor?.company_name||request.anchor?.credit_no||'').slice(0,180)},updatedAt:now(),revision:active.revision+1};tasks.set(id,active);
192
+ let result;try{result=await provider.lookup(request,options)}catch(error){result={status:'error'};throw error}finally{if(!disposed&&tasks.get(id)===active&&active.state==='enriching'){const outcome=providerOutcome(result);active={...active,progress:{...active.progress,outcomes:{...active.progress.outcomes,[outcome]:(active.progress.outcomes[outcome]||0)+1}}};tasks.set(id,active)}}return result;
193
+ }};
194
+ const preview = await previewBytes(task.bytes, { provider:observed, confirmPaidCalls: true, configuration: task.configuration, selectedFields: task.selectedFields,signal:controller.signal,retryOnly,previousChangeSet:retryOnly?allCandidates(task):undefined,onProgress:async({analysis,plan,changeSet,completed,total})=>update({analysis,plan,changeSet},'enriching',{completed,total}) });
184
195
  if (disposed || tasks.get(id) !== active) throw Error('任务已过期或被替换');
185
196
  update(preview,controller.signal.aborted?'cancelled':preview.changeSet.incomplete.some(i => i.reason === 'provider-error') ? 'partial' : 'preview_ready',active.progress);
186
197
  return { taskId: id, filled: active.preview.changeSet.changes.length, incomplete: active.preview.changeSet.incomplete.length, diagnostics:diagnostics(active.preview), previewPath: basePath + '/#task=' + id };
187
198
  } catch (error) {
188
- if (!disposed && tasks.get(id) === active) tasks.set(id, { ...active, state: 'failed', updatedAt: now(), revision: active.revision + 1 });
199
+ if (!disposed && tasks.get(id) === active) tasks.set(id, { ...active, state: 'failed', progress:{...active.progress,currentAction:'执行失败',endedAt:now(),elapsedMs:Math.max(0,now()-active.progress.startedAt)},updatedAt: now(), revision: active.revision + 1 });
189
200
  throw error;
190
201
  } finally { running.delete(id);controllers.delete(id); }
191
202
  },
package/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { providerOutcome } from './provider-outcome.js';
1
2
  import { createFormFillHandler } from './http.js';
2
3
  import { createCatalogProvider, CATALOG_TOOL_DOMAINS, runtimeToolNames, REGISTRATION_TOOL, ENTITY_TOOL } from 'qcc-form-fill-provider';
3
4
  import { randomUUID } from 'node:crypto';
4
- import { join } from 'node:path';
5
+ import { profileTaskDirectory } from './profile-storage.js';
5
6
  import { renderEnrichmentResult } from './diagnostics.js';
6
7
  export { previewBytes, previewFile, writeCopy } from './workflow.js';
7
8
  export const name = 'form-fill-agent';
@@ -9,7 +10,7 @@ export const inject = [];
9
10
  export function apply(ctx, config = {}) {
10
11
  ctx.inject(['webServer'], scope => {
11
12
  let toolService;
12
- const taskDirectory = config.taskDirectory ?? (process.env.DSH_HOME ? join(process.env.DSH_HOME, 'form-fill-tasks') : undefined);
13
+ const taskDirectory = config.taskDirectory ?? profileTaskDirectory(import.meta.url);
13
14
  const service = createFormFillHandler({ basePath: '/form-fill', getPort: () => scope.webServer.port, taskDirectory, ttlMs: 86400000, getQccStatus: () => ['mcp__qcc-company__','mcp__company__','mcp__qcc_company__'].some(p => toolService?.get?.(p + REGISTRATION_TOOL)) });
14
15
  scope.inject?.(['tools'], toolScope => {
15
16
  const tools = toolScope.tools;
@@ -24,7 +25,7 @@ export function apply(ctx, config = {}) {
24
25
  },
25
26
  async execute(args, execution) {
26
27
  if (!execution?.agent || !execution?.token) throw Error('需要 Agent-owned 工具执行上下文');
27
- const runtimeSources=new Map();
28
+ const runtimeSources=new Map();let transportOutcome;
28
29
  const provider = createCatalogProvider({ availableTools:Object.keys(CATALOG_TOOL_DOMAINS).filter(name=>runtimeToolNames(name).some(n=>tools.get(n))), enableEntitySearch: ['mcp__qcc-company__','mcp__company__','mcp__qcc_company__'].some(p=>tools.get(p+ENTITY_TOOL)), callTool: async (name, arguments_, { signal }) => {
29
30
  if (!Object.hasOwn(CATALOG_TOOL_DOMAINS,name)) throw Error('不支持的 QCC 工具');
30
31
  const names = runtimeToolNames(name);
@@ -32,10 +33,10 @@ export function apply(ctx, config = {}) {
32
33
  if (!selected) throw Error('请先连接企查查企业数据 MCP');
33
34
  runtimeSources.set(name,selected);
34
35
  const result = await tools.execute({ name: selected, arguments: arguments_, signal, callId: randomUUID(), rootCallId: execution.rootCallId, parent: execution.token, agent: execution.agent });
35
- return result?.isError ? { isError: true } : result?.value;
36
+ const raw=result?.value;const observed=providerOutcome(raw);if(observed==='no-permission')transportOutcome=observed;return result?.isError ? { isError: true } : raw;
36
37
  } });
37
- const lookup=provider.lookup.bind(provider);provider.lookup=async(...input)=>{const result=await lookup(...input);for(const value of Object.values(result.values||{})){const match=/^qcc:\/\/([^/]+)\//.exec(value.source||'');if(match&&runtimeSources.has(match[1]))value.source=value.source.replace('qcc://'+match[1]+'/', 'qcc://'+runtimeSources.get(match[1])+'/')}return result};
38
- return service.enrich(args.taskId, provider, args.expectedRevision,{retryOnly:args.mode==='retry'});
38
+ const lookup=provider.lookup.bind(provider);provider.lookup=async(...input)=>{transportOutcome=undefined;const result=await lookup(...input);if(transportOutcome)result.outcome=transportOutcome;for(const value of Object.values(result.values||{})){const match=/^qcc:\/\/([^/]+)\//.exec(value.source||'');if(match&&runtimeSources.has(match[1]))value.source=value.source.replace('qcc://'+match[1]+'/', 'qcc://'+runtimeSources.get(match[1])+'/')}return result};
39
+ return service.enrich(args.taskId, provider, args.expectedRevision,{retryOnly:args.mode==='retry',sessionId:execution.agent.session?.id});
39
40
  },
40
41
  });
41
42
  toolScope.effect?.(() => () => disposeTool?.());
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // Resolve the installed profile manifest, never a home-wide shared task store.
6
+ // Linked development packages outside a profile use memory unless explicitly configured.
7
+ export function profileTaskDirectory(moduleUrl) {
8
+ let directory = dirname(fileURLToPath(moduleUrl));
9
+ while (true) {
10
+ try {
11
+ const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
12
+ if (manifest.private === true && Array.isArray(manifest.dsh?.profile?.bundles)) {
13
+ return join(directory, 'form-fill-tasks');
14
+ }
15
+ } catch { /* Not a profile manifest. */ }
16
+ const parent = dirname(directory);
17
+ if (parent === directory) return undefined;
18
+ directory = parent;
19
+ }
20
+ }
@@ -0,0 +1,14 @@
1
+ // Observation only: never turns a failed response into usable business values.
2
+ export function providerOutcome(result) {
3
+ const code=String(result?.code??result?.error?.code??'').toLowerCase();
4
+ if(['403','401','forbidden','unauthorized','permission-denied','no-permission'].includes(code))return 'no-permission';
5
+ if(result?.outcome==='no-permission')return 'no-permission';
6
+ if(result?.status==='not-required')return 'not-required';
7
+ if(result?.status==='not-found')return 'zero-records';
8
+ if(result?.status==='exact')return Object.keys(result.values||{}).length?'data':'zero-records';
9
+ if(result?.status==='ambiguous')return 'review';
10
+ if(result?.code==='qcc-response-invalid')return 'unknown';
11
+ if(result?.isError||result?.status==='error'||result?.status==='cancelled')return 'failed';
12
+ return 'unknown';
13
+ }
14
+ export const OUTCOME_LABELS=Object.freeze({data:'成功有数据','zero-records':'成功零记录','not-required':'无需执行','no-permission':'无权限',failed:'失败',unknown:'未知',review:'需核验'});
@@ -1,7 +1,7 @@
1
1
  import { parseWorkbook } from 'form-fill-core';
2
2
 
3
3
  const retained = new Set(['no-data','user-excluded','formula-preserved','hidden-or-merged','hidden-row','hidden-sheet','sheet-excluded','protected-source-range','placeholder-needs-confirmation']);
4
- export function taskPresentation(task) {
4
+ export function taskPresentation(task,now=Date.now()) {
5
5
  const items=task.preview.changeSet.incomplete;
6
6
  const failures=Math.max(task.state==='failed'?1:0,items.filter(i=>i.reason==='provider-error').length);
7
7
  const review=items.filter(i=>i.reason!=='provider-error'&&!retained.has(i.reason)).length;
@@ -26,6 +26,7 @@ export function taskPresentation(task) {
26
26
  }
27
27
  }
28
28
  const date=new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false}).format(new Date(task.created)).replace('/', '-');
29
- return {title:(task.filename||'未命名表格').replace(/\.xlsx$/i,'')+'|'+subjects.size+' 家企业|'+date,status,running,ended,percent,completed,total,failures,review,noData};
29
+ const elapsedMs=task.progress?.startedAt!==undefined?Math.max(0,(running?now:task.progress.endedAt||task.updatedAt)-task.progress.startedAt):0;
30
+ return {currentAction:task.progress?.currentAction||'尚未执行',recentItem:task.progress?.recentItem||'',elapsedMs,outcomes:task.progress?.outcomes||{},filled:task.preview.changeSet.changes.length,title:(task.filename||'未命名表格').replace(/\.xlsx$/i,'')+'|'+subjects.size+' 家企业|'+date,status,running,ended,percent,completed,total,failures,review,noData};
30
31
  }
31
32
  export const isResultExplanation = item => retained.has(item.reason);
package/lib/task-store.js CHANGED
@@ -38,7 +38,7 @@ export function createTaskStore({ directory, maxTasks = 10, now = Date.now, ttlM
38
38
  if(saved.baseChangeSet){assertChangeSet(saved.baseChangeSet);if(saved.baseChangeSet.planId!==saved.preview.plan.planId)throw Error('Candidate plan mismatch');}
39
39
  if (saved.preview.plan.documentHash !== doc.documentHash || saved.preview.changeSet.planId !== saved.preview.plan.planId) throw Error('Persisted document mismatch');
40
40
  }
41
- const task = { bytes, preview: saved.preview, baseChangeSet:saved.baseChangeSet, progress:saved.progress, configuration: saved.configuration, selectedFields:saved.selectedFields, analyzeOnly: saved.analyzeOnly, created: saved.created, revision: saved.revision ?? 1, owner: saved.owner, filename: saved.filename ?? '未命名表格.xlsx', updatedAt: saved.updatedAt ?? saved.created, state: saved.state === 'enriching' ? 'interrupted' : saved.state ?? (saved.confirmed ? 'completed' : 'preview_ready'), sessionId: saved.sessionId };
41
+ const task = { bytes, preview: saved.preview, baseChangeSet:saved.baseChangeSet, progress:saved.progress, configuration: saved.configuration, selectedFields:saved.selectedFields, analyzeOnly: saved.analyzeOnly, created: saved.created, revision: saved.revision ?? 1, owner: saved.owner, filename: saved.filename ?? '未命名表格.xlsx', updatedAt: saved.updatedAt ?? saved.created, state: saved.state === 'enriching' ? 'interrupted' : saved.state ?? (saved.confirmed ? 'completed' : 'preview_ready'), originWorkspaceId:saved.originWorkspaceId,originSessionId:saved.originSessionId||saved.sessionId,sessionId: saved.sessionId };
42
42
  if (saved.confirmed && task.preview) task.result = applyChangeSet(bytes,task.preview.plan,task.preview.changeSet,{confirmChangeSetId:task.preview.changeSet.changeSetId});
43
43
  tasks.set(id,task);
44
44
  }
@@ -50,9 +50,11 @@ export function createTaskStore({ directory, maxTasks = 10, now = Date.now, ttlM
50
50
  get: id => tasks.get(id),
51
51
  set(id, task) {
52
52
  if (!idPattern.test(id)) throw Error('Invalid task ID');
53
+ const prior=tasks.get(id);if(prior&&prior.originWorkspaceId!==task.originWorkspaceId)throw Error('任务来源工作区不可重绑定');if(prior?.result&&!task.result)throw Error('已确认终态不可回退');
54
+ if(prior&&(prior.originSessionId||prior.sessionId)!==(task.originSessionId||task.sessionId))throw Error('任务来源会话不可重绑定');
53
55
  if (!tasks.has(id) && tasks.size >= maxTasks) throw Error('Task capacity exceeded');
54
56
  if (root) {
55
- const json = JSON.stringify({ schema:5,id,base64:task.bytes.toString('base64'),preview:task.preview,baseChangeSet:task.baseChangeSet,progress:task.progress,configuration:task.configuration,selectedFields:task.selectedFields,analyzeOnly:task.analyzeOnly,created:task.created,revision:task.revision ?? 1,confirmed:!!task.result,owner:task.owner,filename:task.filename,updatedAt:task.updatedAt,state:task.state,sessionId:task.sessionId });
57
+ const json = JSON.stringify({ schema:5,id,base64:task.bytes.toString('base64'),preview:task.preview,baseChangeSet:task.baseChangeSet,progress:task.progress,configuration:task.configuration,selectedFields:task.selectedFields,analyzeOnly:task.analyzeOnly,created:task.created,revision:task.revision ?? 1,confirmed:!!task.result,owner:task.owner,filename:task.filename,updatedAt:task.updatedAt,state:task.state,originWorkspaceId:task.originWorkspaceId,originSessionId:task.originSessionId||task.sessionId,sessionId:task.sessionId });
56
58
  if (Buffer.byteLength(json) > 48*1024*1024) throw Error('Task too large');
57
59
  const temp = join(root,'.'+randomUUID()+'.tmp');
58
60
  try { writeFileSync(temp,json,{mode:0o600,flag:'wx'});renameSync(temp,join(root,id+'.json')); }
package/lib/ui.html CHANGED
@@ -5,12 +5,12 @@
5
5
  <body class="ff-ui ff-workbench">
6
6
  <header class="ff-workbench-head"><div class="ff-brand"><span class="ff-mark" data-icon="table"></span><h1>AI填表工作台</h1></div><p class="muted">识别空位,核验来源,确认后生成新副本。</p></header><nav class="task-tabs" aria-label="任务视图"><button id="current-tab" aria-pressed="true">当前任务</button><button id="history-tab" aria-pressed="false">任务历史</button></nav>
7
7
  <p id="status" role="status" aria-live="polite"></p>
8
- <section id="history" hidden><h2>本浏览器的任务</h2><p class="muted">只显示持有相同访问凭据的本地任务。过期任务自动清理。</p><div id="history-list"></div></section>
8
+ <section id="history" hidden><h2>任务历史(只读)</h2><p class="muted">只显示当前存储实例内持有相同访问凭据的任务元数据。过期任务自动清理。</p><div id="history-list"></div></section>
9
9
  <main id="current">
10
10
  <nav class="ff-stages" aria-label="工作流程"><button data-step="import"><span class="ff-stage-icon" data-icon="upload"></span><span class="ff-stage-label">导入表格</span></button><button data-step="rules"><span class="ff-stage-icon" data-icon="settings"></span><span class="ff-stage-label">字段设置</span></button><button data-step="identity"><span class="ff-stage-icon" data-icon="identity"></span><span class="ff-stage-label">主体核验</span></button><button data-step="preview"><span class="ff-stage-icon" data-icon="table"></span><span class="ff-stage-label">填写预览</span></button><button data-step="download"><span class="ff-stage-icon" data-icon="download"></span><span class="ff-stage-label">确认下载</span></button></nav>
11
11
  <p id="task-meta" class="badge"></p>
12
12
  <details><summary>任务详情</summary><p id="task-details"></p></details>
13
- <div id="progress-panel" hidden><progress id="task-progress" max="100" aria-label="处理进度"></progress><p id="progress-label" role="status"></p><span id="review-count" class="ff-review-count"></span> <span id="failure-count" class="ff-failure-count"></span><p class="muted">100% 只代表处理结束,填写内容需在预览中核验并确认生成副本。</p></div>
13
+ <div id="progress-panel" hidden><progress id="task-progress" max="100" aria-label="处理进度"></progress><p id="progress-label" role="status"></p><p id="progress-detail"></p><span id="review-count" class="ff-review-count"></span> <span id="failure-count" class="ff-failure-count"></span><p class="muted">100% 只代表处理结束,填写内容需在预览中核验并确认生成副本。</p></div>
14
14
  <div class="toolbar ff-execution-toolbar"><span id="execution-progress" role="status"></span><button id="cancel-run" disabled>取消查询</button><button id="retry-run" disabled>仅重试失败 / 未完成查询</button></div>
15
15
  <details id="grid" hidden><summary>查看完整原表 / 结果表格</summary><p class="muted">全量行列按页查看,隐藏行列只读标注。结果视图展示当前已选填写;下载不受分页或搜索影响。</p><div class="toolbar"><label>工作表 <select id="grid-sheet"></select></label><label>视图 <select id="grid-view"><option value="original">原表</option><option value="result">结果预览</option></select></label><label>搜索全部行 <input id="grid-query" type="search" maxlength="200"></label><button id="grid-search">搜索</button></div><p id="grid-status" role="status"></p><div class="scroll"><table><thead id="grid-head"></thead><tbody id="grid-body"></tbody></table></div><div class="toolbar"><button id="grid-prev">上一页</button><button id="grid-next">下一页</button><button id="grid-left">前 16 列</button><button id="grid-right">后 16 列</button></div></details>
16
16
  <div class="card" data-pane="import"><h2>导入表格</h2><p class="muted">支持 XLSX,最大 8 MiB。支持普通内部公式、固定文本或区域下拉、基础条件格式及普通表格对象;宏、图片、图表及复杂扩展结构暂不支持。上传仅做本地分析,不查询企查查。</p>
@@ -47,6 +47,8 @@ if(!/^[a-f0-9]{64}$/.test(owner||'')){owner=[...crypto.getRandomValues(new Uint8
47
47
  document.cookie='ff_owner='+owner+'; SameSite=Strict; Path='+(basePath||'/');
48
48
  let current,filename='已填表格',busy=false,step='import',pendingNavigation,wizardStep=0,wizardFields=new Set(),wizardTask,wizardStructure,expectedDraft,retryIntent=false;
49
49
  const sessionId=new URLSearchParams(location.search).get('session')||undefined;
50
+ const originWorkspaceId=new URLSearchParams(location.search).get('workspace')||undefined;
51
+ const downloadUrl=(id,kind)=>basePath+'/download/'+id+'/'+kind+'?'+new URLSearchParams({session:sessionId||''});
50
52
  let gridPageNumber=1,gridColumn=1,gridRequest=0,gridTask;
51
53
  async function loadGrid(){
52
54
  if(!current||!$('grid').open)return;const request=++gridRequest,id=current.id;
@@ -68,7 +70,7 @@ function controls(value){busy=value;document.querySelectorAll('button').forEach(
68
70
  function row(target,values,labels){const tr=document.createElement('tr');values.forEach((v,i)=>{const td=document.createElement('td');td.textContent=v;td.title=String(v??'');td.dataset.label=labels[i];tr.append(td)});$(target).append(tr);return tr}
69
71
  function syncActions(){
70
72
  const running=current?.state==='enriching';
71
- const p=current?.presentation;$('progress-panel').hidden=!p||(!p.running&&!p.ended);if(p){$('task-progress').value=p.percent;$('task-progress').dataset.ended=String(p.ended);$('progress-label').textContent=(p.running?'处理中':p.ended?'处理结束':'待处理')+' · '+p.percent+'% · '+p.completed+' / '+p.total+' 查询计划项';$('progress-label').dataset.ended=String(p.ended);$('review-count').textContent='待核验 '+p.review+' 项';$('failure-count').textContent='失败 '+p.failures+' 项';}
73
+ const p=current?.presentation;$('progress-panel').hidden=!p||(!p.running&&!p.ended);if(p){$('task-progress').value=p.percent;$('task-progress').dataset.ended=String(p.ended);$('progress-label').textContent=(p.running?'处理中':current.confirmed?'已完成':current.state==='cancelled'||current.state==='interrupted'?'已停止但未完成':p.ended?'等待核验与确认':'待处理')+' · '+p.percent+'% · '+p.completed+' / '+p.total+' 查询计划项';$('progress-label').dataset.ended=String(p.ended);$('review-count').textContent='待核验 '+p.review+' 项';$('failure-count').textContent='失败 '+p.failures+' 项';$('progress-detail').textContent=p.currentAction+' · 已处理 '+p.completed+' 项 · 可填 '+p.filled+' 格 · 无数据 '+p.noData+' 格 · 用时 '+Math.floor(p.elapsedMs/1000)+' 秒'+(p.recentItem?' · 最近:'+p.recentItem:'')+';查询结果:'+Object.entries(p.outcomes||{}).map(([k,v])=>({'data':'有数据','zero-records':'零记录','not-required':'无需执行','no-permission':'无权限',failed:'失败',unknown:'未知',review:'需核验'}[k]||'未知')+' '+v).join(' / ');}
72
74
  $('cancel-run').hidden=!running;$('cancel-run').disabled=busy||!running;
73
75
  $('retry-run').hidden=!current||current.confirmed||!current.changeSet.incomplete.some(i=>['provider-error','cancelled','not-started'].includes(i.reason));
74
76
  $('retry-run').disabled=busy||!current||current.confirmed||running||!current.changeSet.incomplete.some(i=>['provider-error','cancelled','not-started'].includes(i.reason));
@@ -77,12 +79,12 @@ function syncActions(){
77
79
  }
78
80
  $('cancel-run').onclick=async()=>{try{await api('/cancel',{id:current.id});$('status').textContent='已请求取消。已发出的请求可能已在远端执行,本地不再继续后续查询。';await restore()}catch(e){$('status').textContent=e.message}};
79
81
  $('retry-run').onclick=()=>{openWizard(true);wizardStep=3;renderWizard()};
80
- async function api(path,body){if(current&&body?.id===current.id)body={...body,expectedRevision:current.revision};const response=await fetch(basePath+path,{...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)}),headers:{'Content-Type':'application/json','X-Form-Fill-Owner':owner}});const data=await response.json();if(!response.ok)throw Error(data.message);return data}
82
+ async function api(path,body){if(current&&body?.id===current.id)body={...body,expectedRevision:current.revision};const response=await fetch(basePath+path,{...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)}),headers:{'Content-Type':'application/json','X-Form-Fill-Owner':owner,'X-Form-Fill-Session':sessionId||''}});const data=await response.json();if(!response.ok)throw Error(data.message);return data}
81
83
  function navigate(target){if(target==='wizard'){openWizard();return}if(target!=='import'&&!current){$('status').textContent='请先导入表格。';return}step=target;if(parent!==window)parent.postMessage({type:'ff-view',step:target},location.origin);$('history').hidden=true;$('current').hidden=false;$('current-tab').setAttribute('aria-pressed','true');$('history-tab').setAttribute('aria-pressed','false');document.querySelectorAll('[data-pane]').forEach(p=>p.hidden=p.dataset.pane!==target);document.querySelectorAll('[data-step]').forEach(b=>b.setAttribute('aria-current',String(b.dataset.step===target)))}
82
84
  function downloads(){
83
85
  $('downloads').replaceChildren();$('download-summary').textContent='已确认填写 '+current.changeSet.changes.length+' 格 · '+(current.presentation?.status||'副本已生成');
84
- Object.assign($('report-download'),{hidden:false,href:basePath+'/download/'+current.id+'/report',download:filename+'-任务结果报告.xlsx'});
85
- const group=document.createElement('div');group.className='toolbar';const preview=document.createElement('button');preview.textContent='预览已填副本';preview.onclick=async()=>{navigate('preview');$('grid').open=true;$('grid-view').value='result';await loadGrid();$('grid').scrollIntoView({block:'start'})};const a=document.createElement('a');Object.assign(a,{className:'download',href:basePath+'/download/'+current.id+'/xlsx',download:filename+'-已填副本.xlsx',textContent:'下载已填副本 XLSX'});group.append(preview,a);$('downloads').append(group)
86
+ Object.assign($('report-download'),{hidden:false,href:downloadUrl(current.id,'report'),download:filename+'-任务结果报告.xlsx'});
87
+ const group=document.createElement('div');group.className='toolbar';const preview=document.createElement('button');preview.textContent='预览已填副本';preview.onclick=async()=>{navigate('preview');$('grid').open=true;$('grid-view').value='result';await loadGrid();$('grid').scrollIntoView({block:'start'})};const a=document.createElement('a');Object.assign(a,{className:'download',href:downloadUrl(current.id,'xlsx'),download:filename+'-已填副本.xlsx',textContent:'下载已填副本 XLSX'});group.append(preview,a);$('downloads').append(group)
86
88
  }
87
89
  function businessSummary(){return current?(retryIntent?'仅重试失败、取消或未执行的部分,保留成功结果。':'')+'填写文件“'+current.filename+'”中的空白字段。共 '+current.analysis.tables.length+' 个工作表,'+wizardFields.size+' 个填写字段,'+current.analysis.opportunities.filter(p=>wizardFields.has(p.field)).length+' 个待补空位置。字段范围以本次保存的映射为准。保留已有值、公式与未确认位置,核验预览后生成新副本。':'请先上传 XLSX 文件。'}
88
90
  function command(){return current?businessSummary()+'\n'+$('requirements').value+'\n执行关联:调用 form_fill_enrich:taskId='+current.id+',expectedRevision='+current.revision+(retryIntent?',mode=retry':'')+'。字段范围以工作台确认设置为准。':''}
@@ -225,7 +227,7 @@ function renderSettings(data){
225
227
  role.onchange=()=>{manual=true;label.updateMappingStatus();highlightMappingConflicts()};
226
228
  const options=()=>{
227
229
  const group=data.catalogGroups?.find(g=>g.id===dimension.value);
228
- const found=(data.catalog||[]).filter(f=>matches(f,search.value)&&(!dimension.value||(dimension.value==='other'?!data.catalogGroups?.some(g=>g.fields.includes(f.key)):group?.fields.includes(f.key))));
230
+ const found=(data.catalog||[]).filter(f=>(matches(f,search.value)||(data.catalogGroups||[]).some(g=>g.fields.includes(f.key)&&matches({label:g.label,key:g.id},search.value)))&&(!dimension.value||(dimension.value==='other'?!data.catalogGroups?.some(g=>g.fields.includes(f.key)):group?.fields.includes(f.key))));
229
231
  select.replaceChildren();
230
232
  const list=[{key:'',label:'不填写'},...found];
231
233
  if(chosen&&!found.some(f=>f.key===chosen)){const field=data.catalog.find(f=>f.key===chosen);if(field)list.splice(1,0,{...field,label:field.label+'(当前选择,筛选外)'})}
@@ -239,7 +241,7 @@ function renderSettings(data){
239
241
  select.onchange=choose;
240
242
  select.ondblclick=finish;
241
243
  select.onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();finish()}};
242
- search.oninput=options;dimension.onchange=options;
244
+ search.oninput=e=>{if(!e.isComposing)options()};search.oncompositionend=options;dimension.onchange=options;
243
245
  if(!chosen&&!manual){
244
246
  // Strip only a trailing date-format annotation for recommendations. Keep
245
247
  // the original header and require confirmation; semantic qualifiers remain.
@@ -295,8 +297,8 @@ initWizard();filterResults();syncActions();if($('wizard').open)renderWizard();
295
297
  const missing=data.analysis.incomplete.filter(i=>i.reason==='missing-anchor');
296
298
  $('anchor-alert').textContent=missing.length?'有 '+missing.length+' 行缺少可用主体标识。请把实际包含名称的列映射为“企业名称”,或提供非空信用代码;当前不会查询这些行。':'';
297
299
  }
298
- async function restore(silent=false){if(busy||!current&& !location.hash)return;const id=new URLSearchParams(location.hash.slice(1)).get('task');if(!id)return;try{const data=await api('/task/'+encodeURIComponent(id));if(!silent||data.id!==current?.id||data.revision!==current?.revision){const wasRunning=current?.state==='enriching';render(data);if(silent&&data.state==='enriching'){navigate('identity');$('status').textContent=$('execution-progress').textContent}else if(silent&&wasRunning){navigate('preview');$('status').textContent='查询已结束,请检查填写预览。'}if(!silent){const target=pendingNavigation||(data.confirmed?'download':'preview');pendingNavigation=undefined;if(target==='history')$('history-tab').click();else navigate(target)}}}catch(e){if(!silent)$('status').textContent=e.message}}
299
- async function preview(buffer,name,demo=false){controls(true);try{const bytes=new Uint8Array(buffer);if(bytes.length>8388608)throw Error('文件超过 8 MiB');let binary='';for(let i=0;i<bytes.length;i+=32768)binary+=String.fromCharCode(...bytes.subarray(i,i+32768));$('grid').open=true;render(await api('/preview',{base64:btoa(binary),filename:name,sessionId,analyzeOnly:!demo}));navigate(demo?'preview':'rules');$('status').textContent='预览已准备好,请检查后确认。'}catch(e){$('status').textContent=e.message;$('wizard-message').textContent=e.message}finally{controls(false)}}
300
+ async function restore(silent=false){if(busy||!current&& !location.hash)return;const id=new URLSearchParams(location.hash.slice(1)).get('task');if(!id)return;try{const data=await api('/task/'+encodeURIComponent(id));if(silent&&data.id===current?.id&&data.revision===current?.revision){current.presentation=data.presentation;syncActions()}if(!silent||data.id!==current?.id||data.revision!==current?.revision){const wasRunning=current?.state==='enriching';render(data);if(silent&&data.state==='enriching'){navigate('identity');$('status').textContent=$('execution-progress').textContent}else if(silent&&wasRunning){navigate('preview');$('status').textContent='查询已结束,请检查填写预览。'}if(!silent){const target=pendingNavigation||(data.confirmed?'download':'preview');pendingNavigation=undefined;if(target==='history')$('history-tab').click();else navigate(target)}}}catch(e){if(!silent)$('status').textContent=e.message;else $('execution-progress').textContent='状态同步异常,保留已显示进度,正在等待下次同步。'}}
301
+ async function preview(buffer,name,demo=false){controls(true);try{const bytes=new Uint8Array(buffer);if(bytes.length>8388608)throw Error('文件超过 8 MiB');let binary='';for(let i=0;i<bytes.length;i+=32768)binary+=String.fromCharCode(...bytes.subarray(i,i+32768));$('grid').open=true;render(await api('/preview',{base64:btoa(binary),filename:name,sessionId,originWorkspaceId,analyzeOnly:!demo}));navigate(demo?'preview':'rules');$('status').textContent='预览已准备好,请检查后确认。'}catch(e){$('status').textContent=e.message;$('wizard-message').textContent=e.message}finally{controls(false)}}
300
302
  document.querySelectorAll('[data-step]').forEach(b=>b.onclick=()=>navigate(b.dataset.step));
301
303
  $('file').onchange=async()=>{const input=$('file'),f=input.files[0];if(!f||busy)return;input.disabled=true;controls(true);$('status').textContent='正在自动解析表格…';try{await preview(await f.arrayBuffer(),f.name)}catch(e){$('status').textContent=e.message}finally{input.disabled=false;input.value='';controls(false)}};
302
304
  function readMapping(){const configuration={sheets:[],headers:[],mappings:[]};for(const section of $('mapping').querySelectorAll('fieldset')){if(!section.querySelector('.sheet-enabled').checked)continue;const sheet=section.dataset.sheet;configuration.sheets.push(sheet);configuration.headers.push({sheet,row:Number(section.querySelector('.header-row').value)});for(const field of section.querySelectorAll('[data-column]'))if(field.value||field.dataset.manual==='true')configuration.mappings.push({sheet,column:Number(field.dataset.column),field:field.value||null,...(field.dataset.primary==='true'?{role:field.closest('.ff-mapping-row').querySelector('.ff-mapping-role').value}:{})})}return configuration}
@@ -309,7 +311,7 @@ $('confirm').onclick=async()=>{if(!current||busy)return;controls(true);try{await
309
311
  $('selection').onclick=async()=>{if(!current||busy)return;controls(true);try{render(await api('/select',{id:current.id,selectedIds:[...$('changes').querySelectorAll('input:checked')].map(x=>x.value)}));$('status').textContent='选择已应用,请核对新的预览。'}catch(e){$('status').textContent=e.message}finally{controls(false)}};
310
312
  $('refresh').onclick=()=>restore();
311
313
  $('discard').onclick=async()=>{if(!current||busy)return;controls(true);try{await api('/discard',{id:current.id});current=null;history.replaceState(null,'',location.pathname+location.search);$('preview').hidden=true;$('grid').hidden=true;gridTask=null;navigate('import');$('status').textContent='任务及保存的表格已删除。'}catch(e){$('status').textContent=e.message}finally{controls(false)}};
312
- $('history-tab').onclick=async()=>{try{const list=await api('/tasks');$('history-list').replaceChildren();for(const item of list){const div=document.createElement('div');div.className='history-item';const button=document.createElement('button');button.textContent=(item.presentation?.title||item.filename)+' · '+(item.presentation?.status||item.state);button.onclick=()=>{location.hash='task='+item.id;restore()};const details=document.createElement('details'),summary=document.createElement('summary');summary.textContent='任务详情';details.append(summary,document.createTextNode('任务 ID:'+item.id));div.append(button,details);$('history-list').append(div)}if(parent!==window)parent.postMessage({type:'ff-view',step:'history'},location.origin);$('history').hidden=false;$('current').hidden=true;$('current-tab').setAttribute('aria-pressed','false');$('history-tab').setAttribute('aria-pressed','true');if(!list.length)$('history-list').textContent='暂无可恢复的任务。'}catch(e){$('status').textContent=e.message}};
314
+ $('history-tab').onclick=async()=>{try{const list=await api('/history');$('history-list').replaceChildren();for(const item of list){const div=document.createElement('div');div.className='history-item';const title=document.createElement('strong');title.textContent=(item.title||item.filename)+' · '+item.status;const details=document.createElement('details'),summary=document.createElement('summary');summary.textContent='查看历史元数据(只读)';const text=document.createElement('p');text.textContent='来源工作区:'+(item.originWorkspaceId||'旧任务未记录')+';来源会话:'+(item.originSessionId||'独立页面 / 旧任务未记录')+';任务 ID:'+item.id+';有效期至 '+new Date(item.expiresAt).toLocaleString();details.append(summary,text);const hint=document.createElement('p');hint.className='muted';hint.textContent='仅展示当前存储实例内同访问凭据的历史元数据,不加载原表,也不绑定到当前任务。';div.append(title,details,hint);if(parent!==window&&item.originSessionId){const open=document.createElement('button');open.textContent='打开来源会话';open.onclick=()=>parent.postMessage({type:'ff-open-origin',sessionId:item.originSessionId},location.origin);div.append(open)}else{const fallback=document.createElement('p');fallback.textContent='请从宿主历史进入来源会话;独立页面不支持切换宿主会话。';div.append(fallback)}$('history-list').append(div)}if(parent!==window)parent.postMessage({type:'ff-view',step:'history'},location.origin);$('history').hidden=false;$('current').hidden=true;$('current-tab').setAttribute('aria-pressed','false');$('history-tab').setAttribute('aria-pressed','true');if(!list.length)$('history-list').textContent='当前存储实例内无此访问凭据可见的有效历史。'}catch(e){$('status').textContent=e.message}};
313
315
  $('current-tab').onclick=()=>navigate(step);
314
316
  $('wizard-open').onclick=()=>directDraft();$('wizard-close').onclick=()=>closeWizard();$('wizard').addEventListener('cancel',e=>{e.preventDefault();closeWizard()});
315
317
  document.querySelectorAll('[data-wizard-step]').forEach(b=>b.onclick=()=>{const next=Number(b.dataset.wizardStep);wizardStep=next>1&&wizardMappingApplied!==JSON.stringify([current?.id,readMapping()])?1:next;saveWizard();renderWizard()});
@@ -323,8 +325,9 @@ $('copy-command').onclick=async()=>{try{await applyScope();saveWizard();await na
323
325
  $('draft').onclick=()=>sendDraft();
324
326
  $('draft-cancel').onclick=()=>$('draft-conflict').close();
325
327
  for(const mode of ['append','replace'])$('draft-'+mode).onclick=()=>{$('draft-conflict').close();sendDraft(mode)};
326
- $('mapping-search').oninput=()=>{const query=$('mapping-search').value.trim().toLowerCase();$('mapping').querySelectorAll('.ff-mapping-row').forEach(row=>row.hidden=!row.dataset.sourceLabel.toLowerCase().includes(query))};
327
- $('result-search').oninput=filterResults;$('changes').onchange=filterResults;
328
+ $('mapping-search').oninput=e=>{if(e?.isComposing)return;const query=$('mapping-search').value.trim().toLowerCase();$('mapping').querySelectorAll('.ff-mapping-row').forEach(row=>row.hidden=!row.dataset.sourceLabel.toLowerCase().includes(query))};
329
+ $('mapping-search').oncompositionend=()=> $('mapping-search').oninput();
330
+ $('result-search').oninput=e=>{if(!e.isComposing)filterResults()};$('result-search').oncompositionend=filterResults;$('changes').onchange=filterResults;
328
331
  window.addEventListener('message',e=>{if(e.origin!==location.origin||e.source!==parent)return;if(e.data?.type==='ff-theme'&&['light','dark'].includes(e.data.theme))document.body.dataset.ffTheme=e.data.theme;if(e.data?.type==='ff-draft-conflict'){expectedDraft=e.data.draft;if(!$('draft-conflict').open)$('draft-conflict').showModal()}if(e.data?.type==='ff-draft-result'){$('status').textContent=e.data.ok?'已回填,请在对话框修改或发送。':'回填失败,任务已保留。';if(e.data.ok){closeWizard(true);navigate('identity');$('status').textContent='指令已回填,发送后将在此显示查询进度。'}}if(e.data?.type==='ff-navigate'){if(!current&&location.hash){pendingNavigation=e.data.step;return}if(e.data.step==='history')$('history-tab').click();else navigate(e.data.step)}});
329
332
  api('/health').then(s=>{$('connection').textContent=s.qccAvailable?'已发现企查查工商工具,可在对话中执行。':'尚未发现企查查工商工具;请先连接 MCP。合成模板仍可体验。'}).catch(()=>{});
330
333
  $('preview-continue').onclick=async()=>{if(busy||!current)return;controls(true);try{const ids=[...$('changes').querySelectorAll('input:checked')].map(x=>x.value);if(!current.confirmed&&JSON.stringify([...ids].sort())!==JSON.stringify([...(current.selectedIds||[])].sort()))render(await api('/select',{id:current.id,selectedIds:ids}));navigate('download');$('confirm').scrollIntoView({block:'center'});$('confirm').focus();$('status').textContent=current.confirmed?'副本已生成,请点击下载。':'请确认填写结果,点击蓝色按钮生成新副本。'}catch(e){$('status').textContent=e.message}finally{controls(false)}};
@@ -333,6 +336,7 @@ function saveMappingDraft(){if(!current)return;sessionStorage.setItem('ff-ui-map
333
336
  window.addEventListener('pagehide',saveMappingDraft);
334
337
  for(const event of ['change','click'])$('mapping').addEventListener(event,saveMappingDraft);
335
338
  $('mapping').addEventListener('change',()=>{wizardMappingApplied=false;$('mapping-next').hidden=true;highlightMappingConflicts()});
339
+ window.addEventListener('message',e=>{if(e.origin===location.origin&&e.source===parent&&e.data?.type==='ff-origin-unavailable')$('status').textContent='宿主无法打开来源会话,请从宿主历史手动进入;当前任务未改变。'});
336
340
  window.addEventListener('hashchange',()=>{if(new URLSearchParams(location.hash.slice(1)).get('task')!==current?.id)restore()});
337
341
  async function restoreSessionTask(){if(location.hash||!sessionId){restore();return}try{const list=await api('/tasks');if(current||location.hash)return;const task=list.filter(item=>item.sessionId===sessionId).sort((a,b)=>(b.updatedAt||b.created)-(a.updatedAt||a.created))[0];if(task){location.hash='task='+task.id;await restore()}}catch(e){$('status').textContent='恢复任务失败,可在任务历史中重试:'+e.message}}
338
342
  navigate('import');restoreSessionTask();setInterval(()=>{if(!document.hidden&&current&&!busy&&!$('wizard').open)restore(true)},2000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-form-fill-agent",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "private": false,
5
5
  "description": "AI form filling and spreadsheet autofill for XLSX in DeepSeek Harness, using authorized Qichacha MCP facts with cell previews, user confirmation and export to a new copy.",
6
6
  "repository": {