feat: 完成项目助手与监控演示并修复聊天及云部署配置

This commit is contained in:
NathanAlex7
2026-09-11 15:56:28 +08:00
parent c57d291f65
commit af1a1027d7
75 changed files with 5215 additions and 101 deletions
+27
View File
@@ -0,0 +1,27 @@
import {NextRequest, NextResponse} from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const allowed = new Set(["chat", "sessions", "files", "skills", "tokens", "config", "xingbang"]);
async function proxy(request:NextRequest, {params}:{params:{path:string[]}}) {
// General chat has filesystem/tools, unlike the scoped project APIs: local only.
if (!["localhost","127.0.0.1","::1","[::1]"].includes(request.nextUrl.hostname))
return NextResponse.json({detail:"原始开发聊天仅允许本机访问"},{status:403});
if (!allowed.has(params.path[0]) || params.path.some(p=>!p || p==="." || p===".." || /[\\/]/.test(p)))
return NextResponse.json({detail:"接口不存在"},{status:404});
const base = process.env.CORE_BACKEND_URL || process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002";
try {
const upstream = await fetch(`${base}/api/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`, {
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json"},
body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),
cache:"no-store",redirect:"error",signal:request.signal,
});
const headers = new Headers({"Content-Type":upstream.headers.get("content-type")||"application/json", "Cache-Control":"no-cache, no-transform", "X-Accel-Buffering":"no"});
// Never arrayBuffer()/text() an SSE response: pass through chunks immediately.
return new NextResponse(upstream.body,{status:upstream.status,headers});
} catch {
return NextResponse.json({detail:"当前聊天后端未连接,请检查本地服务"},{status:502});
}
}
export {proxy as GET, proxy as POST, proxy as PUT, proxy as DELETE};
+1 -1
View File
@@ -4,7 +4,7 @@ async function proxy(request: NextRequest, {params}: {params:{path:string[]}}) {
const base=process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002";
try {
const response=await fetch(`${base}/api/xingbang/risk-agent/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`,{
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Client-Host":request.headers.get("host")||""},
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Client-Host":request.headers.get("host")||"", "Idempotency-Key":request.headers.get("idempotency-key")||""},
body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),cache:"no-store",
});
const headers=new Headers({"Content-Type":response.headers.get("content-type")||"application/json"});
+2
View File
@@ -2,3 +2,5 @@
@media(max-width:760px){.ra-heading{flex-wrap:wrap}.ra-heading>.ra-button{white-space:nowrap;min-width:128px}.ra-heading>div{flex-basis:100%}}
.ra-rule-row,.ra-review-row{padding:20px 0;border-bottom:1px solid #dfe5e9;margin-bottom:18px}.ra-review-row h3{font-weight:600;font-size:15px}.ra-rule-row .ra-field{margin-bottom:12px}
.ra-alignment{margin:24px 0}.ra-alignment h3{font-size:14px;font-weight:600;margin-bottom:18px}
.xb-project-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}.xb-project-card{text-align:left;border:1px solid #dfe7e2;background:#fff;border-radius:10px;padding:22px;cursor:pointer;color:#334b3a}.xb-project-card:hover{border-color:#82ad91;box-shadow:0 7px 24px rgba(32,92,59,.08)}.xb-project-card>span{font-size:10px;color:#34805a;background:#e8f4ec;padding:4px 7px;border-radius:12px}.xb-project-card h2{font-size:17px;margin:18px 0 8px}.xb-project-card p{font-size:11px;color:#829087}.xb-project-card footer{display:flex;align-items:center;gap:10px;border-top:1px solid #edf1ee;margin-top:20px;padding-top:15px}.xb-project-card footer small{margin-left:auto}.xb-back{border:0;background:transparent;color:#47755a;cursor:pointer;margin-bottom:22px}.xb-tabs{display:flex;gap:4px;border-bottom:1px solid #dfe6e1;margin-bottom:22px;overflow:auto}.xb-tabs button{border:0;background:transparent;padding:13px 17px;color:#788a7e;white-space:nowrap;cursor:pointer}.xb-tabs button.active{color:#17633f;border-bottom:2px solid #17633f}.xb-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.xb-cards article,.xb-panel{background:#fff;border:1px solid #e0e7e2;border-radius:8px;padding:22px}.xb-cards b,.xb-cards small{display:block;color:#829087;font-size:11px}.xb-cards strong{display:block;color:#225f41;font-size:25px;margin:15px 0}.xb-panel{max-width:920px}.xb-panel h2{font-size:18px;margin-bottom:18px}.xb-panel>p{font-size:12px;color:#687b6e;line-height:1.8;margin-bottom:18px}.xb-finding{display:flex;gap:14px;padding:16px 0;border-bottom:1px solid #edf1ee}.xb-finding:last-of-type{margin-bottom:20px}.xb-finding b{font-size:13px}.xb-finding p{font-size:12px;margin:7px 0;color:#627367}.xb-finding small{font-size:10px;color:#94a097}.xb-status{height:23px;white-space:nowrap;font-size:10px;color:#a05e19;background:#fff2df;padding:5px 8px;border-radius:12px}.xb-status.ok{color:#29734c;background:#e6f4eb}.xb-compare{display:flex;align-items:center;gap:20px;margin-top:24px;padding:22px;background:#f3f8f4;border-radius:8px}.xb-compare strong{font-size:27px;color:#276947}.xb-compare small{margin-left:auto;max-width:260px;color:#7b887f}.xb-report{margin-top:20px;border-top:1px solid #e3e9e5;padding-top:20px}.xb-report>small{color:#a36d2d}.xb-report article{margin-top:18px}.xb-report h3{font-size:14px;margin-bottom:8px}.xb-report pre{white-space:pre-wrap;font:12px/1.8 Arial,"Microsoft YaHei",sans-serif;color:#53645a}@media(max-width:760px){.xb-cards{grid-template-columns:1fr}.xb-compare{align-items:flex-start;flex-wrap:wrap}.xb-compare small{margin-left:0;width:100%}}
.xb-wide{max-width:none;margin-top:20px}.xb-task{background:#fff;border:1px solid #dfe6e1;border-radius:8px;padding:20px;margin:15px 0 22px}.xb-task .ra-field{margin-bottom:12px}.xb-task small{display:block;color:#66776d;margin-top:12px}.xb-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0}.xb-actions input,.xb-actions select,.xb-panel>select,.xb-report>select{background:#fff;border:1px solid #dbe1e7;border-radius:5px;padding:9px 11px;font-size:12px;max-width:100%}.xb-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 20px;margin-top:20px}.xb-grow{flex:1;min-width:0}.xb-panel pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:450px;overflow:auto;font:12px/1.7 Consolas,"Microsoft YaHei",monospace;background:#f6f8f7;padding:14px;margin:10px 0}.xb-panel details{font-size:12px;line-height:1.8;margin:12px 0}.xb-panel summary{cursor:pointer;color:#345e49}.xb-panel h3{font-size:15px;font-weight:600;margin:20px 0 12px}.xb-panel p{font-size:12px;line-height:1.8;overflow-wrap:anywhere;margin:8px 0}.xb-panel a{color:#17634c;text-decoration:underline}.xb-step{border-bottom:1px solid #e5ece7;padding:10px 0}.xb-warning{color:#9a5a16}.xb-document{border-top:1px solid #dfe6e1;margin-top:20px;padding-top:12px}.xb-section-text{width:100%;min-height:170px;border:1px solid #dbe1e7;padding:14px;font:13px/1.8 Arial,"Microsoft YaHei",sans-serif;border-radius:5px;resize:vertical}.xb-section-text:disabled{color:#526258;background:#f5f7f5}.xb-scenario{border-top:1px solid #e5ece7;padding:15px 0}.xb-finding{flex-wrap:wrap}.xb-finding small{overflow-wrap:anywhere}.xb-config-list .xb-finding>div{flex:1;min-width:250px}@media(max-width:760px){.xb-form-grid{grid-template-columns:1fr}.xb-task{padding:14px}}
File diff suppressed because one or more lines are too long
+103
View File
@@ -0,0 +1,103 @@
"use client";
import {useEffect, useRef, useState} from "react";
import {Activity, ArrowRight, CheckCircle2, Database, FlaskConical, Play, RefreshCw, ShieldCheck, SlidersHorizontal, Square, X} from "lucide-react";
import AgentNav from "@/components/xingbang/AgentNav";
import "./operations.css";
const ROOT = "/api/risk-agent/project-assistant/operations";
const names: Record<string, string> = {queued:"排队中", running:"执行中", completed:"执行完成", partial:"部分完成", failed:"执行失败", cancelled:"已取消", pass:"检查通过", issue:"发现问题", unknown:"依据不足", not_applicable:"不适用", error:"错误", skipped:"未执行", ok:"正常", unverified:"未验证", unavailable:"不可用"};
const stamp = (value?:string) => value ? new Date(value).toLocaleString("zh-CN", {hour12:false}) : "—";
const fmt = (value:any) => value === null || value === undefined ? "—" : String(value);
function Badge({value}:{value:string}) {return <span className={`oc-badge ${value}`}>{names[value] || value}</span>;}
async function api(path:string, body?:unknown, key?:string) {
const token = sessionStorage.getItem("xingbang-access-token");
const response = await fetch(ROOT+path, {method:body===undefined?"GET":"POST", cache:"no-store", headers:{"Content-Type":"application/json", ...(token?{Authorization:`Bearer ${token}`} : {}), ...(key?{"Idempotency-Key":key}:{})}, body:body===undefined?undefined:JSON.stringify(body)});
const data = await response.json();
if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : "请求失败,请核对输入与权限");
return data;
}
export default function OperationsCenter() {
const [domain,setDomain] = useState("lab"), [view,setView] = useState("monitor"), [ready,setReady] = useState(false);
const [overview,setOverview] = useState<any>(null), [source,setSource] = useState<any>(null), [detail,setDetail] = useState<any>(null);
const [selected,setSelected] = useState(""), [status,setStatus] = useState("all"), [search,setSearch] = useState("");
const [busy,setBusy] = useState(false), [error,setError] = useState(""), [notice,setNotice] = useState(""), [health,setHealth] = useState<any>(null);
const [connectionError,setConnectionError] = useState("");
const [form,setForm] = useState<any>({}), [dirty,setDirty] = useState(false), [model,setModel] = useState(false), [requirement,setRequirement] = useState("");
const [compareId,setCompareId] = useState(""), [comparison,setComparison] = useState<any>(null);
const epoch = useRef(0);
const admin = overview?.identity?.preview || overview?.identity?.role === "admin";
const loadSource = async () => {
const data = await api("/lab/source"); setSource(data);
setForm({revision:data.project.revision, amount:data.project.amount, liabilities:data.finance.liabilities, dd_liabilities:data.finance.dd_liabilities, cashflow:data.finance.cashflow ?? "", duplicate:!!data.assets.find((a:any)=>a.row_id===3)?.active, enabled:!!data.project.enabled});
setDirty(false);
};
useEffect(()=>{const q=new URLSearchParams(location.search); setDomain(q.get("domain")==="current" || (q.has("project") && !q.has("domain")) ? "current":"lab"); setView(["monitor","trace","lab","health"].includes(q.get("view")||"")?q.get("view")!:"monitor"); setReady(true);},[]);
useEffect(()=>{
if(!ready)return;
const version=++epoch.current; let disposed=false, loading=false;
setOverview(null);setSelected("");setDetail(null);setSource(null);setError("");setCompareId("");setComparison(null);
async function load(){if(loading)return;loading=true;try{const data=await api(`/overview?domain=${domain}`);if(!disposed&&epoch.current===version){setOverview(data);setConnectionError("");}}catch(e:any){if(!disposed)setConnectionError(e.message);}finally{loading=false;}}
load(); const timer=setInterval(load,2500);
if(domain==="lab")loadSource().catch(e=>{if(!disposed)setError(e.message);});
return()=>{disposed=true;clearInterval(timer);};
},[domain,ready]);
useEffect(()=>{
if(!selected)return; let disposed=false, loading=false;setDetail(null);setCompareId("");setComparison(null);
async function load(){if(loading)return;loading=true;try{const data=await api(`/runs/${encodeURIComponent(selected)}?domain=${domain}`);if(!disposed)setDetail(data);}catch(e:any){if(!disposed)setError(e.message);}finally{loading=false;}}
load();const timer=setInterval(load,2500);return()=>{disposed=true;clearInterval(timer);};
},[selected,domain]);
useEffect(()=>{if(view==="health"&&admin)api("/health").then(setHealth).catch(e=>setError(e.message));},[view,admin]);
useEffect(()=>{let disposed=false;setComparison(null);if(compareId)api(`/runs/${compareId}?domain=${domain}`).then(data=>{if(!disposed)setComparison(data);}).catch(e=>{if(!disposed)setError(e.message);});return()=>{disposed=true;};},[compareId,domain]);
async function act(fn:()=>Promise<void>){setBusy(true);setError("");setNotice("");try{await fn();setOverview(await api(`/overview?domain=${domain}`));}catch(e:any){setError(e.message);}finally{setBusy(false);}}
function edit(key:string,value:any){setForm((f:any)=>({...f,[key]:value}));setDirty(true);}
function chooseView(next:string){setView(next);history.replaceState({},"",`?domain=${domain}&view=${next}`);}
async function submit(){await act(async()=>{const run=await api("/lab/runs",{source_revision:source.project.revision,model,requirement:model?requirement:""},crypto.randomUUID());setSelected(run.id);setView("monitor");await loadSource();setNotice("真实任务已入队。执行记录会自动刷新;历史快照保持不变。");});}
const runs=(overview?.runs||[]).filter((r:any)=>(status==="all"||r.status===status)&&(!search||`${r.project_id} ${r.id}`.toLowerCase().includes(search.toLowerCase())));
const run=detail?.run;
const oldChecks=new Map<string,any>((comparison?.run?.checks||[]).map((c:any)=>[c.code,c]));
const newChecks=new Map<string,any>((run?.checks||[]).map((c:any)=>[c.code,c]));
const differences=Array.from(new Set([...Array.from(oldChecks.keys()),...Array.from(newChecks.keys())])).map(code=>({code,before:oldChecks.get(code),after:newChecks.get(code)}));
return <div className="oc-shell"><AgentNav active="operations"/>
<main className="oc-main">
<header className="oc-hero"><div><p className="oc-eyebrow">XINGBANG / OPERATIONS</p><h1>项目运行中心</h1><p>看到真实执行,追溯每次变化。</p></div><div className="oc-live"><span/>每 2.5 秒更新 <small>最后成功读取 {overview?stamp(overview.time):"等待连接"}</small></div></header>
<div className="oc-environment"><div role="group" aria-label="数据环境"><button className={domain==="current"?"active":""} onClick={()=>setDomain("current")} disabled={busy}>项目工作区</button><button className={domain==="lab"?"active":""} onClick={()=>setDomain("lab")} disabled={busy}>隔离演示库</button></div><p>{domain==="lab"?"LAB-001 · 独立 SQLite · 所有数值及阈值均为演示数据":"仅展示当前身份获授权的项目;不会自动重跑或修改项目"}</p></div>
<nav className="oc-tabs" aria-label="运行中心功能">{[["monitor","运行与待办",Activity],["trace","检查依据与对比",ShieldCheck],["lab","演示与验收",FlaskConical],["health","系统自检",SlidersHorizontal]].map(([id,label,Icon]:any)=><button key={id} className={view===id?"active":""} onClick={()=>chooseView(id)}><Icon size={16}/>{label}</button>)}</nav>
{error&&<div className="oc-alert error" role="alert">{error}<button aria-label="关闭错误" onClick={()=>setError("")}><X size={15}/></button></div>}
{connectionError&&<div className="oc-alert error" role="alert">监控连接异常:{connectionError}。下方可能是上次读取的数据。</div>}
{notice&&<div className="oc-alert" role="status">{notice}</div>}
{(view==="monitor"||view==="trace")&&<>
<section className="oc-stats" aria-label="运行汇总">{[["running","执行中"],["queued","排队中"],["failed","执行失败"],["partial","部分完成"]].map(([key,label])=><button key={key} onClick={()=>setStatus(status===key?"all":key)} className={status===key?"chosen":""}><span>{label}</span><strong>{overview?.counts[key]??"—"}</strong><small>{key==="partial"?"缺依据或步骤未完整执行":"点击筛选任务"}</small></button>)}<div><span>项目待处理问题</span><strong>{overview?.todo_count??"—"}</strong><small>{overview?.stale_report_count??0} 份底稿待更新</small></div></section>
<div className="oc-workspace"><section className="oc-card"><div className="oc-card-head"><h2>任务记录</h2><small>最近 100 条 / 共 {overview?.total_runs??0} 条</small></div><div className="oc-filters"><input aria-label="搜索项目或任务" placeholder="搜索项目 / 任务 ID" value={search} onChange={e=>setSearch(e.target.value)}/><select aria-label="筛选运行状态" value={status} onChange={e=>setStatus(e.target.value)}><option value="all">全部状态</option>{Object.entries(names).slice(0,6).map(([k,n])=><option key={k} value={k}>{n}</option>)}</select></div>
<div className="oc-table-wrap"><table><thead><tr><th>项目 / 任务</th><th>执行状态</th><th>耗时 / 等待</th><th>业务结果</th></tr></thead><tbody>{runs.map((r:any)=><tr key={r.id} className={selected===r.id?"selected":""}><td><button className="oc-task" onClick={()=>setSelected(r.id)}><b>{r.project_id}</b><small>{stamp(r.created_at)}</small><code>{r.id.slice(0,16)}…</code></button></td><td><Badge value={r.status}/><small>{r.step}</small>{r.lease_expired&&<em>工作租约已过期,等待恢复</em>}{r.heartbeat_at&&<small>心跳 {stamp(r.heartbeat_at)}</small>}</td><td>{fmt(r.elapsed_seconds)} s<small>等待 {fmt(r.queue_seconds)} s</small></td><td>{r.summary?.open??"—"} 项待处理{r.superseded&&<small>数据已变更,此次结果未覆盖项目</small>}</td></tr>)}</tbody></table></div>
{!runs.length&&<div className="oc-empty"><Activity size={26}/><p>{overview?"此筛选下暂无运行":"正在连接后端…"}</p>{domain==="lab"&&<button onClick={()=>setView("lab")}>准备演示数据 <ArrowRight size={14}/></button>}</div>}
</section>
<aside className="oc-card"><div className="oc-card-head"><h2>待处理事项</h2><small>按项目权限过滤</small></div>{!(overview?.todos?.length||overview?.stale_reports?.length)&&<p className="oc-empty">暂无记录不代表全部检查通过,请核对运行范围。</p>}{overview?.todos?.slice(0,10).map((f:any)=><button className="oc-todo" key={f.id} onClick={()=>setSelected(f.last_seen_run_id)}><span>{f.project_id} · {f.status}{f.stale?" · 需重新检查":""}</span><b>{f.title}</b><small>{f.suggested_action}</small></button>)}{overview?.stale_reports?.map((r:any)=><div className="oc-todo" key={r.id}><b>{r.project_id} · 底稿待更新</b>{domain==="current"&&<a href={`/xingbang-workbench?project=${encodeURIComponent(r.project_id)}`}>进入项目处理</a>}</div>)}</aside></div>
{detail&&<section className="oc-card oc-detail"><div className="oc-card-head"><div><h2>执行依据 · {run.project_id}</h2><code>{run.id}</code></div><div className="oc-actions"><Badge value={run.status}/>{domain==="lab"&&admin&&["queued","running"].includes(run.status)&&<button disabled={busy} onClick={()=>act(async()=>{await api(`/lab/runs/${run.id}/cancel`,{});setNotice("已请求取消,已完成记录保留。");})}><Square size={14}/>取消任务</button>}</div></div>
<p className="oc-note">数据版本 {detail.data_revision} · SQL 源版本 {fmt(detail.source_revision)} · 快照 {run.input_snapshot_id}。执行完成 ≠ 无业务问题。</p>
<p className="oc-note">本次自然语言要求:{run.requirement||"无;按已冻结的方案执行"}</p>{run.error&&<p className="oc-alert error">{run.error}</p>}
<div className="oc-steps">{run.steps.map((s:any)=><article key={s.step_id}><div><b>{s.name}</b><Badge value={s.status}/></div><small>{s.skill.version} · {fmt(s.elapsed_seconds)} 秒</small>{s.errors?.map((e:any,i:number)=><p className="oc-error-text" key={i}>{e.message}</p>)}{s.missing_inputs?.length>0&&<p>缺输入:{s.missing_inputs.join("、")}</p>}</article>)}</div>
<h3>实际采用的 Skills 与规则</h3><div className="oc-table-wrap"><table><thead><tr><th>组件</th><th>本次冻结版本</th><th>当前发布版本</th><th>执行内容 / 哈希</th></tr></thead><tbody>{detail.components.map((c:any)=><tr key={c.id}><td>{c.name}<small>{c.id} · {c.engine||c.kind}</small></td><td>{c.version}</td><td>{c.current_version}{c.changed&&<em>版本已变化,历史任务不变</em>}</td><td>{c.conditions?.map((rule:any,i:number)=><p key={i}>{rule.field} {rule.operator} {rule.threshold}(触发问题)</p>)}<code>{c.content_hash?.slice(0,20)}…</code></td></tr>)}</tbody></table></div>
<h3>检查结果与数据来源</h3><div className="oc-checks">{(run.checks||[]).map((c:any,i:number)=><details key={c.code+String(i)}><summary><Badge value={c.result}/>{c.title}</summary><p>{c.suggested_action}</p><p>规则依据:{c.basis_ids?.join("、")}</p>{(run.evidence||[]).filter((e:any)=>c.evidence_ids?.includes(e.id)).map((e:any)=><p key={e.id}>{e.source} · {e.locator} · 值:{fmt(e.value)}<small>来源版本 {fmt(e.source_revision)} · {e.id}</small></p>)}</details>)}</div>
<h3>与同项目历史运行对比</h3><select aria-label="选择对比运行" value={compareId} onChange={e=>setCompareId(e.target.value)}><option value="">选择一个历史版本</option>{overview?.runs.filter((r:any)=>r.project_id===run.project_id&&r.id!==run.id).map((r:any)=><option key={r.id} value={r.id}>{stamp(r.created_at)} · {r.id.slice(-8)}</option>)}</select>
{comparison&&<><p className="oc-note">所选历史 → 当前选中任务。覆盖率:{fmt(comparison.run.summary?.coverage)} → {fmt(run.summary?.coverage)};未执行的检查不会被视作通过。</p><div className="oc-table-wrap"><table><thead><tr><th>检查项</th><th>所选历史</th><th>当前任务</th></tr></thead><tbody>{differences.map(d=><tr key={d.code}><td>{d.after?.title||d.before?.title}</td><td><Badge value={d.before?.result||"skipped"}/></td><td><Badge value={d.after?.result||"skipped"}/></td></tr>)}</tbody></table></div></>}
<details className="oc-events"><summary>执行事件 · {detail.events.length} 条</summary>{detail.events.map((e:any)=><p key={e.seq}>{stamp(e.time)} · {e.action} · {e.detail.name||""}</p>)}</details>
</section>}
{view==="trace"&&!detail&&<p className="oc-note">点击上方任务,查看当次冻结的配置版本、检查结果、SQL 行级来源及历史对比。</p>}
</>}
{view==="lab"&&(domain!=="lab"?<section className="oc-card oc-empty"><Database size={32}/><h2>演示操作只在隔离库执行</h2><p>不会修改项目工作区已有数据。</p><button className="primary" onClick={()=>setDomain("lab")}>切换到隔离演示库</button></section>:<>
<section className="oc-demo-intro"><div><p className="oc-eyebrow">MINIMAL DATA / REAL EXECUTION</p><h2>一个项目,演示完整的数据变化</h2><p>SQLite 原始记录 → 人工确认同步 → 冻结快照 → Skills / 规则 → 问题与差异</p></div><Database size={42}/></section>
<div className="oc-workspace"><section className="oc-card oc-source"><div className="oc-card-head"><h2>01 / 编辑演示源</h2><small>{source?.database} · 版本 {source?.project.revision??"—"}</small></div><p className="oc-note">1 条项目、1 条财务、3 条设备(其中 1 条为重复记录)。万元 / 人民币 / 2025 年 / 合并口径。</p>
{source&&<><div className="oc-form-grid">{[["amount","融资金额(万元)"],["liabilities","财报负债(万元)"],["dd_liabilities","尽调负债(万元)"],["cashflow","经营现金流(空白表示缺失)"]].map(([key,label])=><label key={key}>{label}<input aria-label={label} type="number" step="any" disabled={!admin||busy} value={form[key]??""} onChange={e=>edit(key,e.target.value)}/></label>)}</div><div className="oc-checkboxes"><label><input type="checkbox" disabled={!admin||busy} checked={!!form.duplicate} onChange={e=>edit("duplicate",e.target.checked)}/>设备重复记录参与读取</label><label><input type="checkbox" disabled={!admin||busy} checked={!!form.enabled} onChange={e=>edit("enabled",e.target.checked)}/>数据源启用(取消可演示读取失败)</label></div>
<div className="oc-table-wrap"><table><thead><tr><th>源表行 ID</th><th>设备编号</th><th>设备价值</th><th>参与读取</th></tr></thead><tbody>{source.assets.map((a:any)=><tr key={a.row_id}><td>{a.row_id}</td><td>{a.asset_key}</td><td>{a.value}</td><td>{a.active?"是":"否"}</td></tr>)}</tbody></table></div>
<div className="oc-actions"><button className="primary" disabled={!admin||busy||!dirty} onClick={()=>act(async()=>{await api("/lab/source",form);await loadSource();setNotice("已写入 SQLite 源表,尚未重新检查。点击“同步并检查”才采用这批数据。");})}>保存源数据</button><button disabled={busy} onClick={()=>act(loadSource)}><RefreshCw size={14}/>重新读取源表</button></div><p className="oc-note">源版本 {source.project.revision} / 已同步版本 {fmt(source.synced_revision)} {dirty?"· 有未保存修改":""}</p></>}
</section><aside className="oc-card oc-recipe"><h2>最小演示步骤</h2><ol><li><b>先检查</b><p>5000 万、负债不一致、缺现金流、设备重复:预期 4 项待处理。</p></li><li><b>补正两处</b><p>尽调负债改为 13200,取消设备重复,保存并检查:预期 2 项。</p></li><li><b>补齐并调方案</b><p>现金流填 900,融资金额改 4000:覆盖率 1.08 → 1.35,预期 0 项。</p></li><li><b>切换规则</b><p>阈值从 120% 改为 140%,重新检查:覆盖率问题重新出现。</p></li></ol><p className="oc-note">以上是固定演示条件,不是审批结论。不会一键重置当前数据。</p></aside></div>
<section className="oc-card oc-source"><div className="oc-card-head"><h2>02 / 配置与真实执行</h2><small>固定 4 个确定性 Skills,模型步骤可选</small></div><div className="oc-skill-pills">{["财务一致性","现金流完整性","设备去重","价值覆盖率"].map(s=><span key={s}><CheckCircle2 size={15}/>{s}</span>)}</div><div className="oc-form-grid"><label>演示规则版本<select aria-label="演示规则版本" disabled={!admin||busy||!source||dirty} value={source?.release.version_id||"lab.coverage@1.0.0"} onChange={e=>act(async()=>{await api("/lab/rule",{version:e.target.value.split("@")[1],revision:source.release.revision});await loadSource();setNotice("演示规则已切换,仅影响下一次运行。历史依据不变。");})}><option value="lab.coverage@1.0.0">v1.0.0 · 低于 120% 提示问题</option><option value="lab.coverage@2.0.0">v2.0.0 · 低于 140% 提示问题</option></select></label><label>本次要求(仅真实模型模式)<input aria-label="本次自然语言要求" disabled={!model||busy} placeholder="例如:重点核对现金流,列明未验证事项" value={requirement} onChange={e=>setRequirement(e.target.value)}/></label></div><label className="oc-model"><input type="checkbox" checked={model} disabled={busy} onChange={e=>setModel(e.target.checked)}/>附加原生 risk_evidence Skill 与真实模型核查(需内网模型配置,失败会如实显示)</label><div className="oc-actions"><button className="primary" disabled={busy||!admin||!source||dirty} onClick={submit}><Play size={16}/>{busy?"处理中…":"确认同步并检查"}</button><small>默认不调用模型、不生成模拟思考过程。</small></div></section>
<section className="oc-card oc-source"><div className="oc-card-head"><div><h2>03 / 隔离回归验收</h2><p className="oc-note">临时库中执行四组固定案例,比较真实检查码与覆盖率。不改本页数据。</p></div><button disabled={busy||!admin} onClick={()=>act(async()=>{const result=await api("/lab/evaluate",{});setNotice(result.passed?"四组隔离回归验收通过,结果已留存。":"验收发现差异,请查看结果。");})}><FlaskConical size={16}/>{busy?"处理中…":"运行四组验收"}</button></div>{overview?.evaluations?.map((e:any)=><details className="oc-evaluation" key={e.id} open={e===overview.evaluations[0]}><summary><Badge value={e.passed?"pass":"failed"}/>{stamp(e.created_at)} · {e.duration_ms} ms</summary><p className="oc-note">{e.scope}</p><div className="oc-table-wrap"><table><thead><tr><th>案例</th><th>预期 / 实际问题项</th><th>预期 / 实际覆盖率</th><th>结果</th></tr></thead><tbody>{e.cases.map((c:any)=><tr key={c.name}><td>{c.name}<small>规则 {c.rule_version}</small></td><td>{c.expected_codes.length} / {c.actual_codes.length}<details><summary>检查码对照</summary><p>预期:{c.expected_codes.join("、")||"无"}</p><p>实际:{c.actual_codes.join("、")||"无"}</p></details></td><td>{c.expected_coverage} / {fmt(c.actual_coverage)}</td><td><Badge value={c.passed?"pass":"failed"}/></td></tr>)}</tbody></table></div></details>)}{!overview?.evaluations?.length&&<p className="oc-note">尚无验收记录,不能据此声明通过。</p>}</section>
</>)}
{view==="health"&&<section className="oc-card oc-source"><div className="oc-card-head"><h2>系统自检</h2>{admin&&<button disabled={busy} onClick={()=>act(async()=>setHealth(await api("/health")))}><RefreshCw size={15}/>重新检查</button>}</div>{!admin?<p className="oc-note">仅管理员可查看系统诊断,项目资料权限另行校验。</p>:<><p className="oc-note">本次检测 {stamp(health?.time)};结果是检查时点的状态,不是持续可用性承诺。</p>{health?.checks.map((c:any)=><article className="oc-health" key={c.name}><div><h3>{c.name}</h3><p>{c.detail}</p></div><Badge value={c.status}/></article>)}</>}</section>}
<footer className="oc-footer">兴邦金租项目助手 · 本地演示 / 运行追溯 · 原四 Agent 能力保留在兼容入口,未直接删除</footer>
</main>
</div>;
}
+79 -8
View File
@@ -1,12 +1,83 @@
"use client";
import {useEffect,useState} from "react";
import {ArrowRight,RefreshCw,ShieldCheck} from "lucide-react";
import AgentNav,{projectLink} from "@/components/xingbang/AgentNav";
import {useEffect, useRef, useState} from "react";
import {ArrowRight, FileText, RefreshCw, ShieldCheck} from "lucide-react";
import AgentNav from "@/components/xingbang/AgentNav";
import AssistantConfiguration from "@/components/xingbang/AssistantConfiguration";
import AssistantHistory from "@/components/xingbang/AssistantHistory";
import "../data-access/workspace.css";
export default function XingbangWorkspace(){
const [data,setData]=useState<any[]>([]),[search,setSearch]=useState(""),[error,setError]=useState(""),[loading,setLoading]=useState(true);
async function load(){setLoading(true);setError("");try{const token=sessionStorage.getItem("xingbang-access-token");const r=await fetch("/api/risk-agent/workspace/projects",{headers:token?{Authorization:`Bearer ${token}`}:{}});const d=await r.json();if(!r.ok)throw Error(d.detail);setData(d.projects);}catch(e:any){setError(e.message);}finally{setLoading(false);}}
useEffect(()=>{load();},[]);
return <div className="ra-app"><AgentNav active="overview"/><main className="xb-overview"><div className="xb-overview-heading"><div><h1>兴邦金租 · 项目工作台</h1><p>项目总览</p></div><a className="ra-button primary" href="/data-access?view=run"><ShieldCheck size={16}/>发起风险研判</a></div>{error&&<div role="alert" className="ra-alert error">{error}<a href="/data-access">身份设置</a></div>}<div className="xb-overview-stat"><span><strong>{data.length}</strong>项目</span><span><strong>{data.filter(p=>p.coordination&&p.coordination.stage!=="已完成").length}</strong>评审在办</span><span><strong>{data.filter(p=>p.collateral?.stale).length}</strong>核验待更新</span><span><strong>{data.filter(p=>p.report?.status==="final").length}</strong>报告已定稿</span></div><div className="xb-overview-tools"><input aria-label="搜索项目" placeholder="项目编号 / 客户名称" value={search} onChange={e=>setSearch(e.target.value)}/><button className="ra-button" title="刷新项目" disabled={loading} onClick={load}><RefreshCw size={15}/></button></div><div className="xb-table-wrap"><table className="xb-project-table"><thead><tr><th>项目 / 客户</th><th>01 风险研判</th><th>02 报告与经验</th><th>03 评审协同</th><th>04 租赁物与增信</th></tr></thead><tbody>{data.filter(p=>(p.project_id+" "+p.risk?.customer).toLowerCase().includes(search.toLowerCase())).map(p=><tr key={p.project_id}><td><b>{p.project_id}</b><small>{p.risk?.customer||"客户资料待接入"}</small></td><td><a href={projectLink("/data-access",p.project_id)}>{p.risk?`${p.risk.findings} 项关注`:"开始研判"}<ArrowRight size={12}/></a><small>{p.risk?.time?new Date(p.risk.time).toLocaleString():"暂无快照"}</small></td><td><a href={projectLink("/report-workbench",p.project_id)}>{p.report?(p.report.status==="final"?"已定稿":"待复核草稿"):"建立报告"}<ArrowRight size={12}/></a><small>{p.report?`版本 ${p.report.version}`:"尚未建立"}</small></td><td><a href={projectLink("/review-workbench",p.project_id)}>{p.coordination?.stage||"接入评审"}<ArrowRight size={12}/></a><small>{p.coordination?`${p.coordination.owner} · ${p.coordination.open_tasks} 项补件`:"尚未分配"}</small></td><td><a href={projectLink("/collateral-workbench",p.project_id)}>{!p.collateral?"开始核验":p.collateral.stale?"需重新核验":`${p.collateral.open_checks} 项待落实`}<ArrowRight size={12}/></a><small>{p.collateral?.has_result?"已保存核验快照":"尚无核验结果"}</small></td></tr>)}</tbody></table></div>{!data.length&&<div className="ra-empty">{loading?"正在读取项目…":"暂无项目研判记录"}</div>}</main></div>;
const tabs = [["overview", "概览"], ["findings", "材料与问题"], ["scenario", "方案比较"], ["report", "报告底稿"], ["experience", "反馈与经验"]];
const statuses: Record<string, string> = {queued: "已排队", running: "检查中", completed: "已完成", partial: "部分完成", failed: "未完成", cancelled: "已取消", skipped: "未执行", draft: "草稿", published: "已发布", rejected: "已拒绝", rolled_back: "已停用", stale: "待更新", finalized: "已定稿"};
const fields = [["operating_cashflow", "经营现金流(万元)"], ["due_diligence_liabilities", "尽调负债(万元)"], ["financial_liabilities", "财报负债(万元)"], ["duplicate_asset_count", "重复设备数量"], ["collection_evidence", "回款依据"], ["collection_dependency", "是否依赖特定回款"]];
const categories = [["rule_omission", "检查遗漏"], ["false_positive", "误报"], ["judgment", "专业判断"], ["process", "流程建议"], ["expression", "表达修改"], ["factual", "事实纠正"], ["data", "数据纠正"]];
export default function XingbangWorkspace() {
const [items, setItems] = useState<any[]>([]), [project, setProject] = useState<any>(null), [identity, setIdentity] = useState<any>(null);
const [tab, setTab] = useState("overview"), [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false), [search, setSearch] = useState("");
const [requirement, setRequirement] = useState(""), [plan, setPlan] = useState("corporate.default"), [runId, setRunId] = useState(""), [document, setDocument] = useState<any>(null);
const [field, setField] = useState(fields[0][0]), [value, setValue] = useState(""), [reason, setReason] = useState(""), [amount, setAmount] = useState("4000");
const [template, setTemplate] = useState("template.due-diligence"), [reportId, setReportId] = useState(""), [sectionEdits, setSectionEdits] = useState<Record<string, string>>({});
const [category, setCategory] = useState("rule_omission"), [feedbackNote, setFeedbackNote] = useState(""), [feedbackId, setFeedbackId] = useState("");
const [experience, setExperience] = useState({title: "", trigger: "", action: "", exceptions: "", scope: "project", team: "", industry: "", required_field: "collection.evidence", exception_field: "collection.evidence", expires_at: "2027-09-11", condition_field: "collection.dependency", condition_value: "true"});
const selected = useRef("");
const headers = (json = true) => {const token = sessionStorage.getItem("xingbang-access-token"); return {...(json ? {"Content-Type": "application/json"} : {}), ...(token ? {Authorization: `Bearer ${token}`} : {})};};
async function request(path: string, body?: any) {
const response = await fetch(`/api/risk-agent/project-assistant${path}`, {method: body === undefined ? "GET" : "POST", headers: {...headers(), ...(/\/(runs|reports)$/.test(path) && body ? {"Idempotency-Key": crypto.randomUUID()} : {})}, body: body === undefined ? undefined : JSON.stringify(body)});
const data = await response.json();
if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail || "操作失败"));
return data;
}
async function open(id: string) {
selected.current = id;
const data = await request(`/projects/${encodeURIComponent(id)}`);
if (selected.current !== id) return;
setProject(data); history.replaceState({}, "", `?project=${encodeURIComponent(id)}`);
}
async function load() {try {const data = await request("/projects"); setItems(data.projects); setIdentity(data.identity); const id = new URLSearchParams(location.search).get("project"); if (id) await open(id);} catch (e: any) {setError(e.message);}}
async function act(path: string, body: any, message = "操作已记录") {
setBusy(true); setError(""); setNotice("");
try {const result = await request(path, body); if (selected.current) await open(selected.current); setNotice(message); return result;}
catch (e: any) {setError(e.message); return null;} finally {setBusy(false);}
}
async function download(path: string, name: string) {
try {const r = await fetch(`/api/risk-agent/project-assistant${path}`, {headers: headers(false)}); if (!r.ok) throw Error((await r.json()).detail || "下载失败"); const url = URL.createObjectURL(await r.blob()); const a = window.document.createElement("a"); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10000);} catch (e: any) {setError(e.message);}
}
useEffect(() => {load();}, []);
const running = project?.runs?.some((r: any) => ["queued", "running"].includes(r.status));
useEffect(() => {if (!running || !project?.id) return; const id = project.id; const timer = setInterval(() => {if (selected.current === id) open(id).catch((e: any) => setError(e.message));}, 1500); return () => clearInterval(timer);}, [project?.id, running]);
const canConfigure = identity?.preview || ["admin", "reviewer"].includes(identity?.role);
const canReview = identity?.preview || identity?.role === "reviewer";
function back() {selected.current = ""; setProject(null); setDocument(null); setRunId(""); setReportId(""); setFeedbackId(""); setSectionEdits({}); history.replaceState({}, "", "/xingbang-workbench"); load();}
const alerts = <>{error && <div role="alert" className="ra-alert error">{error}</div>}{notice && <div role="status" className="ra-alert">{notice}</div>}</>;
if (!project) return <div className="ra-app"><AgentNav active="overview"/><main className="xb-overview"><div className="xb-overview-heading"><div><h1>兴邦金租 · 对公项目助手</h1><p>项目检查、补件复核、融资比较、底稿与经验复用</p></div><button className="ra-button" onClick={load}><RefreshCw size={15}/>刷新</button></div>{alerts}<div className="xb-actions"><input aria-label="搜索项目" placeholder="项目编号 / 客户名称" value={search} onChange={e => setSearch(e.target.value)}/>{canConfigure && <button className="ra-button" onClick={() => setTab(tab === "configuration" ? "overview" : "configuration")}>组件配置与发布</button>}</div>{tab === "configuration" && canConfigure ? <AssistantConfiguration request={request} canEdit={identity?.preview || identity?.role === "admin"}/> : <div className="xb-project-grid">{items.filter(x => (x.id + x.customer).toLowerCase().includes(search.toLowerCase())).map(x => <button className="xb-project-card" key={x.id} onClick={() => {setTab("overview"); open(x.id).catch(e => setError(e.message));}}><span>{x.data_domain === "demo" ? "演示项目" : "正式项目"}</span><h2>{x.customer}</h2><p>{x.id} · {x.industry} · {x.stage}</p><footer><b>{x.open_findings} 项待处理</b><small>{statuses[x.latest_run?.status] || "尚未检查"}</small><ArrowRight size={16}/></footer></button>)}</div>}</main></div>;
const base = `/projects/${encodeURIComponent(project.id)}`;
const chosenRun = project.runs.find((r: any) => r.id === runId) || project.runs[0];
const chosenReport = project.reports.find((r: any) => r.id === reportId) || project.reports[0];
const finished = project.runs.filter((r: any) => ["completed", "partial"].includes(r.status));
const currentRun = finished[0];
const reportSourceRun = finished.find((r: any) => r.id === runId) || currentRun;
const openFindings = project.findings.filter((f: any) => !["已解决", "不适用"].includes(f.status));
const evidence = project.runs.flatMap((r: any) => r.evidence || []);
async function upload(file: File) {
setBusy(true); setError("");
try {const form = new FormData(); form.append("file", file); const response = await fetch(`/api/risk-agent/project-assistant${base}/documents`, {method: "POST", headers: headers(false), body: form}); const data = await response.json(); if (!response.ok) throw Error(data.detail || "上传失败"); setDocument(data); await open(project.id); setNotice("材料已解析;请核对候选字段后确认,尚未自动修改项目事实。");} catch (e: any) {setError(e.message);} finally {setBusy(false);}
}
async function reportAction(action: string, payload: any = {}) {if (["regenerate", "finalize"].includes(action) && Object.keys(sectionEdits).length) {setError("请先保存尚未提交的章节修改"); return;} const saved = await act(`${base}/reports/${chosenReport.id}/actions`, {action, revision: chosenReport.revision, payload}); if (saved && payload.section_id) setSectionEdits(previous => {const next = {...previous}; delete next[payload.section_id]; return next;});}
return <div className="ra-app"><AgentNav active="overview" project={project.id}/><main className="xb-overview">
<button className="xb-back" onClick={back}>← 返回项目列表</button><div className="xb-overview-heading"><div><h1>{project.customer}</h1><p>{project.id} · {project.industry} · {project.stage} · {project.data_domain === "demo" ? "演示数据" : "正式数据"}</p></div><button className="ra-button" onClick={() => open(project.id)}><RefreshCw size={15}/>刷新</button></div>{alerts}
<section className="xb-task"><label className="ra-field"><span>本次检查要求(可选,不改变正式规则)</span><textarea value={requirement} onChange={e => setRequirement(e.target.value)} maxLength={2000} placeholder="例如:重点核对现金流与回款依据,说明哪些材料还需要补充"/></label><div className="xb-actions"><select aria-label="业务检查方案" value={plan} onChange={e => setPlan(e.target.value)}>{project.plans.map((p: any) => <option value={p.id} key={p.id}>{p.name} · {p.version}</option>)}</select><button className="ra-button primary" disabled={busy || running} onClick={() => act(`${base}/runs`, {requirement, plan_id: plan, revision: project.revision}, "检查已进入队列;刷新或离开页面不会丢失任务。 ")}><ShieldCheck size={16}/>{running ? "检查进行中" : "检查项目"}</button></div><small>必查项始终执行;语义核查需要已配置的内网模型,失败会明确标出。</small></section>
<nav className="xb-tabs">{tabs.map(t => <button key={t[0]} className={tab === t[0] ? "active" : ""} onClick={() => setTab(t[0])}>{t[1]}</button>)}{canConfigure && <button className={tab === "configuration" ? "active" : ""} onClick={() => setTab("configuration")}>配置治理</button>}</nav>
{tab === "overview" && <><section className="xb-cards"><article><b>当前数据版本</b><strong>v{project.data_revision}</strong><small>{project.documents.length} 份材料</small></article><article><b>未处理问题</b><strong>{openFindings.length}</strong><small>人工状态与机器结论分别留痕</small></article><article><b>最近运行</b><strong>{statuses[project.runs[0]?.status] || "未检查"}</strong><small>{project.runs[0]?.created_at ? new Date(project.runs[0].created_at).toLocaleString() : "点击检查项目"}</small></article></section>
<section className="xb-panel xb-wide"><h2>检查过程与历史</h2>{project.runs.length ? <><select aria-label="选择检查历史" value={chosenRun?.id || ""} onChange={e => setRunId(e.target.value)}>{project.runs.map((r: any) => <option key={r.id} value={r.id}>{new Date(r.created_at).toLocaleString()} · {statuses[r.status]} · {r.id.slice(-8)}</option>)}</select><p>本次要求:{chosenRun.requirement || "无额外要求"}</p><small>快照:{chosenRun.snapshot_id || "尚未完成取数"}</small>{["queued", "running"].includes(chosenRun.status) && <button className="ra-button" onClick={() => act(`${base}/runs/${chosenRun.id}/cancel`, {}, "已取消;迟到结果不会发布。")}>取消本次运行</button>}{chosenRun.error && <p role="alert">{chosenRun.error}</p>}{chosenRun.steps.map((s: any) => <details className="xb-step" key={s.step_id}><summary><span className="xb-status">{statuses[s.status]}</span> {s.name} · {s.skill.version}</summary><p>{s.errors?.map((e: any) => e.message).join(";")}</p>{s.outputs?.requirement_response && <p>{s.outputs.requirement_response}</p>}<pre>{JSON.stringify({outputs: s.outputs, checks: s.findings, input_hash: s.input_hash, trace: s.trace}, null, 2)}</pre></details>)}{chosenRun.data_errors?.map((e: any, i: number) => <p key={i} className="xb-warning">{e.card_id}:{e.message}</p>)}<details><summary>冻结组件版本与变更摘要</summary><pre>{JSON.stringify({versions: chosenRun.component_versions, changes: chosenRun.change_summary, superseded: chosenRun.superseded}, null, 2)}</pre></details></> : <p>尚无运行记录。</p>}</section>
<details className="xb-panel xb-wide"><summary>项目事件与兼容历史</summary><AssistantHistory key={project.id} projectId={project.id} records={project.legacy_records} request={request}/>{project.events.map((e: any) => <p key={e.seq}>{new Date(e.time).toLocaleString()} · {e.action} · {e.actor}</p>)}{project.migration_note && <p>{project.migration_note};已保留 {project.legacy_records.length} 条旧记录。</p>}<p><a href={`/review-workbench?project=${encodeURIComponent(project.id)}`}>原评审工作台</a> · <a href={`/report-workbench?project=${encodeURIComponent(project.id)}`}>原报告工作台</a> · <a href={`/collateral-workbench?project=${encodeURIComponent(project.id)}`}>租赁物核验</a></p></details></>}
{tab === "findings" && <><section className="xb-panel xb-wide"><h2>材料档案</h2><p>上传后先查看解析结果,确认候选值才更新项目。旧版本与来源保留;扫描件无法识别时会提示补充。</p><label className="ra-field"><span>上传 TXT / CSV / DOCX / XLSX / PDF / 图片(最多 10 MB)</span><input aria-label="上传项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" disabled={busy} onChange={e => {if (e.target.files?.[0]) upload(e.target.files[0]); e.target.value = "";}}/></label>{project.documents.map((d: any) => <div className="xb-finding" key={d.id}><div><b>{d.name}</b><p>版本 {d.version} · {d.status} · {d.size} 字节</p></div><button className="ra-button" onClick={async () => {try {setDocument(await request(`${base}/documents/${d.id}`));} catch (e: any) {setError(e.message);}}}>查看解析</button><button className="ra-button" onClick={() => download(`${base}/documents/${d.id}/download`, d.name)}>下载原件</button></div>)}
{document && <div className="xb-document"><h3>{document.name}</h3>{document.warnings.map((w: string, i: number) => <p className="xb-warning" key={i}>{w}</p>)}<label className="ra-field"><span>来源确认或补件说明</span><input value={reason} onChange={e => setReason(e.target.value)} placeholder="说明已核对的期间、口径和依据"/></label>{document.candidates.map((c: any) => <div className="xb-finding" key={c.id}><div><b>{c.field} = {String(c.value ?? "缺失")}</b><p>{c.locator} · {c.period || "期间未明"} · {c.unit || "单位未明"} · {c.scope || "口径未明"}</p></div><button className="ra-button" disabled={busy || !reason.trim()} onClick={() => act(`${base}/supplements`, {candidate_ids: [c.id], revision: project.revision, reason}, "候选值已确认;请重新检查项目。")}>确认该来源</button></div>)}<details><summary>解析原文与定位</summary>{document.chunks.map((c: any, i: number) => <p key={i}><b>{c.locator}</b> {c.text}{c.formula && <small> 公式:{c.formula};缓存:{c.cached_value || "无"}</small>}</p>)}</details></div>}</section>
<section className="xb-panel xb-wide"><h2>问题与补件</h2>{!project.findings.length && <p>尚无问题记录,请先执行检查。</p>}{project.findings.map((f: any) => <article className="xb-finding" key={f.id}><span className={`xb-status ${f.status === "已解决" ? "ok" : ""}`}>{f.status}</span><div className="xb-grow"><b>{f.title}</b><p>{f.suggested_action}</p><small>机器:{f.result} · {f.stale ? "来源待复核" : "已核对本次快照"} · {f.code}</small><details><summary>查看证据、依据与处理历史</summary>{f.evidence_ids.map((id: string) => {const e = evidence.find((r: any) => r.id === id); return <p key={id}>{e ? `${e.source} · ${e.locator} · ${e.field} = ${String(e.value ?? "缺失")}` : id}</p>;})}<p>依据:{f.basis_ids.join("、")}</p>{f.history?.map((h: any, i: number) => <p key={i}>{h.time} · {h.from} → {h.to} · {h.note || h.result}</p>)}</details><div className="xb-actions">{["待补件", "待复核", "不适用"].map(status => <button key={status} className="ra-button" disabled={busy || !reason.trim()} onClick={() => act(`${base}/findings/${f.id}/actions`, {action: status, revision: f.revision, note: reason})}>{status}</button>)}</div></div></article>)}<div className="xb-form-grid"><label className="ra-field"><span>人工补充字段</span><select value={field} onChange={e => setField(e.target.value)}>{fields.map(f => <option key={f[0]} value={f[0]}>{f[1]}</option>)}</select></label><label className="ra-field"><span>确认值(留空表示资料仍缺失)</span><input value={value} onChange={e => setValue(e.target.value)} placeholder={field === "collection_dependency" ? "true 或 false" : "输入实际核对值"}/></label><label className="ra-field"><span>补件 / 问题处理说明</span><input value={reason} onChange={e => setReason(e.target.value)} placeholder="必须填写确认依据"/></label></div><button className="ra-button primary" disabled={busy || !reason.trim()} onClick={() => act(`${base}/supplements`, {changes: {[field]: value === "" ? null : field === "collection_dependency" ? value === "true" ? true : value === "false" ? false : value : value}, finding_ids: [], revision: project.revision, reason}, "补件已保存,原来源已保留;请重新检查确认问题变化。")}>保存补件</button></section></>}
{tab === "scenario" && <section className="xb-panel xb-wide"><h2>融资金额情景比较</h2><p>当前金额 {project.facts.facility_amount} 万元,租赁物价值 {project.facts.lease_asset_value} 万元。比较不会修改基线;现金流等其他问题继续保留。120% 仅是演示阈值。</p><div className="xb-actions"><input aria-label="情景融资金额" value={amount} onChange={e => setAmount(e.target.value)}/><span>万元</span><button className="ra-button primary" disabled={busy} onClick={() => act(`${base}/scenarios`, {facility_amount: amount}, "情景已计算,项目基线未改变。")}>计算比较</button></div>{project.scenarios.map((s: any) => <article key={s.id} className="xb-scenario"><div className="xb-compare"><strong>{(Number(s.baseline.coverage) * 100).toFixed(2)}%</strong><ArrowRight/><strong>{(Number(s.scenario.coverage) * 100).toFixed(2)}%</strong><small>融资额 {s.baseline.facility_amount} → {s.scenario.facility_amount} 万元;另有 {s.unresolved.length} 项问题未消除</small></div><p>{s.assumptions.join(";")}</p><label className="ra-field"><span>采纳理由(可选,采纳将改变项目融资金额)</span><input value={reason} onChange={e => setReason(e.target.value)}/></label><button className="ra-button" disabled={busy || !reason.trim() || s.project_revision !== project.revision} onClick={() => act(`${base}/scenarios/${s.id}/adopt`, {action: "adopt", revision: project.revision, note: reason}, "情景已采纳;原版本保留,请重新检查。")}>{s.project_revision === project.revision ? "确认采纳该情景" : "基线已变化,请重新比较"}</button></article>)}</section>}
{tab === "report" && <section className="xb-panel xb-wide"><h2>报告底稿</h2><div className="xb-actions"><select aria-label="报告模板" value={template} onChange={e => setTemplate(e.target.value)}><option value="template.due-diligence">尽调草稿</option><option value="template.review">评审底稿</option><option value="template.committee">上会摘要</option></select><select aria-label="报告来源运行" value={reportSourceRun?.id || ""} onChange={e => setRunId(e.target.value)}>{finished.map((r: any) => <option key={r.id} value={r.id}>{new Date(r.created_at).toLocaleString()} · {statuses[r.status]}</option>)}</select><button className="ra-button primary" disabled={busy || !currentRun} onClick={async () => {const r = await act(`${base}/reports`, {run_id: reportSourceRun.id, template_id: template}); if (r) setReportId(r.id);}}><FileText size={15}/>生成底稿</button></div>{chosenReport && <div className="xb-report"><select aria-label="选择底稿版本" value={chosenReport.id} onChange={e => {setReportId(e.target.value); setSectionEdits({});}}>{project.reports.map((r: any) => <option key={r.id} value={r.id}>{r.template.name} · {statuses[r.status]} · {r.id.slice(-8)}</option>)}</select><p>{chosenReport.disclaimer} · {statuses[chosenReport.status]}</p><small>来源快照:{chosenReport.snapshot_id}</small><div className="xb-actions"><button className="ra-button" onClick={() => download(`${base}/reports/${chosenReport.id}/export`, `${project.id}-${chosenReport.template.name}.docx`)}>导出 Word</button><button className="ra-button" disabled={busy || !currentRun || chosenReport.status === "finalized"} onClick={() => reportAction("regenerate", {run_id: currentRun.id})}>按最新运行更新候选</button>{canReview && <button className="ra-button" disabled={busy || chosenReport.status === "finalized"} onClick={() => reportAction("finalize")}>确认定稿</button>}</div>{chosenReport.sections.map((s: any) => <article key={s.id}><h3>{s.title} {s.stale ? "· 来源已过期" : ""} {s.locked ? "· 已锁定" : ""} {s.confirmed ? "· 已确认" : ""}</h3><textarea aria-label={`编辑${s.title}`} className="xb-section-text" disabled={s.locked || ["overview", "findings"].includes(s.id) || chosenReport.status === "finalized"} value={sectionEdits[s.id] ?? s.content} onChange={e => setSectionEdits({...sectionEdits, [s.id]: e.target.value})}/>{chosenReport.status !== "finalized" && <div className="xb-actions"><button className="ra-button" disabled={busy || s.locked || ["overview", "findings"].includes(s.id)} onClick={() => reportAction("section", {section_id: s.id, content: sectionEdits[s.id] ?? s.content, confirmed: false})}>保存修改</button><button className="ra-button" disabled={busy || s.stale} onClick={() => reportAction("section", {section_id: s.id, content: sectionEdits[s.id] ?? s.content, confirmed: true, locked: true})}>确认并锁定</button>{s.locked && <button className="ra-button" disabled={busy} onClick={() => reportAction("section", {section_id: s.id, locked: false, confirmed: false})}>解除锁定</button>}</div>}{s.candidate && <details open><summary>更新候选(未覆盖当前正文)</summary><pre>{s.candidate.content}</pre><button className="ra-button" disabled={busy} onClick={() => reportAction("section", {section_id: s.id, accept_candidate: true})}>采纳候选内容</button></details>}</article>)}</div>}</section>}
{tab === "experience" && <section className="xb-panel xb-wide"><h2>专业反馈与经验复用</h2><p>先记录具体纠正,再形成经验草稿。经验需明确适用和例外条件,评测后审核发布;不会自动变成正式制度。</p><div className="xb-form-grid"><label className="ra-field"><span>反馈类别</span><select value={category} onChange={e => setCategory(e.target.value)}>{categories.map(c => <option key={c[0]} value={c[0]}>{c[1]}</option>)}</select></label><label className="ra-field"><span>反馈内容(关联最近一次已结束运行)</span><textarea value={feedbackNote} onChange={e => setFeedbackNote(e.target.value)}/></label></div><button className="ra-button" disabled={busy || !currentRun || !feedbackNote.trim()} onClick={async () => {const f = await act(`${base}/feedback`, {category, note: feedbackNote, target_kind: "run", target_id: currentRun.id}); if (f) setFeedbackId(f.id);}}>记录反馈</button>
<div className="xb-actions"><button className="ra-button" disabled={busy || running || !feedbackId} onClick={() => act(`${base}/runs`, {feedback_id: feedbackId, requirement: "根据选定专业反馈提炼经验草稿,不改变正式制度", plan_id: plan, revision: project.revision}, "经验提炼任务已排队;结果仅为本项目草稿,未配置模型时会明确失败。")}>从选定反馈提炼经验草稿</button></div><h3>创建经验草稿</h3><label className="ra-field"><span>来源反馈</span><select value={feedbackId} onChange={e => setFeedbackId(e.target.value)}><option value="">请选择反馈</option>{project.feedback.map((f: any) => <option key={f.id} value={f.id}>{f.note}</option>)}</select></label><div className="xb-form-grid">{[["title", "经验标题"], ["trigger", "适用条件说明"], ["action", "建议核查动作"], ["exceptions", "不适用 / 例外说明"]].map(([key, label]) => <label className="ra-field" key={key}><span>{label}</span><input value={(experience as any)[key]} onChange={e => setExperience({...experience, [key]: e.target.value})}/></label>)}<label className="ra-field"><span>触发条件</span><select value={experience.condition_field} onChange={e => setExperience({...experience, condition_field: e.target.value})}><option value="collection.dependency">依赖特定回款</option></select><select aria-label="触发条件值" value={experience.condition_value} onChange={e => setExperience({...experience, condition_value: e.target.value})}><option value="true">是</option><option value="false">否</option></select></label><label className="ra-field"><span>执行动作与例外</span><p>检查回款依据是否缺失;已有到账依据时不再触发。</p></label><label className="ra-field"><span>范围</span><select value={experience.scope} onChange={e => setExperience({...experience, scope: e.target.value})}><option value="project">仅本项目</option><option value="team">指定团队</option></select></label>{experience.scope === "team" && <label className="ra-field"><span>团队</span><input value={experience.team} placeholder="本机演示可填 demo" onChange={e => setExperience({...experience, team: e.target.value})}/></label>}<label className="ra-field"><span>有效期</span><input type="date" value={experience.expires_at} onChange={e => setExperience({...experience, expires_at: e.target.value})}/></label></div><button className="ra-button primary" disabled={busy || !feedbackId || !experience.title || !experience.trigger || !experience.action || !experience.exceptions} onClick={() => {const {condition_field, condition_value, ...body} = experience; act(`${base}/experiences`, {...body, feedback_id: feedbackId, conditions: {[condition_field]: condition_value === "true"}, expires_at: experience.expires_at + "T23:59:59+08:00"}, "经验草稿已保存,尚未生效。");}}>保存经验草稿</button>
{project.experiences.map((x: any) => <article className="xb-panel" key={x.id}><span className="xb-status">{statuses[x.status]}</span><h3>{x.title}</h3><p>适用:{x.trigger};例外:{x.exceptions}</p><p>{x.action} · {x.scope === "team" ? `团队 ${x.team}` : "仅本项目"} · 有效至 {x.expires_at}</p><div className="xb-actions">{x.status === "draft" && <button className="ra-button" disabled={busy} onClick={() => act(`${base}/experiences/${x.id}/actions`, {action: "evaluate", revision: x.revision, note: "隔离留出集评测"})}>评测适用与反例</button>}{canReview && x.status === "draft" && <button className="ra-button" disabled={busy || !x.evaluation?.passed} onClick={() => act(`${base}/experiences/${x.id}/actions`, {action: "publish", revision: x.revision, note: "确认脱敏条件与检查动作后发布"})}>审核发布</button>}{canReview && x.status === "published" && <button className="ra-button" disabled={busy} onClick={() => act(`${base}/experiences/${x.id}/actions`, {action: "rollback", revision: x.revision, note: "停用经验,后续运行不再应用"})}>停用回滚</button>}</div>{x.evaluation && <details><summary>评测结果:{x.evaluation.passed ? "通过" : "未通过"}</summary><pre>{JSON.stringify(x.evaluation, null, 2)}</pre></details>}</article>)}<h3>本项目实际应用记录</h3>{project.experience_applications.length ? project.experience_applications.map((a: any) => <p key={a.id}>{a.experience_version_id} · {a.status} · {a.check_code} · {a.reason}</p>) : <p>尚未应用已发布经验。</p>}</section>}
{tab === "configuration" && canConfigure && <AssistantConfiguration request={request} canEdit={identity?.preview || identity?.role === "admin"}/>}
</main></div>;
}
+1
View File
@@ -39,6 +39,7 @@ export default function ChatInput() {
className="flex-1 resize-none bg-transparent text-[14px] outline-none placeholder:text-gray-400 max-h-40 py-1 leading-relaxed"
/>
<button
aria-label={disabled ? "正在等待回答" : "发送消息"}
onClick={handleSubmit}
disabled={!text.trim() || disabled}
className="shrink-0 w-8 h-8 flex items-center justify-center rounded-xl bg-[#002fa7] text-white disabled:opacity-25 hover:bg-[#001f7a] transition-all active:scale-95"
+1 -1
View File
@@ -117,7 +117,7 @@ function AuthErrorAlert({ content }: { content: string }) {
</p>
<div className="flex items-center gap-3 pt-1">
<a
href="http://localhost:8002/"
href="/operations-center?domain=current&view=health"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] font-medium text-red-600 hover:text-red-800 transition-colors"
+10 -5
View File
@@ -1,8 +1,9 @@
"use client";
import {useEffect, useState} from "react";
import {Blocks, FileText, LayoutDashboard, ShieldCheck, Users} from "lucide-react";
import {Activity, Blocks, FileText, FlaskConical, LayoutDashboard, ShieldCheck, Users, Settings2} from "lucide-react";
import "./navigation.css";
import "./legacy-navigation.css";
export const agents = [
{id:"risk",name:"风险研判",href:"/data-access",icon:ShieldCheck},
@@ -10,21 +11,25 @@ export const agents = [
{id:"coordination",name:"评审协同",href:"/review-workbench",icon:Users},
{id:"collateral",name:"租赁物与增信核验",href:"/collateral-workbench",icon:Blocks},
];
export function projectLink(href:string, project?:string){return href+(project?`?project=${encodeURIComponent(project)}`:"");}
export function projectLink(href:string, project?:string){return href+(project?`${href.includes("?")?"&":"?"}project=${encodeURIComponent(project)}`:"");}
export function useRequestedProject(){
const [project,setProject]=useState("");
useEffect(()=>setProject(new URLSearchParams(window.location.search).get("project")||""),[]);
return project;
}
export default function AgentNav({active,project}:{active:string;project?:string}){
return <nav className="xb-agent-nav" aria-label="兴邦四 Agent 导航">
return <nav className="xb-agent-nav" aria-label="兴邦项目工具导航">
<a href={projectLink("/xingbang-workbench",project)} className="xb-brand" title="兴邦金租项目工作台">
<span className="xb-brand-mark" aria-hidden="true">兴</span>
<span><b>兴邦金租</b><small>智能评审工作台</small></span>
</a>
<a href={projectLink("/xingbang-workbench",project)} className="xb-home"><LayoutDashboard size={16}/><span>项目总览</span></a>
<span className="xb-agent-label">四 Agent 工作区</span>
{agents.map((a,i)=><a key={a.id} href={projectLink(a.href,project)} className={active===a.id?"selected":""} aria-current={active===a.id?"page":undefined}><a.icon size={16}/><span><small>0{i+1}</small>{a.name}</span></a>)}
<span className="xb-agent-label">项目工具与验证</span>
<a href={projectLink("/operations-center",project)} className={active==="operations"?"selected":""}><Activity size={16}/>运行中心</a>
<a href={projectLink("/operations-center?view=trace",project)}><ShieldCheck size={16}/>检查依据</a>
<a href="/operations-center?view=lab&domain=lab"><FlaskConical size={16}/>演示与验收</a>
<a href={projectLink("/data-access",project)} className={active==="risk"?"selected":""}><Settings2 size={16}/>数据与配置</a>
<details className="xb-legacy"><summary>兼容入口</summary><div>{agents.filter(a=>a.id!=="risk").map(a=><a key={a.id} href={projectLink(a.href,project)}><a.icon size={14}/>{a.name}</a>)}<small>旧业务能力保留,尚未全部迁移</small></div></details>
{project&&<span className="xb-project-context">当前项目:{project}</span>}
</nav>;
}
@@ -0,0 +1,22 @@
"use client";
import {useEffect, useState} from "react";
export default function AssistantConfiguration({request, canEdit}: {request: (path: string, body?: any) => Promise<any>; canEdit: boolean}) {
const [catalog, setCatalog] = useState<any>(null), [editor, setEditor] = useState(""), [message, setMessage] = useState(""), [busy, setBusy] = useState(false);
async function load() {try {setCatalog(await request("/components"));} catch (e: any) {setMessage(e.message);}}
useEffect(() => {load();}, []);
async function action(path: string, body: any) {
setBusy(true); setMessage("");
try {const result = await request(path, body); setMessage(result.passed === false ? "评测未通过,禁止发布。请查看评测明细。" : "操作已记录"); await load();}
catch (e: any) {setMessage(e.message);} finally {setBusy(false);}
}
return <section className="xb-panel xb-wide">
<h2>组件版本与发布</h2><p>此区域供管理员和审核人使用。已发布版本不可覆盖;修改时填写新版本号,先评测再由独立审核人发布。本机预览允许单人演练。</p>
<p><a href="/data-access">打开数据源、数据卡、Skills 与业务方案管理 →</a></p>
{message && <div role="status" className="ra-alert">{message}</div>}
<div className="xb-config-list">{catalog?.published.map((c: any) => <article className="xb-finding" key={c.id}><div><b>{c.name}</b><p>{c.kind} · {c.id} · {c.version} · {c.scope}</p><small>{c.content_hash}</small></div>{canEdit && <button className="ra-button" onClick={() => {const {revision, component_id, ...body} = c; setEditor(JSON.stringify({...body, version: c.version + ".next"}, null, 2));}}>基于此版本创建草稿</button>}</article>)}</div>
{canEdit && <><label className="ra-field"><span>组件草稿 JSON(业务用户无需填写)</span><textarea className="ra-code" value={editor} onChange={e => setEditor(e.target.value)} placeholder="从已发布组件创建草稿;修改版本、规则、模板或技能依赖"/></label><button className="ra-button" disabled={!editor || busy} onClick={() => {try {action("/components", JSON.parse(editor));} catch {setMessage("JSON 格式不正确");}}}>保存草稿</button></>}
<h3>待评测与审核</h3>{catalog?.drafts.map((d: any) => <article className="xb-panel" key={d.id}><b>{d.name} · {d.version}</b><p>{d.status} · {d.edited_by}</p><div className="xb-actions"><button className="ra-button" disabled={busy} onClick={() => action(`/components/${encodeURIComponent(d.id)}/actions`, {action: "evaluate", revision: d.revision, note: "执行隔离回归评测"})}>运行评测</button><button className="ra-button" disabled={busy || !d.evaluation?.passed} onClick={() => action(`/components/${encodeURIComponent(d.id)}/actions`, {action: "publish", revision: d.revision, note: "审核发布"})}>审核发布</button></div>{d.evaluation && <pre>{JSON.stringify(d.evaluation, null, 2)}</pre>}</article>)}
<details><summary>历史版本与回退</summary>{catalog?.versions.map((v: any) => <div className="xb-finding" key={v.id}><span>{v.name} · {v.version}</span><button className="ra-button" disabled={busy} onClick={() => action(`/components/${encodeURIComponent(v.id)}/actions`, {action: "rollback", revision: v.revision, note: "人工选择已发布历史版本"})}>切换到该版本</button></div>)}</details>
</section>;
}
@@ -0,0 +1,14 @@
"use client";
import {useState} from "react";
export default function AssistantHistory({projectId, records, request}: {projectId: string; records: any[]; request: (path: string) => Promise<any>}) {
const [archive, setArchive] = useState<any>(null), [error, setError] = useState("");
const names: Record<string, string> = {runs: "旧运行", snapshots: "旧快照", reports: "旧底稿", findings: "旧问题", experiences: "旧经验", scenarios: "旧情景"};
async function open(record: any) {
setError("");
try {setArchive(await request(`/projects/${encodeURIComponent(projectId)}/legacy/${record.kind}/${encodeURIComponent(record.id.slice(record.id.indexOf(":") + 1))}`));}
catch (e: any) {setError(e.message);}
}
if (!records.length) return null;
return <div><h3>迁移保留的只读历史</h3><p>旧原型中的证据定位未经过当前解析器验证,仅供历史对照,不作为新运行的确认依据。</p>{error && <p role="alert">{error}</p>}<div className="xb-actions">{records.map(r => <button className="ra-button" key={r.id} onClick={() => open(r)}>{names[r.kind] || r.kind} · {r.id.slice(-8)}</button>)}</div>{archive && <article className="xb-document"><h3>{names[archive.kind]} · {archive.legacy_id}</h3>{archive.body.sections ? archive.body.sections.map((s: any) => <section key={s.id}><h4>{s.title}</h4><pre>{s.content}</pre></section>) : archive.kind === "experiences" ? <><b>{archive.body.title}</b><p>{archive.body.action}</p><p>适用:{archive.body.trigger};例外:{archive.body.exceptions}</p></> : <pre>{JSON.stringify(archive.body, null, 2)}</pre>}</article>}</div>;
}
@@ -0,0 +1 @@
.xb-legacy{position:relative;font-size:11px;color:#839789;cursor:pointer}.xb-legacy>div{position:absolute;right:0;top:28px;width:220px;padding:12px;background:white;border:1px solid #dfe8e2;box-shadow:0 8px 25px #183e2520;border-radius:8px}.xb-legacy small{display:block;padding:8px;line-height:1.6}
+6 -51
View File
@@ -3,10 +3,10 @@
* Custom SSE parser for POST requests (native EventSource only supports GET).
*/
const API_BASE =
typeof window !== "undefined"
? `http://${window.location.hostname}:8002/api`
: "http://localhost:8002/api";
import {readChatStream} from "./chat-stream";
// Same-origin streaming proxy shares the current workbench backend configuration.
const API_BASE = "/api/core";
export interface SSEEvent {
event: string;
@@ -21,52 +21,7 @@ export async function* streamChat(
message: string,
sessionId: string
): AsyncGenerator<SSEEvent> {
const response = await fetch(`${API_BASE}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, session_id: sessionId, stream: true }),
});
if (!response.ok) {
throw new Error(`Chat API error: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let currentEvent = "message";
for (const line of lines) {
if (line.startsWith("event:")) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith("data:")) {
const dataStr = line.slice(5).trim();
if (dataStr) {
try {
const data = JSON.parse(dataStr);
yield { event: currentEvent, data };
} catch {
// Skip malformed JSON
}
}
}
// Empty line resets event type
if (line === "") {
currentEvent = "message";
}
}
}
yield* readChatStream(`${API_BASE}/chat`, {message,session_id:sessionId,stream:true});
}
/**
@@ -309,7 +264,7 @@ export async function setRagMode(
/* ── 兴邦金租智能体系统 API (新版,基于会议痛点) ─── */
const XINGBANG_API_BASE = API_BASE.replace("/api", "/api/xingbang");
const XINGBANG_API_BASE = `${API_BASE}/xingbang`;
/**
* 获取兴邦系统配置(五Agent列表、项目类型等)
+92
View File
@@ -0,0 +1,92 @@
/** Stateful SSE decoder: TCP chunks are not event boundaries. */
export interface ChatEvent { event: string; data: Record<string, unknown> }
export class ChatEventDecoder {
private buffer = "";
private event = "message";
private data: string[] = [];
push(text: string, flush = false): ChatEvent[] {
this.buffer += text;
const result: ChatEvent[] = [];
const lines = this.buffer.split("\n");
this.buffer = lines.pop() || "";
if (flush) { if (this.buffer) lines.push(this.buffer); lines.push(""); this.buffer = ""; }
for (const raw of lines) {
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
if (!line) {
if (this.data.length) {
let parsed: unknown;
try { parsed = JSON.parse(this.data.join("\n")); }
catch { throw new Error("回答数据格式异常,请重试;已接收的内容会保留。"); }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("回答事件格式不受支持");
result.push({event: this.event, data: parsed as Record<string, unknown>});
}
this.event = "message"; this.data = [];
} else if (line.startsWith("event:")) {
this.event = line.slice(6).trim();
} else if (line.startsWith("data:")) {
this.data.push(line.slice(5).replace(/^ /, ""));
}
}
return result;
}
}
/** No automatic request replay: a tool may already have executed. */
export async function* readChatStream(url: string, body: unknown, options: {
fetcher?: typeof fetch; idleMs?: number; totalMs?: number;
} = {}): AsyncGenerator<ChatEvent> {
const controller = new AbortController();
const fetcher = options.fetcher || fetch;
const idleMs = options.idleMs ?? 90_000;
let timedOut = false, receivedDone = false, receivedError = false, answerSeen = false;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
const expire = () => { timedOut = true; controller.abort(); };
let idle = setTimeout(expire, idleMs);
const total = setTimeout(expire, options.totalMs ?? 180_000);
const touch = () => { clearTimeout(idle); idle = setTimeout(expire, idleMs); };
const decoder = new TextDecoder();
const parser = new ChatEventDecoder();
const annotate = (events: ChatEvent[]) => {
const normalized: ChatEvent[] = [];
for (const event of events) {
if (event.event === "token" && typeof event.data.content === "string" && event.data.content.trim()) answerSeen = true;
if (event.event === "done") {
if (!answerSeen) {
if (typeof event.data.content !== "string" || !event.data.content.trim()) throw new Error("模型没有返回回答正文,请检查聊天模型配置后重试。");
normalized.push({event:"token",data:{content:event.data.content}});
answerSeen = true;
}
receivedDone = true;
}
if (event.event === "error") receivedError = true;
normalized.push(event);
}
return normalized;
};
try {
const response = await fetcher(url, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body),signal:controller.signal});
if (!response.ok) throw new Error(`聊天服务请求失败(HTTP ${response.status}),请检查当前后端连接。`);
if (!response.headers.get("content-type")?.includes("text/event-stream")) throw new Error("聊天服务未返回事件流,请检查后端地址。");
reader = response.body?.getReader();
if (!reader) throw new Error("聊天服务未返回数据流。");
touch();
while (true) {
const {done,value} = await reader.read();
if (done) break;
for (const event of annotate(parser.push(decoder.decode(value,{stream:true})))) { touch(); yield event; }
if (receivedError) return;
}
for (const event of annotate(parser.push(decoder.decode(),true))) { touch(); yield event; }
if (!receivedDone && !receivedError) throw new Error("回答连接提前中断;已接收的内容已保留,请检查后重试。");
} catch (error) {
if (timedOut) {
if (receivedDone) return; // An optional title request must not invalidate a delivered answer.
throw new Error("回答等待超时;请检查模型服务后重试。不会自动重复执行工具。");
}
throw error;
} finally {
clearTimeout(idle);clearTimeout(total);controller.abort();
if (reader) {try {await reader.cancel();}catch {} reader.releaseLock();}
}
}
+2 -2
View File
@@ -473,7 +473,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
case "error":
msg.content +=
`\n\n**Error:** ${event.data.error || "Unknown error"}`;
`\n\n**未完成:** ${event.data.error || "聊天服务未返回有效回答"}`;
break;
}
@@ -491,7 +491,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
...updated[idx],
content:
updated[idx].content +
`\n\n**Connection error:** ${err instanceof Error ? err.message : "Unknown"}`,
`\n\n**连接或回答异常:** ${err instanceof Error ? err.message : "未知错误,请检查后端服务"}`,
};
}
return updated;