dsh-form-fill-agent 0.1.0-alpha.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-data-cleaning-agent plugin contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: form-fill-agent
3
+ name: dsh-form-fill-agent
package/lib/client.js ADDED
@@ -0,0 +1,20 @@
1
+ // Optional native entry: never claims or reuses another agent's session.
2
+ window.__ModuleLoader__.load({
3
+ id: 'dsh-form-fill-agent',
4
+ factory(require) {
5
+ function apply(ctx) {
6
+ try {
7
+ const react = require('react');
8
+ if (typeof react?.createElement !== 'function' || typeof ctx.slots?.inject !== 'function' || typeof ctx.slots?.register !== 'function') return;
9
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
10
+ name: 'sidebar.footer.action', id: 'form-fill-agent', order: 12,
11
+ }, function FormFillEntry() {
12
+ return react.createElement('a', { href: '/form-fill/', target: '_blank', rel: 'noopener noreferrer', title: 'AI填表', 'aria-label': '打开 AI填表工作台', style: { display: 'block', padding: '8px 12px', color: 'inherit', textDecoration: 'none' } }, '▦ AI填表');
13
+ }));
14
+ } catch {
15
+ // Public /form-fill/ page remains available if optional Client slots differ.
16
+ }
17
+ }
18
+ return { name: 'form-fill-agent', inject: [], apply };
19
+ },
20
+ });
package/lib/http.js ADDED
@@ -0,0 +1,100 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { applyChangeSet } from 'form-fill-core';
4
+ import { previewBytes } from './workflow.js';
5
+ import { createTaskStore } from './task-store.js';
6
+ const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
7
+ const FIXTURES = ['客户台账', '供应商准入表', '合同主体信息表'];
8
+ export function createFormFillHandler({ basePath = '', getPort, now = Date.now, ttlMs = 15 * 60 * 1000, maxTasks = 10, taskDirectory } = {}) {
9
+ const tasks = createTaskStore({ directory: taskDirectory, maxTasks, now, ttlMs });
10
+ let inFlight = 0, disposed = false;
11
+ const running = new Set();
12
+ const sweep = () => { for (const [id, task] of tasks) if (now() - task.created >= ttlMs) tasks.delete(id); };
13
+ const timer = setInterval(sweep, Math.min(ttlMs, 60000)); timer.unref();
14
+ const handler = async (request, response) => {
15
+ const send = (status, data, type = 'application/json') => {
16
+ response.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" });
17
+ response.end(type === 'application/json' ? JSON.stringify(data) : data);
18
+ };
19
+ if (disposed) return send(503, { message: '插件已卸载,请重新打开' });
20
+ const host = request.headers.host, port = getPort?.();
21
+ if (!Number.isInteger(port) || port === 43120 || !['127.0.0.1:' + port, 'localhost:' + port].includes(host)) return send(403, { message: '仅允许本机隔离端口访问' });
22
+ const origin = 'http://' + host;
23
+ if (request.headers['sec-fetch-site'] === 'cross-site' || (request.headers.origin && request.headers.origin !== origin)) return send(403, { message: '仅允许同源访问' });
24
+ let reserved = false;
25
+ try {
26
+ sweep();
27
+ const url = new URL(request.url, origin);
28
+ if (url.pathname !== basePath && !url.pathname.startsWith(basePath + '/')) return send(404, { message: '路径不存在' });
29
+ const path = url.pathname.slice(basePath.length) || '/';
30
+ if (request.method === 'GET' && path === '/') return send(200, await readFile(new URL('./ui.html', import.meta.url)), 'text/html; charset=utf-8');
31
+ if (request.method === 'GET' && path === '/health') return send(200, { plugin: 'form-fill-agent', product: 'AI填表', version: '0.1.0-alpha.1', provider: 'mock-only', companionRequired: false, taskStorage: taskDirectory ? 'disk' : 'memory', tasks: tasks.size });
32
+ if (request.method === 'GET' && path.startsWith('/task/')) {
33
+ const id = path.slice(6), task = tasks.get(id);
34
+ if (!task) return send(404, { message: '任务不存在或已过期' });
35
+ return send(200, { id, ...task.preview, confirmed: !!task.result });
36
+ }
37
+ if (request.method === 'GET' && path.startsWith('/fixture/')) {
38
+ const name = decodeURIComponent(path.slice(9));
39
+ if (!FIXTURES.includes(name)) return send(404, { message: '模板不存在' });
40
+ return send(200, await readFile(new URL('./fixtures/' + name + '.xlsx', import.meta.url)), XLSX_TYPE);
41
+ }
42
+ if (request.method === 'GET' && path.startsWith('/download/')) {
43
+ const parts = path.split('/'), [, , id, kind] = parts, task = tasks.get(id);
44
+ if (parts.length !== 4 || !task?.result) return send(404, { message: '请先确认写回,或预览已过期' });
45
+ if (kind === 'xlsx') return send(200, task.result.bytes, XLSX_TYPE);
46
+ if (kind === 'changes') return send(200, { kind: 'WritebackReport', changeSet: task.preview.changeSet, appliedChanges: task.result.changes, outputChecksum: task.result.checksum });
47
+ if (kind === 'incomplete') return send(200, task.result.incomplete);
48
+ return send(404, { message: '制品不存在' });
49
+ }
50
+ if (request.method !== 'POST' || !['/preview', '/confirm', '/discard'].includes(path)) return send(404, { message: '路径不存在' });
51
+ if (request.headers.origin !== origin || request.headers['content-type'] !== 'application/json') return send(403, { message: '请求需来自本页' });
52
+ if (path === '/preview') {
53
+ if (tasks.size + inFlight >= maxTasks) return send(429, { message: '预览数量已达上限,请先释放旧预览' });
54
+ inFlight++; reserved = true;
55
+ }
56
+ const chunks = []; let length = 0;
57
+ for await (const chunk of request) {
58
+ length += chunk.length;
59
+ if (length > (path === '/preview' ? 12 * 1024 * 1024 : 4096)) return send(413, { message: '请求过大' });
60
+ chunks.push(chunk);
61
+ }
62
+ let body; try { body = JSON.parse(Buffer.concat(chunks).toString()); } catch { return send(400, { message: '请求格式无效' }); }
63
+ if (!body || Array.isArray(body) || typeof body !== 'object') return send(400, { message: '请求格式无效' });
64
+ if (path === '/preview') {
65
+ if (typeof body.base64 !== 'string' || !body.base64 || !/^[A-Za-z0-9+/]*={0,2}$/.test(body.base64) || body.base64.length % 4) return send(400, { message: '文件编码无效' });
66
+ const bytes = Buffer.from(body.base64, 'base64');
67
+ if (bytes.toString('base64') !== body.base64) return send(400, { message: '文件编码无效' });
68
+ const preview = await previewBytes(bytes), id = randomUUID();
69
+ if (disposed) return send(503, { message: '插件已卸载' });
70
+ tasks.set(id, { bytes, preview, created: now() });
71
+ return send(200, { id, ...preview });
72
+ }
73
+ const task = tasks.get(body.id);
74
+ if (!task) return send(404, { message: '预览已过期,请重新上传' });
75
+ if (running.has(body.id)) return send(409, { message: '任务正在查询,请稍后再操作' });
76
+ if (path === '/discard') { tasks.delete(body.id); return send(200, { discarded: true }); }
77
+ if (body.confirmChangeSetId !== task.preview.changeSet.changeSetId) return send(409, { message: '请确认当前预览' });
78
+ const result = task.result ?? applyChangeSet(task.bytes, task.preview.plan, task.preview.changeSet, { confirmChangeSetId: body.confirmChangeSetId });
79
+ tasks.set(body.id, { ...task, result });
80
+ return send(200, { filled: result.changes.length, incomplete: result.incomplete.length, checksum: result.checksum });
81
+ } catch (error) { return send(400, { code: error.code ?? 'FORM_FILL_ERROR', message: error.code ? error.message : '处理失败,请检查输入文件' }); }
82
+ finally { if (reserved) inFlight--; }
83
+ };
84
+ return {
85
+ handler,
86
+ async enrich(id, provider) {
87
+ sweep();
88
+ const task = tasks.get(id);
89
+ if (disposed || !task || task.result || running.has(id)) throw Error('任务不存在、已确认或正在执行');
90
+ running.add(id);
91
+ try {
92
+ const preview = await previewBytes(task.bytes, { provider, confirmPaidCalls: true });
93
+ if (disposed || tasks.get(id) !== task) throw Error('任务已过期或被替换');
94
+ tasks.set(id, { ...task, preview, revision: (task.revision ?? 1) + 1 });
95
+ return { taskId: id, filled: preview.changeSet.changes.length, incomplete: preview.changeSet.incomplete.length, previewPath: basePath + '/#task=' + id };
96
+ } finally { running.delete(id); }
97
+ },
98
+ dispose() { if (disposed) return; disposed = true; clearInterval(timer); tasks.close(); },
99
+ };
100
+ }
package/lib/index.js ADDED
@@ -0,0 +1,40 @@
1
+ import { createFormFillHandler } from './http.js';
2
+ import { createQccProvider, REGISTRATION_TOOL } from 'qcc-form-fill-provider';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ export { previewBytes, previewFile, writeCopy } from './workflow.js';
6
+ export const name = 'form-fill-agent';
7
+ export const inject = [];
8
+ export function apply(ctx, config = {}) {
9
+ ctx.inject(['webServer'], scope => {
10
+ const taskDirectory = config.taskDirectory ?? (process.env.DSH_HOME ? join(process.env.DSH_HOME, 'form-fill-tasks') : undefined);
11
+ const service = createFormFillHandler({ basePath: '/form-fill', getPort: () => scope.webServer.port, taskDirectory, ttlMs: 86400000 });
12
+ scope.inject?.(['tools'], toolScope => {
13
+ const tools = toolScope.tools;
14
+ const disposeTool = tools.register({
15
+ name: 'form_fill_enrich',
16
+ description: 'Use QCC to fill an uploaded AI填表 task. Invoke only when the user requests QCC enrichment. Returns counts and a preview link; the user confirms cell changes in the workbench.',
17
+ parameters: { type: 'object', additionalProperties: false, properties: { taskId: { type: 'string' } }, required: ['taskId'] },
18
+ output: {
19
+ schema: { type: 'object', properties: { taskId: { type: 'string' }, filled: { type: 'integer' }, incomplete: { type: 'integer' }, previewPath: { type: 'string' } }, required: ['taskId','filled','incomplete','previewPath'] },
20
+ render: (_args, value) => [{ type: 'text', text: 'AI填表:可填写 ' + value.filled + ' 格,未完成 ' + value.incomplete + ' 项。预览:' + value.previewPath }],
21
+ },
22
+ async execute(args, execution) {
23
+ if (!execution?.agent || !execution?.token) throw Error('需要 Agent-owned 工具执行上下文');
24
+ const provider = createQccProvider({ callTool: async (name, arguments_, { signal }) => {
25
+ if (name !== REGISTRATION_TOOL) throw Error('不支持的 QCC 工具');
26
+ const names = ['mcp__qcc-company__', 'mcp__company__', 'mcp__qcc_company__'].map(prefix => prefix + name);
27
+ const selected = names.find(candidate => tools.get(candidate));
28
+ if (!selected) throw Error('请先连接企查查企业数据 MCP');
29
+ const result = await tools.execute({ name: selected, arguments: arguments_, signal, callId: randomUUID(), rootCallId: execution.rootCallId, parent: execution.token, agent: execution.agent });
30
+ return result?.isError ? { isError: true } : result?.value;
31
+ } });
32
+ return service.enrich(args.taskId, provider);
33
+ },
34
+ });
35
+ toolScope.effect?.(() => () => disposeTool?.());
36
+ });
37
+ const disposeRoute = scope.webServer.register({ kind: 'prefix', path: '/form-fill', handler: service.handler });
38
+ ctx.effect(() => () => { disposeRoute?.(); service.dispose(); });
39
+ });
40
+ }
@@ -0,0 +1,69 @@
1
+ import { mkdirSync, lstatSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, rmdirSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { assertPlan, assertChangeSet, parseWorkbook, applyChangeSet } from 'form-fill-core';
5
+ const idPattern = /^[0-9a-f-]{36}$/;
6
+ export function createTaskStore({ directory, maxTasks = 10, now = Date.now, ttlMs = 86400000 } = {}) {
7
+ const tasks = new Map();
8
+ let root, lock;
9
+ if (directory) {
10
+ root = resolve(directory);
11
+ mkdirSync(root, { recursive: true, mode: 0o700 });
12
+ if (!lstatSync(root).isDirectory() || lstatSync(root).isSymbolicLink() || (process.platform !== 'win32' && (lstatSync(root).mode & 0o077))) throw Error('Task directory must be private (0700) and not a symlink');
13
+ lock = join(root, '.lock');
14
+ try { mkdirSync(lock, { mode: 0o700 }); }
15
+ catch (error) {
16
+ if (error.code !== 'EEXIST') throw error;
17
+ const pid = Number(readFileSync(join(lock, 'pid'), 'utf8'));
18
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw Error('Invalid task store lock');
19
+ let alive = true;
20
+ try { process.kill(pid, 0); } catch (e) { if (e.code === 'ESRCH') alive = false; }
21
+ if (alive) throw Error('Task store already in use');
22
+ unlinkSync(join(lock, 'pid')); rmdirSync(lock); mkdirSync(lock, { mode: 0o700 });
23
+ }
24
+ writeFileSync(join(lock, 'pid'), String(process.pid), { mode: 0o600, flag: 'wx' });
25
+ try {
26
+ for (const file of readdirSync(root)) {
27
+ if (!file.endsWith('.json')) continue;
28
+ const id = file.slice(0,-5), path = join(root,file);
29
+ if (!idPattern.test(id) || lstatSync(path).isSymbolicLink() || lstatSync(path).size > 48 * 1024 * 1024) throw Error('Invalid persisted task');
30
+ const saved = JSON.parse(readFileSync(path,'utf8'));
31
+ if (saved.schema !== 1 || saved.id !== id || !Number.isFinite(saved.created)) throw Error('Invalid task schema');
32
+ if (now() - saved.created >= ttlMs) { unlinkSync(path); continue; }
33
+ if (tasks.size >= maxTasks) throw Error('Task capacity exceeded');
34
+ const bytes = Buffer.from(saved.base64,'base64');
35
+ const doc = parseWorkbook(bytes);
36
+ if (saved.preview) {
37
+ assertPlan(saved.preview.plan); assertChangeSet(saved.preview.changeSet);
38
+ if (saved.preview.plan.documentHash !== doc.documentHash || saved.preview.changeSet.planId !== saved.preview.plan.planId) throw Error('Persisted document mismatch');
39
+ }
40
+ const task = { bytes, preview: saved.preview, created: saved.created, revision: saved.revision ?? 1 };
41
+ if (saved.confirmed && task.preview) task.result = applyChangeSet(bytes,task.preview.plan,task.preview.changeSet,{confirmChangeSetId:task.preview.changeSet.changeSetId});
42
+ tasks.set(id,task);
43
+ }
44
+ } catch (error) { unlinkSync(join(lock,'pid')); rmdirSync(lock); throw error; }
45
+ }
46
+ return {
47
+ get size() { return tasks.size; },
48
+ [Symbol.iterator]: () => tasks[Symbol.iterator](),
49
+ get: id => tasks.get(id),
50
+ set(id, task) {
51
+ if (!idPattern.test(id)) throw Error('Invalid task ID');
52
+ if (!tasks.has(id) && tasks.size >= maxTasks) throw Error('Task capacity exceeded');
53
+ if (root) {
54
+ const json = JSON.stringify({ schema:1,id,base64:task.bytes.toString('base64'),preview:task.preview,created:task.created,revision:task.revision ?? 1,confirmed:!!task.result });
55
+ if (Buffer.byteLength(json) > 48*1024*1024) throw Error('Task too large');
56
+ const temp = join(root,'.'+randomUUID()+'.tmp');
57
+ try { writeFileSync(temp,json,{mode:0o600,flag:'wx'});renameSync(temp,join(root,id+'.json')); }
58
+ finally { try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; } }
59
+ }
60
+ tasks.set(id,task);
61
+ },
62
+ delete(id) {
63
+ if (!idPattern.test(id)) return false;
64
+ if (root) { try { unlinkSync(join(root,id+'.json')); } catch (e) { if(e.code!=='ENOENT')throw e; } }
65
+ return tasks.delete(id);
66
+ },
67
+ close() { tasks.clear(); if(lock) { unlinkSync(join(lock,'pid'));rmdirSync(lock);lock=null; } },
68
+ };
69
+ }
package/lib/ui.html ADDED
@@ -0,0 +1,57 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
3
+ <title>AI填表工作台</title>
4
+ <style>
5
+ :root{color-scheme:light dark;font-family:system-ui,sans-serif;background:#f4f7f9;color:#203848}*{box-sizing:border-box}body{max-width:1120px;margin:48px auto;padding:0 24px}h1{font-size:36px;margin-bottom:8px}p{line-height:1.7}.badge{font-size:13px;background:#dcece9;color:#205e50;border-radius:16px;padding:6px 12px;display:inline-block}.card{background:white;border:1px solid #d8e2e8;border-radius:16px;padding:24px;margin:24px 0}.controls{display:flex;gap:12px;flex-wrap:wrap;align-items:center}button,a.download{background:#225f73;color:white;padding:11px 18px;border:0;border-radius:8px;font-size:15px;cursor:pointer;text-decoration:none}button.secondary{background:#e9f0f4;color:#225064}button:disabled{opacity:.45;cursor:default}button:focus-visible,a:focus-visible{outline:3px solid #e8a829;outline-offset:3px}input{max-width:100%;font-size:15px}.scroll{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:14px}th,td{text-align:left;border-bottom:1px solid #e0e6eb;padding:12px;vertical-align:top;overflow-wrap:anywhere;min-width:70px}th{background:#edf3f6}td small{display:block;color:#64798a;margin-top:5px}#status{min-height:28px;color:#315e70}#summary{font-weight:650}h2{font-size:21px}#downloads{margin-top:18px;display:flex;gap:12px;flex-wrap:wrap}.muted{color:#607888;font-size:14px}@media(max-width:640px){body{margin:24px auto;padding:0 14px}.card{padding:16px}h1{font-size:30px}}@media(prefers-color-scheme:dark){:root{background:#15232d;color:#dce7ed}.card{background:#1b2d39;border-color:#354c59}th{background:#243f4e}td,th{border-color:#38505e}.muted,td small,#status{color:#adcad7}.badge{background:#244c46;color:#c5e6da}button.secondary{background:#304b5a;color:#d4e7f0}}
6
+ @media(max-width:640px){.scroll table,.scroll tbody,.scroll tr{display:block;width:100%}.scroll thead{display:none}.scroll tr{padding:8px 0;border-bottom:1px solid #71818c55}.scroll td{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px;border:0;padding:6px 0;min-width:0}.scroll td::before{content:attr(data-label);font-weight:600;opacity:.7}}
7
+ </style>
8
+ <span class="badge">AI填表 · 单元格预览 · 原件不覆盖</span>
9
+ <h1>AI填表</h1><p>上传已有 Excel,查看哪些空位可以填写,确认后下载新副本。</p>
10
+ <div class="card">
11
+ <h2>1. 选择表格</h2>
12
+ <p class="muted">仅支持简单 XLSX 表格,最大 8 MiB。含公式、宏、图片、数据验证等复杂结构会明确拒绝。模板体验使用合成数据。真实表格上传后可在 DSH 对话中使用企查查查询。</p>
13
+ <div class="controls"><input type="file" id="file" accept=".xlsx" aria-label="选择 XLSX 文件"><button id="upload">分析表格</button></div>
14
+ <p>也可以直接体验三套合成模板:</p>
15
+ <div class="controls" id="samples"><button class="secondary" data-name="客户台账">客户台账</button><button class="secondary" data-name="供应商准入表">供应商准入表</button><button class="secondary" data-name="合同主体信息表">合同主体信息表</button></div>
16
+ </div>
17
+ <p id="status" role="status" aria-live="polite"></p>
18
+ <section id="preview" hidden>
19
+ <div class="card"><h2>企查查补全与任务恢复</h2><p>首次分析不调用企查查。需要真实数据时,复制下面的任务指令到 DSH 对话,完成后点击刷新。保留本页地址可恢复任务;DSH 中默认保存 24 小时,可手动删除。</p><p id="qcc-command" style="overflow-wrap:anywhere"></p><div class="controls"><button id="copy-command">复制查询指令</button><button id="refresh">刷新任务</button><button id="discard" class="secondary">删除任务</button></div></div>
20
+ <div class="card"><h2>2. 预览填写内容</h2><p id="summary"></p><p id="structure" class="muted"></p><p class="muted">默认只填空白,已有值保持原样。每个值均标注来源、获取时间及置信度。</p>
21
+ <div class="scroll"><table><thead><tr><th>位置</th><th>字段</th><th>原值</th><th>准备填写</th><th>依据与来源</th></tr></thead><tbody id="changes"></tbody></table></div></div>
22
+ <div class="card"><h2>需要你处理的内容</h2><div class="scroll"><table><thead><tr><th>位置</th><th>字段</th><th>原因</th></tr></thead><tbody id="incomplete"></tbody></table></div></div>
23
+ <div class="card"><h2>3. 确认并生成副本</h2><p>原文件不会被覆盖。确认后可下载已填表格、变更清单及未完成项。</p><button id="confirm">确认这些填写,生成新副本</button><div id="downloads"></div></div>
24
+ </section>
25
+ <script>
26
+ const basePath=location.pathname.startsWith('/form-fill')?'/form-fill':'';
27
+ const $=id=>document.getElementById(id);let current,filename='已填表格',busy=false;
28
+ const reasons={'unknown-field':'缺少可靠来源,请人工填写','no-match':'未匹配到企业','candidate-review-required':'主体需要人工确认,请使用完整登记名称','no-data':'该字段未返回','hidden-sheet':'隐藏工作表保持原样','hidden-row':'隐藏行保持原样','hidden-or-merged':'隐藏或合并位置需人工确认','placeholder-needs-confirmation':'已有占位文字,默认不覆盖','missing-anchor':'缺少主体名称','header-not-found':'未识别可靠表头','ambiguous-mapping':'重复字段需要人工选择','multiple-tables':'多个表头,请先拆分','provider-error':'企查查工具调用失败或未连接,请稍后重试','low-confidence':'置信度不足','missing-provenance':'缺少来源证据','unsafe-value':'值不安全,已阻止写入'};
29
+ function controls(value){busy=value;document.querySelectorAll('button').forEach(b=>b.disabled=value)}
30
+ function row(target,values,labels){const tr=document.createElement('tr');values.forEach((value,i)=>{const td=document.createElement('td');td.textContent=value;td.dataset.label=labels[i];tr.append(td)});$(target).append(tr)}
31
+ async function api(path,body){const response=await fetch(basePath+path,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const data=await response.json();if(!response.ok)throw Error(data.message);return data}
32
+ function downloads(){ $('downloads').replaceChildren();for(const [kind,label,suffix] of [['xlsx','下载已填副本','-已填副本.xlsx'],['changes','下载变更清单','-变更.json'],['incomplete','下载未完成项','-未完成.json']]){const a=document.createElement('a');Object.assign(a,{className:'download',href:basePath+'/download/'+current.id+'/'+kind,download:filename+suffix,textContent:label});$('downloads').append(a)}}
33
+ function render(data){
34
+ current=data;location.hash='task='+data.id;$('changes').replaceChildren();$('incomplete').replaceChildren();$('downloads').replaceChildren();
35
+ const real=data.plan.paidCalls>0;
36
+ $('summary').textContent='可填写 '+data.changeSet.changes.length+' 格 · 预计 '+data.plan.estimatedCalls+' 次'+(real?'企查查调用':'合成调用 · 费用 0')+' · 未完成 '+data.changeSet.incomplete.length+' 项';
37
+ $('structure').textContent=data.analysis.tables.map(t=>t.sheet+':表头在第 '+t.headerRow+' 行').join(';');
38
+ $('qcc-command').textContent='使用企查查填写任务 '+data.id+',请调用 form_fill_enrich,taskId 为 '+data.id;
39
+ for(const c of data.changeSet.changes)row('changes',[c.sheet+' ! '+c.cell,c.label,c.oldValue||'(空白)',c.newValue,(c.source.startsWith('qcc://')?'企查查 · '+c.source.slice(6):'合成工商示例')+' · 获取于 '+c.acquiredAt.slice(0,10)+' · '+Math.round(c.confidence*100)+'%'],['位置','字段','原值','准备填写','来源']);
40
+ for(const i of data.changeSet.incomplete)row('incomplete',[i.sheet+' '+(i.cell||''),i.label||'—',reasons[i.reason]||'此项需要人工处理'],['位置','字段','原因']);
41
+ $('preview').hidden=false;if(data.confirmed)downloads();$('status').textContent='预览已准备好,请检查后确认。';
42
+ }
43
+ async function restore(){if(busy)return;const id=new URLSearchParams(location.hash.slice(1)).get('task');if(!id)return;controls(true);try{render(await api('/task/'+encodeURIComponent(id)))}catch(e){$('status').textContent=e.message}finally{controls(false)}}
44
+ async function preview(buffer,name){
45
+ controls(true);$('status').textContent='正在识别表头、主体和待填空位…';$('preview').hidden=true;
46
+ 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));
47
+ const data=await api('/preview',{base64:btoa(binary)});filename=name.replace(/\.xlsx$/i,'');render(data);
48
+ }catch(e){$('status').textContent=e.message}finally{controls(false)}
49
+ }
50
+ $('upload').onclick=async()=>{const f=$('file').files[0];if(!f){$('status').textContent='请先选择 XLSX 文件';return}await preview(await f.arrayBuffer(),f.name)};
51
+ $('samples').onclick=async e=>{if(!e.target.dataset.name||busy)return;const name=e.target.dataset.name;try{const r=await fetch(basePath+'/fixture/'+encodeURIComponent(name));if(!r.ok)throw Error('模板读取失败');await preview(await r.arrayBuffer(),name)}catch(e){$('status').textContent=e.message}};
52
+ $('confirm').onclick=async()=>{if(!current||busy)return;controls(true);try{await api('/confirm',{id:current.id,confirmChangeSetId:current.changeSet.changeSetId});downloads();$('status').textContent='副本已生成,原文件保持不变。'}catch(e){$('status').textContent=e.message}finally{controls(false)}};
53
+ $('refresh').onclick=restore;
54
+ $('discard').onclick=async()=>{if(!current||busy)return;controls(true);try{await api('/discard',{id:current.id});current=null;history.replaceState(null,'',location.pathname);$('preview').hidden=true;$('status').textContent='任务及保存的表格已删除。'}catch(e){$('status').textContent=e.message}finally{controls(false)}};
55
+ $('copy-command').onclick=async()=>{try{await navigator.clipboard.writeText($('qcc-command').textContent);$('status').textContent='已复制,请在 DSH 对话中发送。'}catch{$('status').textContent='请手动复制上面的文字。'}};
56
+ restore();
57
+ </script></html>
@@ -0,0 +1,34 @@
1
+ import { readFile, mkdir, writeFile, lstat } from 'node:fs/promises';
2
+ import { join, resolve, dirname, basename } from 'node:path';
3
+ import { analyzeDocument, buildFillPlan, executePlan, applyChangeSet, serialize, FillError } from 'form-fill-core';
4
+ import { createMockProvider, FIELD_CATALOG } from 'qcc-form-fill-provider';
5
+
6
+ export async function previewBytes(bytes, { provider = createMockProvider(), maxCalls = 100, confirmPaidCalls = false } = {}) {
7
+ if (provider.mode !== 'mock' && (provider.mode !== 'qcc' || confirmPaidCalls !== true)) throw new FillError('REAL_PROVIDER_DISABLED', '真实来源需要调用方明确授权');
8
+ const { analysis } = analyzeDocument(bytes, FIELD_CATALOG);
9
+ const plan = buildFillPlan(analysis, provider.capabilities, provider.version);
10
+ const changeSet = await executePlan(plan, provider, { maxCalls, confirmPaidCalls });
11
+ return { analysis, plan, changeSet };
12
+ }
13
+ export async function previewFile(path, options) {
14
+ if (!path.toLowerCase().endsWith('.xlsx')) throw new FillError('NOT_XLSX', '请选择 XLSX 文件');
15
+ const stat = await lstat(path);
16
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8 * 1024 * 1024) throw new FillError('FILE_TOO_LARGE', '需要不超过 8 MiB 的普通 XLSX 文件');
17
+ return previewBytes(await readFile(path), options);
18
+ }
19
+ export async function writeCopy(inputPath, outputDirectory, preview, confirmation) {
20
+ const input = resolve(inputPath), directory = resolve(outputDirectory);
21
+ // Exclusive directory creation prevents overwrite and simultaneous duplicate writes.
22
+ if (directory === input || dirname(input) === directory) throw new FillError('OUTPUT_PATH', '请选择新的独立输出目录');
23
+ const result = applyChangeSet(await readFile(input), preview.plan, preview.changeSet, { confirmChangeSetId: confirmation });
24
+ await mkdir(directory, { recursive: false, mode: 0o700 });
25
+ const paths = {
26
+ workbook: join(directory, basename(input, '.xlsx') + '-已填副本.xlsx'),
27
+ changes: join(directory, 'changes.json'), incomplete: join(directory, 'incomplete.json'), plan: join(directory, 'plan.json'),
28
+ };
29
+ await writeFile(paths.workbook, result.bytes, { flag: 'wx', mode: 0o600 });
30
+ await writeFile(paths.changes, JSON.stringify({ kind: 'WritebackReport', changeSet: preview.changeSet, appliedChanges: result.changes, outputChecksum: result.checksum }, null, 2), { flag: 'wx', mode: 0o600 });
31
+ await writeFile(paths.incomplete, JSON.stringify(result.incomplete, null, 2), { flag: 'wx', mode: 0o600 });
32
+ await writeFile(paths.plan, serialize(preview.plan), { flag: 'wx', mode: 0o600 });
33
+ return { paths, checksum: result.checksum, filled: result.changes.length, incomplete: result.incomplete.length };
34
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "dsh-form-fill-agent",
3
+ "version": "0.1.0-alpha.1",
4
+ "private": false,
5
+ "description": "AI填表智能体:XLSX blank-cell preview, QCC enrichment and persistent tasks for DSH.",
6
+ "repository": { "type": "git", "url": "git+https://github.com/duhu2000/dsh-form-fill-agent.git", "directory": "packages/dsh-form-fill-agent" },
7
+ "publishConfig": { "access": "public", "tag": "next", "registry": "https://registry.npmjs.org/" },
8
+ "type": "module",
9
+ "main": "lib/index.js",
10
+ "exports": {
11
+ ".": "./lib/index.js",
12
+ "./http": "./lib/http.js",
13
+ "./client": "./lib/client.js"
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "cordis.patch.yml",
18
+ "LICENSE"
19
+ ],
20
+ "license": "MIT",
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "dependencies": {
25
+ "form-fill-core": "0.1.0-alpha.1",
26
+ "qcc-form-fill-provider": "0.1.0-alpha.1"
27
+ },
28
+ "dsh": {
29
+ "client": { "inject": [], "platform": "web" },
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ }
33
+ }
34
+ }