Files
2026-11-05 01:04:53 +08:00

31 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import dynamic from 'next/dynamic';
const PdfPreview=dynamic(()=>import('../components/PdfPreview'),{ssr:false,loading:()=> <div className="pdf-message">正在加载 PDF 预览…</div>});
import Head from 'next/head';
import {useEffect,useState} from 'react';
import {FileText,Braces,Plus,Trash2,ArrowRight,Play,Download,Copy,Upload,Sparkles,ChevronRight,Check,Code2} from 'lucide-react';
import {Field,buildSchema,sampleFields,sampleText} from '../lib/schema';
const types:Field['type'][]=['string','number','boolean','object','array'];
function Editor({fields,onChange,depth=0}:{fields:Field[];onChange:(f:Field[])=>void;depth?:number}){
const update=(i:number,patch:Partial<Field>)=>onChange(fields.map((f,n)=>n===i?{...f,...patch}:f));
return <div className={depth?'nested':''}>{fields.map((f,i)=><div className="field-group" key={i}><div className="field-row"><input aria-label="字段名" value={f.name} placeholder="fieldName" onChange={e=>update(i,{name:e.target.value})}/><select aria-label="字段类型" value={f.type} onChange={e=>update(i,{type:e.target.value as Field['type'],fields:e.target.value==='object'?(f.fields?.length?f.fields:[{name:'value',type:'string',description:''}]):f.fields})}>{types.map(t=><option key={t}>{t}</option>)}</select><input aria-label="提取描述" value={f.description} placeholder="告诉模型提取什么…" onChange={e=>update(i,{description:e.target.value})}/><button className="icon" title="删除字段" onClick={()=>onChange(fields.filter((_,n)=>n!==i))}><Trash2 size={15}/></button></div>{(f.type==='object'||f.type==='array')&&<div className="children"><div className="child-label"><ChevronRight size={13}/>{f.name} <span>{f.fields?.length||0} 个嵌套字段</span>{f.type==='array'&&!f.fields?.length&&<small>当前为文本数组</small>}</div><Editor fields={f.fields||[]} onChange={children=>update(i,{fields:children})} depth={depth+1}/></div>}</div>)}{depth<5&&<button className="add-field" onClick={()=>onChange([...fields,{name:`field${fields.length+1}`,type:'string',description:''}])}><Plus size={14}/>添加{depth?'嵌套':''}字段</button>}</div>;
}
export default function Home(){
const [fields,setFields]=useState<Field[]>(sampleFields),[text,setText]=useState(sampleText),[file,setFile]=useState<File|null>(null),[url,setUrl]=useState(''),[tab,setTab]=useState('config'),[format,setFormat]=useState('formatted'),[result,setResult]=useState<any>(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[health,setHealth]=useState<any>(null),[token,setToken]=useState(''),[task,setTask]=useState<any>(null),[instructions,setInstructions]=useState(''),[resultView,setResultView]=useState('tables'),[schemas,setSchemas]=useState<any[]>([]),[schemaChoice,setSchemaChoice]=useState('');
useEffect(()=>{fetch('/api/v1/health').then(r=>r.json()).then(setHealth).catch(()=>{});try{const saved=localStorage.getItem('extractor-fields');if(saved){const parsed=JSON.parse(saved);buildSchema(parsed);setFields(parsed);}const last=localStorage.getItem('extractor-task');if(last){const restored=JSON.parse(last);setTask(restored);setFields(restored.fields);setText('');}}catch{}},[]);
useEffect(()=>{if(!file)return;const u=URL.createObjectURL(file);setUrl(u);return()=>URL.revokeObjectURL(u);},[file]);
function change(f:Field[]){setTask(null);setInstructions('');localStorage.removeItem('extractor-task');setFields(f);setResult(null);localStorage.setItem('extractor-fields',JSON.stringify(f));}
async function copy(s:string){try{await navigator.clipboard.writeText(s);setNotice('已复制');setTimeout(()=>setNotice(''),2000);}catch{setError('复制失败,请手动选择文本复制');}}
function download(data:any,name:string){const u=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=u;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(u),1000);}
const auth=()=>token?{Authorization:`Bearer ${token}`} as Record<string,string>:{};
async function loadSchemas(){try{const r=await fetch('/api/v1/schemas',{headers:auth()});const p=await r.json();if(!r.ok)throw new Error(p.error?.message||'读取 schema 失败');setSchemas(p.schemas);setNotice(p.schemas.length?'可复用 schema 已刷新':'暂无已保存 schema');setTimeout(()=>setNotice(''),2000);}catch(e:any){setError(e.message);}}
async function saveSchema(){const name=window.prompt('为当前 schema 命名');if(!name)return;try{const r=await fetch('/api/v1/schemas',{method:'POST',headers:{...auth(),'Content-Type':'application/json'},body:JSON.stringify({name,fields})});const p=await r.json();if(!r.ok)throw new Error(p.error?.message||'保存失败');await loadSchemas();setSchemaChoice(p.id);}catch(e:any){setError(e.message);}}
async function refreshTask(id:string){const r=await fetch(`/api/v1/tasks/${id}`,{headers:auth()});if(!r.ok)return;const t=await r.json();setTask(t);const out=await fetch(t.links.result,{headers:auth()});if(out.status===200){setResult(await out.json());setTab('results');}}
useEffect(()=>{if(!task?.id)return;let active=true;const sync=async()=>{try{const r=await fetch(`/api/v1/tasks/${task.id}/result`,{headers:token?{Authorization:`Bearer ${token}`}:{}});if(r.status===200&&active){setResult(await r.json());}}catch{}};sync();const timer=setInterval(sync,3000);return()=>{active=false;clearInterval(timer);};},[task?.id,token]);
async function markdown(t=task){const r=await fetch(t.links.instructions,{headers:auth()});if(!r.ok)throw new Error('无法读取任务说明,请检查访问令牌');return await r.text();}
async function copyInstructions(){try{await copy(await markdown());}catch(e:any){setError(e.message);}}
async function downloadInstructions(){try{const md=await markdown();const u=URL.createObjectURL(new Blob([md],{type:'text/markdown;charset=utf-8'}));const a=document.createElement('a');a.href=u;a.download=`task-${task.id}.md`;a.click();setTimeout(()=>URL.revokeObjectURL(u),1000);}catch(e:any){setError(e.message);}}
function csv(table:any){const rows=[table.columns,...table.rows];const content='\ufeff'+rows.map((row:any[])=>row.map(v=>'"'+String(v??'').replaceAll('"','""')+'"').join(',')).join('\r\n');const u=URL.createObjectURL(new Blob([content],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=u;a.download=table.name+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(u),1000);}
async function run(){setError('');try{buildSchema(fields);}catch(e:any){setError(e.message);return;}setBusy(true);try{const body=new FormData();body.append('fields',JSON.stringify(fields));body.append('title',(file?.name||'文档提取任务').slice(0,120));if(file)body.append('file',file);else if(task&&!text){const source=await fetch(task.links.document,{headers:auth()});if(!source.ok)throw new Error('无法读取已保存原件');body.append('file',await source.blob(),task.document.name);}else body.append('text',text);const r=await fetch('/api/v1/tasks',{method:'POST',body,headers:auth()});const p=await r.json();if(!r.ok)throw new Error(p.error?.message||'请求失败');setTask(p);setResult(null);setInstructions(await markdown(p));localStorage.setItem('extractor-task',JSON.stringify(p));setNotice('任务已保存,复制 Markdown 给 agent 即可');setTimeout(()=>setNotice(''),3500);}catch(e:any){setError(e.message);}finally{setBusy(false);}}
return <><Head><title>Fieldwork · 模块化信息提取器</title><meta name="description" content="将文档变成可调用的结构化数据"/><meta name="viewport" content="width=device-width, initial-scale=1"/></Head><header><div className="brand"><span className="brand-mark"><Braces size={22}/></span>fieldwork<span className="local">LOCAL STUDIO</span></div><a href="/API.md" target="_blank" rel="noreferrer"><Code2 size={16}/>API 文档<ArrowRight size={16}/></a></header><main><div className="intro"><div><div className="eyebrow">DOCUMENT → STRUCTURED DATA</div><h1>让信息,<span>各归其位。</span></h1><p>定义你关心的字段,把繁杂文档转为可直接使用的数据。</p></div><div className="intro-meta"><span className="dot"/>本地工作空间<small>PDF / IMAGE / TEXT</small></div></div><div className="workspace"><section className="source panel"><div className="panel-head"><div><span className="step">01</span><strong>文档来源</strong></div><span className="muted">SOURCE</span></div><div className="source-tabs"><button className={!file?'active':''} onClick={()=>{setFile(null);setResult(null);setTask(null);setInstructions('');localStorage.removeItem('extractor-task');}}>粘贴文本</button><label><Upload size={14}/>上传文档<input type="file" hidden accept=".pdf,.png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" onChange={e=>{const f=e.target.files?.[0];if(f){if(f.size>12*1024*1024){setError('文件不能超过 12 MB');return;}setFile(f);setResult(null);setTask(null);setInstructions('');localStorage.removeItem('extractor-task');}e.target.value='';}}/></label></div>{task&&!file?<div className="preview"><div className="file-info"><FileText size={18}/><span>{task.document.name}<small>{(task.document.size/1024).toFixed(1)} KB · 已保存任务原件</small></span></div>{(task.document.mime==='application/pdf'||/\.pdf$/i.test(task.document.name))?<PdfPreview url={task.links.document} token={token} name={task.document.name}/>:<div className="file-placeholder"><FileText size={40}/><p>文档已安全保存在本机</p><small>Agent 可从任务 API 下载原始文件</small><small>点击「粘贴文本」或上传新文件可开始新任务</small></div>}</div>:file?<div className="preview"><div className="file-info"><FileText size={18}/><span>{file.name}<small>{(file.size/1024).toFixed(1)} KB</small></span><button className="icon" title="移除文件" onClick={()=>{setFile(null);setResult(null);setTask(null);setInstructions('');localStorage.removeItem('extractor-task');}}><Trash2 size={16}/></button></div>{file.type.startsWith('image/')?<img src={url} alt="上传文档预览"/>:(file.type==='application/pdf'||/\.pdf$/i.test(file.name))?<PdfPreview url={url} name={file.name}/>:<div className="file-placeholder"><FileText size={40}/><p>文本文件已就绪</p><small>创建任务后,agent 可通过 API 下载文件</small></div>}</div>:<div className="text-wrap"><div className="paper-label"><FileText size={16}/>原始文档 <span>可编辑</span></div><textarea aria-label="待提取文本" value={text} onChange={e=>{setText(e.target.value);setResult(null);setTask(null);setInstructions('');localStorage.removeItem('extractor-task');}} placeholder="粘贴需要提取的文档内容…"/><div className="text-count">{text.length.toLocaleString()} 字符</div></div>}<div className="source-footer"><span>文件上限 12 MB · 单次处理一份文档</span><button onClick={()=>{setFile(null);setText(sampleText);change(sampleFields);setError('');}}>载入示例 ↗</button></div></section><section className="config panel"><div className="panel-head"><div><span className="step">02</span><strong>提取工作台</strong></div><span className="muted">EXTRACT</span></div><div className="toolbar"><div className="tabs"><button className={tab==='config'?'active':''} onClick={()=>setTab('config')}>字段配置 <span>{fields.length}</span></button><button className={tab==='results'?'active':''} onClick={()=>setTab('results')}>提取结果{result&&<span className="result-dot"/>}</button></div><button className="icon" title="复制字段定义" onClick={()=>copy(JSON.stringify(fields,null,2))}><Copy size={16}/></button></div><div className="config-body">{tab==='config'?<><div className="section-heading"><h2>构建你的提取模块</h2><p>每个字段描述一种信息,数组与对象支持多层嵌套。</p></div><div className="schema-library"><select aria-label="可复用 schema" value={schemaChoice} onChange={e=>setSchemaChoice(e.target.value)}><option value="">选择已保存的 schema…</option>{schemas.map(s=><option key={s.id} value={s.id}>{s.name} · v{s.version}</option>)}</select><button onClick={loadSchemas}>刷新列表</button><button disabled={!schemaChoice} onClick={()=>{const s=schemas.find(s=>s.id===schemaChoice);if(s)change(s.fields);}}>载入</button><button onClick={saveSchema}>保存当前</button></div><div className="field-table"><div className="table-head"><span>字段名称</span><span>类型</span><span>提取说明</span><span/></div><Editor fields={fields} onChange={change}/></div><div className="schema-actions"><button onClick={()=>download(fields,'fields.json')}><Download size={14}/>导出配置</button><label><Upload size={14}/>导入配置<input hidden type="file" accept=".json" onChange={async e=>{try{const f=e.target.files?.[0];if(f){const p=JSON.parse(await f.text());buildSchema(p);change(p);setError('');}}catch(e:any){setError(e.message);}e.target.value='';}}/></label><button onClick={()=>{setFormat(format==='json'?'formatted':'json');}}>查看 JSON Schema</button></div>{format==='json'&&<pre>{(()=>{try{return JSON.stringify(buildSchema(fields),null,2);}catch{return '请先完成有效字段配置';}})()}</pre>}<div className="tip"><Sparkles size={17}/><div><strong>一次定义,交给 agent 执行</strong><p>创建任务 → 复制 Markdown → agent 读取文档 → API 回填结果。</p></div></div></>:<><div className="section-heading"><h2>{'Agent 提取结果'}</h2><p>{result?'Agent 回填的数据已通过 schema 校验,内容仍需复核。':'把任务 Markdown 交给 agent,提交结果后会自动显示。'}</p></div>{result?<><div className="result-tools"><span>{new Date(result.submittedAt).toLocaleString()}</span><button onClick={()=>copy(JSON.stringify(result.data,null,2))}><Copy size={14}/>复制</button><button onClick={()=>download(result.data,'extraction.json')}><Download size={14}/>下载 JSON</button></div><div className="view-switch"><button onClick={()=>setResultView('tables')} className={resultView==='tables'?'active':''}>表格视图</button><button onClick={()=>setResultView('json')} className={resultView==='json'?'active':''}>JSON</button></div>{resultView==='json'?<pre className="result-json">{JSON.stringify(result.data,null,2)}</pre>:<div className="tables">{result.tables?.length?result.tables.map((table:any,i:number)=><div className="result-table" key={i}><div className="table-title">{table.name}<button onClick={()=>csv(table)}>下载 CSV</button></div><div className="table-scroll"><table><thead><tr>{table.columns.map((c:string,j:number)=><th key={j}>{c}</th>)}</tr></thead><tbody>{table.rows.map((row:any[],j:number)=><tr key={j}>{row.map((v,k)=><td key={k}>{v===null?'—':String(v)}</td>)}</tr>)}</tbody></table></div></div>):<p>无表格记录,可切换 JSON 查看完整数据。</p>}</div>}</>:<div className="empty"><Braces size={42}/><p>等待第一份结构化数据</p><p>无需额外模型密钥</p></div>}</>}</div><div className="run-area">{error&&<div role="alert" className="error">{error}</div>}{health?.authRequired&&<input type="password" aria-label="API 访问令牌" placeholder="API 访问令牌" value={token} onChange={e=>setToken(e.target.value)}/>}{task&&<div className="task-card"><div className="task-status"><span className="dot ready"/>{result?'Agent 已提交结果':'任务已保存 · 等待 agent 回填'}</div><code>{task.id}</code><div className="task-buttons"><button onClick={copyInstructions}><Copy size={13}/>复制任务 Markdown</button><button onClick={downloadInstructions}><Download size={13}/>下载 .md</button><button onClick={()=>refreshTask(task.id)}>刷新结果</button></div><details><summary onClick={()=>{if(!instructions)markdown().then(setInstructions).catch(e=>setError(e.message));}}>查看任务说明</summary><pre>{instructions||'正在读取…'}</pre></details></div>}<div className="run-row"><div><span className={`dot ${health?'ready':''}`}/>{health?'Agent API 已就绪':'正在连接服务'}<small>无需模型密钥 · 结果每 3 秒同步</small></div><button className="run" disabled={busy} onClick={run}>{busy?'正在保存…':task?'另建提取任务':'生成提取任务'}{busy?<span className="spinner"/>:<Play size={15} fill="currentColor"/>}</button></div></div></section></div><footer><span>FIELDWORK / 把文档交给机器,把判断留给你。</span><a href="/API.md" target="_blank" rel="noreferrer">为 coding agent 准备的 API 指南 <ArrowRight size={14}/></a></footer></main>{notice&&<div className="toast"><Check size={16}/>{notice}</div>}</>;
}