31 lines
16 KiB
TypeScript
31 lines
16 KiB
TypeScript
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>}</>;
|
||
}
|