| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433 |
- import {
- AlertTriangle,
- Bot,
- ClipboardList,
- Database,
- FileSpreadsheet,
- FolderOpen,
- KeyRound,
- Play,
- RefreshCcw,
- Save,
- Send,
- Settings,
- ShieldCheck,
- Terminal,
- Trash2,
- UserRound,
- Wifi
- } from "lucide-react";
- import { Component, type ErrorInfo, type ReactNode } from "react";
- import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
- import type {
- AgentTask,
- AppConfig,
- AssistantResultTable,
- CaptchaResult,
- DashboardSnapshot,
- LogEntry,
- McpRuntimeConfigSnapshot,
- PriceComparisonExportResult,
- PriceComparisonRow,
- SessionStatus,
- TableExportResult,
- TaskRunResult
- } from "../shared/types";
- import logoUrl from "./assets/logo.png";
- type View = "assistant" | "tasks" | "settings" | "logs";
- interface ChatMessage {
- id: string;
- role: "user" | "assistant";
- text: string;
- taskIds?: string[];
- taskParams?: Record<string, Record<string, string>>;
- comparisonRows?: PriceComparisonRow[];
- orderStats?: OrderStatsTableData;
- mcpTables?: AssistantResultTable[];
- attachment?: ChatAttachment;
- }
- interface OrderStatsTableData {
- title: string;
- rows: OrderStatRow[];
- }
- interface OrderStatRow {
- metric: string;
- value: string;
- note: string;
- evidence: string;
- }
- interface ChatAttachment {
- kind: "excel";
- fileName: string;
- filePath: string;
- rowCount: number;
- tableCount?: number;
- }
- interface Feedback {
- type: "success" | "error" | "info";
- text: string;
- }
- interface ErrorBoundaryState {
- error?: Error;
- }
- const categoryNames: Record<AgentTask["category"], string> = {
- system: "系统",
- member: "会员",
- points: "积分",
- coupon: "券",
- order: "订单",
- product: "商品",
- ops: "运维"
- };
- export class AppErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
- state: ErrorBoundaryState = {};
- static getDerivedStateFromError(error: Error): ErrorBoundaryState {
- return { error };
- }
- componentDidCatch(error: Error, info: ErrorInfo) {
- console.error("Renderer crashed", error, info.componentStack);
- }
- render() {
- if (this.state.error) {
- return (
- <div className="error-screen">
- <img src={logoUrl} alt="市民请集合" />
- <h1>界面渲染异常</h1>
- <p>{this.state.error.message || "当前页面遇到异常,已拦截白屏。"}</p>
- <button className="primary-button" type="button" onClick={() => window.location.reload()}>
- <RefreshCcw size={16} />
- <span>重新加载</span>
- </button>
- </div>
- );
- }
- return this.props.children;
- }
- }
- export function App() {
- const [activeView, setActiveView] = useState<View>("assistant");
- const [dashboard, setDashboard] = useState<DashboardSnapshot>();
- const [config, setConfig] = useState<AppConfig>();
- const [configDraft, setConfigDraft] = useState<AppConfig>();
- const [session, setSession] = useState<SessionStatus>({ authenticated: false });
- const [tasks, setTasks] = useState<AgentTask[]>([]);
- const [logs, setLogs] = useState<LogEntry[]>([]);
- const [selectedTaskId, setSelectedTaskId] = useState("product.price.compare");
- const [params, setParams] = useState<Record<string, string>>({});
- const [lastResult, setLastResult] = useState<TaskRunResult>();
- const [busy, setBusy] = useState(false);
- const [chatBusy, setChatBusy] = useState(false);
- const [authFeedback, setAuthFeedback] = useState<Feedback>();
- const [loginFocusTick, setLoginFocusTick] = useState(0);
- const [prompt, setPrompt] = useState("");
- const [messages, setMessages] = useState<ChatMessage[]>([
- {
- id: crypto.randomUUID(),
- role: "assistant",
- text: "你好,我是市民请集合智能助手。你可以直接说要查会员、导出数据、处理积分或记录一个管理员操作需求,我会先给出可执行动作。"
- }
- ]);
- const selectedTask = useMemo(
- () => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0],
- [selectedTaskId, tasks]
- );
- async function refresh() {
- const [nextDashboard, nextConfig, nextTasks, nextLogs, nextSession] = await Promise.all([
- window.smqjhAgent.getDashboard(),
- window.smqjhAgent.getConfig(),
- window.smqjhAgent.listTasks(),
- window.smqjhAgent.listLogs(),
- window.smqjhAgent.getSession()
- ]);
- setDashboard(nextDashboard);
- setConfig(nextConfig);
- setConfigDraft((current) => current ?? nextConfig);
- setTasks(nextTasks);
- setLogs(nextLogs);
- setSession(nextSession);
- }
- useEffect(() => {
- void refresh();
- return window.smqjhAgent.onLog((entry) => setLogs((current) => [entry, ...current].slice(0, 300)));
- }, []);
- async function runTask() {
- if (!selectedTask) {
- return;
- }
- setBusy(true);
- try {
- const result = await window.smqjhAgent.runTask({ taskId: selectedTask.id, params });
- setLastResult(result);
- await refresh();
- } finally {
- setBusy(false);
- }
- }
- async function saveConfig(nextConfig: AppConfig) {
- const saved = await window.smqjhAgent.saveConfig(nextConfig);
- setConfig(saved);
- setConfigDraft(saved);
- await refresh();
- }
- async function login(request: { username: string; password: string; tenantCode: string; captchaId: string; captchaCode: string }) {
- setBusy(true);
- setAuthFeedback({ type: "info", text: "正在登录..." });
- try {
- const nextSession = await window.smqjhAgent.login(request);
- setSession(nextSession);
- setAuthFeedback({ type: "success", text: "登录成功" });
- await refresh();
- } catch (error) {
- const message = friendlyError(error);
- setAuthFeedback({ type: "error", text: message || "登录失败" });
- } finally {
- setBusy(false);
- }
- }
- async function resolveProductPriceParams(text: string, currentMessages: ChatMessage[]): Promise<Record<string, string> | undefined> {
- const localParams = buildProductPriceParams(text, currentMessages);
- if (!shouldAskDeepSeekForProductIntent(text, currentMessages)) {
- return localParams;
- }
- try {
- const result = await window.smqjhAgent.extractIntent({
- message: text,
- history: currentMessages.slice(-8).map((message) => ({
- role: message.role,
- content: message.text
- })),
- authenticated: session.authenticated,
- username: session.username,
- environmentName: config?.environmentName ?? dashboard?.config.environmentName ?? "test-gateway",
- baseUrl: config?.baseUrl ?? dashboard?.config.baseUrl ?? "http://192.168.1.242:8080",
- availableTasks: tasks.map(({ id, title, category, description, dangerLevel }) => ({
- id,
- title,
- category,
- description,
- dangerLevel
- }))
- });
- const params = normalizeProductIntentParams(result.params, text, currentMessages);
- if (result.taskId === "product.price.compare" && result.confidence >= 0.5 && params?.productKeyword) {
- return params;
- }
- return undefined;
- } catch {
- return localParams;
- }
- }
- async function resolveDatabaseQueryParams(text: string, currentMessages: ChatMessage[]): Promise<Record<string, string> | undefined> {
- if (!config?.deepSeek.enabled && !dashboard?.config.deepSeek.enabled) {
- return undefined;
- }
- try {
- const result = await window.smqjhAgent.extractIntent({
- message: text,
- history: currentMessages.slice(-8).map((message) => ({
- role: message.role,
- content: message.text
- })),
- authenticated: session.authenticated,
- username: session.username,
- environmentName: config?.environmentName ?? dashboard?.config.environmentName ?? "test-gateway",
- baseUrl: config?.baseUrl ?? dashboard?.config.baseUrl ?? "http://192.168.1.242:8080",
- availableTasks: tasks.map(({ id, title, category, description, dangerLevel }) => ({
- id,
- title,
- category,
- description,
- dangerLevel
- }))
- });
- if (result.taskId !== "database.readonly.query" || result.confidence < 0.55) {
- return undefined;
- }
- const sql = (result.params?.sql ?? "").trim();
- if (!sql) {
- return undefined;
- }
- return {
- question: result.params?.question?.trim() || text,
- sql
- };
- } catch {
- return undefined;
- }
- }
- async function sendPrompt(event: FormEvent) {
- event.preventDefault();
- const text = prompt.trim();
- if (!text || chatBusy) {
- return;
- }
- const userMessage: ChatMessage = { id: crypto.randomUUID(), role: "user", text };
- setPrompt("");
- setChatBusy(true);
- const rowsForExport = isComparisonExportIntent(text) ? getLatestComparisonRows(messages) ?? getComparisonRows(lastResult?.data) : undefined;
- const tablesForExport = isComparisonExportIntent(text) ? getLatestMcpTables(messages) : undefined;
- if (rowsForExport) {
- const actionMessage: ChatMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- text: "收到,我正在把上一轮价格对比结果整理成 Excel 文件。"
- };
- setMessages((current) => [...current, userMessage, actionMessage]);
- try {
- const exported = await window.smqjhAgent.exportPriceComparison({
- title: buildComparisonExportTitle(rowsForExport),
- rows: rowsForExport
- });
- setMessages((current) =>
- current.map((message) =>
- message.id === actionMessage.id
- ? {
- ...message,
- text: formatExportResult(exported),
- attachment: {
- kind: "excel",
- fileName: exported.fileName,
- filePath: exported.filePath,
- rowCount: exported.rowCount
- }
- }
- : message
- )
- );
- } catch (error) {
- const message = friendlyError(error);
- setMessages((current) =>
- current.map((item) =>
- item.id === actionMessage.id
- ? {
- ...item,
- text: `Excel 导出失败:${message || "写入文件失败"}。上一轮表格数据还在当前会话里,可以稍后重试导出。`
- }
- : item
- )
- );
- } finally {
- setChatBusy(false);
- }
- return;
- }
- if (tablesForExport) {
- const actionMessage: ChatMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- text: "收到,我正在把上一轮表格结果整理成 Excel 文件。"
- };
- setMessages((current) => [...current, userMessage, actionMessage]);
- try {
- const exported = await window.smqjhAgent.exportTables({
- title: buildTableExportTitle(tablesForExport),
- tables: tablesForExport
- });
- setMessages((current) =>
- current.map((message) =>
- message.id === actionMessage.id
- ? {
- ...message,
- text: formatTableExportResult(exported),
- attachment: {
- kind: "excel",
- fileName: exported.fileName,
- filePath: exported.filePath,
- rowCount: exported.rowCount,
- tableCount: exported.tableCount
- }
- }
- : message
- )
- );
- } catch (error) {
- const message = friendlyError(error);
- setMessages((current) =>
- current.map((item) =>
- item.id === actionMessage.id
- ? {
- ...item,
- text: `Excel 导出失败:${message || "写入文件失败"}。上一轮表格数据还在当前会话里,可以稍后重试导出。`
- }
- : item
- )
- );
- } finally {
- setChatBusy(false);
- }
- return;
- }
- const latestOrderStats = isOrderStatsTableIntent(text) ? getLatestOrderStats(messages) : undefined;
- if (latestOrderStats) {
- setMessages((current) => [
- ...current,
- userMessage,
- {
- id: crypto.randomUUID(),
- role: "assistant",
- text: "已把上一轮订单统计整理成表格视图。",
- orderStats: latestOrderStats
- }
- ]);
- setChatBusy(false);
- return;
- }
- const thinkingMessage: ChatMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- text: "正在理解需求,并按需要调用 MCP/本地工具..."
- };
- const requestMessages = [...messages, userMessage].slice(-12).map((message) => ({
- role: message.role,
- content: message.text
- }));
- setMessages((current) => [...current, userMessage, thinkingMessage]);
- try {
- const result = await window.smqjhAgent.runAssistant({
- message: text,
- history: requestMessages,
- authenticated: session.authenticated,
- username: session.username,
- environmentName: config?.environmentName ?? dashboard?.config.environmentName ?? "test-gateway",
- baseUrl: config?.baseUrl ?? dashboard?.config.baseUrl ?? "http://192.168.1.242:8080"
- });
- const taskIds = result.toolCalls
- .map((call) => call.name)
- .filter((name) => tasks.some((task) => task.id === name));
- const taskParams = Object.fromEntries(
- result.toolCalls
- .filter((call) => tasks.some((task) => task.id === call.name))
- .map((call) => [call.name, stringifyParams(call.arguments)])
- );
- await refresh();
- setMessages((current) =>
- current.map((message) =>
- message.id === thinkingMessage.id
- ? {
- ...message,
- text: result.content || "已完成处理,结果如下。",
- taskIds: taskIds.length ? taskIds : undefined,
- taskParams,
- comparisonRows: result.comparisonRows?.length ? result.comparisonRows : undefined,
- mcpTables: result.tables?.length ? result.tables : undefined
- }
- : message
- )
- );
- } catch (error) {
- const message = friendlyError(error);
- setMessages((current) =>
- current.map((item) =>
- item.id === thinkingMessage.id
- ? {
- ...item,
- text: `智能体执行失败:${message || "调用失败"}。\n\n我没有继续走前端规则兜底,避免再次出现意图被硬匹配带偏。请检查 MCP 服务、MCP 托管的 DeepSeek 配置和数据库只读配置后重试。`
- }
- : item
- )
- );
- } finally {
- setChatBusy(false);
- }
- return;
- const orderCountParams = buildOrderCountParams(text, messages);
- if (orderCountParams) {
- const actionMessage: ChatMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- text: "收到,我直接走数据库只读查询订单数量,并排除逻辑删除订单。"
- };
- setMessages((current) => [...current, userMessage, actionMessage]);
- setSelectedTaskId("order.count.query");
- setParams(orderCountParams);
- setLastResult(undefined);
- try {
- const result = await window.smqjhAgent.runTask({
- taskId: "order.count.query",
- params: orderCountParams
- });
- setLastResult(result);
- await refresh();
- setMessages((current) =>
- current.map((message) =>
- message.id === actionMessage.id
- ? {
- ...message,
- text: formatOrderCountResult(result, orderCountParams),
- orderStats: buildOrderStatsTable(result, orderCountParams)
- }
- : message
- )
- );
- } catch (error) {
- const message = friendlyError(error);
- setMessages((current) =>
- current.map((item) =>
- item.id === actionMessage.id
- ? {
- ...item,
- text: `订单数量查询启动失败:${message || "调用失败"}。`
- }
- : item
- )
- );
- } finally {
- setChatBusy(false);
- }
- return;
- }
- const productLookupParams = await resolveProductLookupParams(text, messages);
- if (productLookupParams) {
- const actionMessage: ChatMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- text: `我判断这个问题需要查商品库,正在按“${productLookupParams.productKeyword}”执行数据库只读查询。`,
- taskIds: ["product.lookup.summary"],
- taskParams: {
- "product.lookup.summary": productLookupParams
- }
- };
- setMessages((current) => [...current, userMessage, actionMessage]);
- setSelectedTaskId("product.lookup.summary");
- setParams(productLookupParams);
- setLastResult(undefined);
- try {
- const result = await window.smqjhAgent.runTask({
- taskId: "product.lookup.summary",
- params: productLookupParams
- });
- setLastResult(result);
- await refresh();
- setMessages((current) =>
- current.map((message) =>
- message.id === actionMessage.id
- ? {
- ...message,
- text: formatProductLookupResult(result, productLookupParams)
- }
- : message
- )
- );
- } catch (error) {
- const message = friendlyError(error);
- setMessages((current) =>
- current.map((item) =>
- item.id === actionMessage.id
- ? {
- ...item,
- text: `商品资料查询启动失败:${message || "调用失败"}。`
- }
- : item
- )
- );
- } finally {
- setChatBusy(false);
- }
- return;
- }
- const productPriceParams = await resolveProductPriceParams(text, messages);
- if (productPriceParams) {
- const actionMessage: ChatMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- text: `DeepSeek 已识别到商品和比价口径,我按“${productPriceParams.productKeyword}”直接发起商品价格对比调研:先用数据库只读查询 smqjh 系统价,再后台采集苏宁易购和慢慢买公开价格线索。`,
- taskIds: ["product.price.compare"],
- taskParams: {
- "product.price.compare": productPriceParams
- }
- };
- setMessages((current) => [...current, userMessage, actionMessage]);
- setSelectedTaskId("product.price.compare");
- setParams(productPriceParams);
- setLastResult(undefined);
- try {
- const result = await window.smqjhAgent.runTask({
- taskId: "product.price.compare",
- params: productPriceParams
- });
- setLastResult(result);
- await refresh();
- setMessages((current) =>
- current.map((message) =>
- message.id === actionMessage.id
- ? {
- ...message,
- text: formatProductPriceResult(result, productPriceParams),
- comparisonRows: getComparisonRows(result.data)
- }
- : message
- )
- );
- } catch (error) {
- const message = friendlyError(error);
- setMessages((current) =>
- current.map((item) =>
- item.id === actionMessage.id
- ? {
- ...item,
- text: `商品价格对比调研启动失败:${message || "调用失败"}。你也可以点击下方任务手动执行。`
- }
- : item
- )
- );
- } finally {
- setChatBusy(false);
- }
- return;
- }
- const databaseQueryParams = await resolveDatabaseQueryParams(text, messages);
- if (databaseQueryParams) {
- const actionMessage: ChatMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- text: "DeepSeek 判断这个问题需要查 smqjh 数据库,我会先做只读 SQL 安全校验,再直接执行并返回表格。",
- taskIds: ["database.readonly.query"],
- taskParams: {
- "database.readonly.query": databaseQueryParams
- }
- };
- setMessages((current) => [...current, userMessage, actionMessage]);
- setSelectedTaskId("database.readonly.query");
- setParams(databaseQueryParams);
- setLastResult(undefined);
- try {
- const result = await window.smqjhAgent.runTask({
- taskId: "database.readonly.query",
- params: databaseQueryParams
- });
- setLastResult(result);
- await refresh();
- setMessages((current) =>
- current.map((message) =>
- message.id === actionMessage.id
- ? {
- ...message,
- text: formatReadOnlyQueryResult(result, databaseQueryParams)
- }
- : message
- )
- );
- } catch (error) {
- const message = friendlyError(error);
- setMessages((current) =>
- current.map((item) =>
- item.id === actionMessage.id
- ? {
- ...item,
- text: `数据库智能只读查询启动失败:${message || "调用失败"}。`
- }
- : item
- )
- );
- } finally {
- setChatBusy(false);
- }
- return;
- }
- const localReply = buildAssistantReply(text, tasks, session);
- const legacyThinkingMessage: ChatMessage = {
- id: crypto.randomUUID(),
- role: "assistant",
- text: "正在分析这个需求..."
- };
- const legacyRequestMessages = [...messages, userMessage].slice(-12).map((message) => ({
- role: message.role,
- content: message.text
- }));
- setMessages((current) => [...current, userMessage, legacyThinkingMessage]);
- try {
- const result = await window.smqjhAgent.chat({
- messages: legacyRequestMessages,
- authenticated: session.authenticated,
- username: session.username,
- environmentName: config?.environmentName ?? dashboard?.config.environmentName ?? "test-gateway",
- baseUrl: config?.baseUrl ?? dashboard?.config.baseUrl ?? "http://192.168.1.242:8080",
- availableTasks: tasks.map(({ id, title, category, description, dangerLevel }) => ({
- id,
- title,
- category,
- description,
- dangerLevel
- }))
- });
- const taskIds = matchTaskIds(text, tasks);
- setMessages((current) =>
- current.map((message) =>
- message.id === legacyThinkingMessage.id
- ? { ...message, text: result.content, taskIds: taskIds.length > 0 ? taskIds : localReply.taskIds }
- : message
- )
- );
- } catch (error) {
- const message = friendlyError(error);
- setMessages((current) =>
- current.map((item) =>
- item.id === legacyThinkingMessage.id
- ? {
- ...item,
- text: `DeepSeek 暂时没有返回:${message || "调用失败"}\n\n${localReply.text}`,
- taskIds: localReply.taskIds
- }
- : item
- )
- );
- } finally {
- setChatBusy(false);
- }
- }
- function openTask(taskId: string, presetParams?: Record<string, string>) {
- setSelectedTaskId(taskId);
- setParams(presetParams ?? {});
- setLastResult(undefined);
- setActiveView("tasks");
- }
- function openLogin() {
- setActiveView("settings");
- setLoginFocusTick((value) => value + 1);
- }
- return (
- <div className="shell">
- <aside className="sidebar">
- <div className="brand">
- <img className="brand-logo" src={logoUrl} alt="市民请集合" />
- <div>
- <strong>市民请集合</strong>
- <span>智能助手</span>
- </div>
- </div>
- <nav className="nav">
- <NavButton active={activeView === "assistant"} icon={<Bot size={18} />} label="对话" onClick={() => setActiveView("assistant")} />
- <NavButton active={activeView === "tasks"} icon={<ClipboardList size={18} />} label="任务" onClick={() => setActiveView("tasks")} />
- <NavButton active={activeView === "settings"} icon={<Settings size={18} />} label="配置" onClick={() => setActiveView("settings")} />
- <NavButton active={activeView === "logs"} icon={<Terminal size={18} />} label="日志" onClick={() => setActiveView("logs")} />
- </nav>
- <button className="session-box" type="button" onClick={openLogin} title="打开后台登录">
- <StatusPill ok={session.authenticated} label={session.authenticated ? "已登录" : "未登录"} />
- <span>{session.username ?? "后台账号"}</span>
- </button>
- </aside>
- <main className="content">
- <header className="topbar">
- <div>
- <h1>{activeView === "assistant" ? "市民请集合智能助手" : activeView === "tasks" ? "任务中心" : activeView === "settings" ? "系统配置" : "运行日志"}</h1>
- <p>{config?.environmentName ?? "test-gateway"} · {config?.baseUrl ?? "http://192.168.1.242:8080"}</p>
- </div>
- <button className="icon-button" onClick={() => void refresh()} title="刷新">
- <RefreshCcw size={18} />
- </button>
- </header>
- {activeView === "assistant" && dashboard && (
- <AssistantView
- messages={messages}
- prompt={prompt}
- setPrompt={setPrompt}
- sendPrompt={sendPrompt}
- chatBusy={chatBusy}
- dashboard={dashboard}
- session={session}
- tasks={tasks}
- onPickTask={openTask}
- />
- )}
- {activeView === "tasks" && (
- <TasksView
- tasks={tasks}
- selectedTask={selectedTask}
- selectedTaskId={selectedTaskId}
- setSelectedTaskId={(taskId) => {
- setSelectedTaskId(taskId);
- setParams({});
- setLastResult(undefined);
- }}
- params={params}
- setParams={setParams}
- runTask={runTask}
- busy={busy}
- lastResult={lastResult}
- />
- )}
- {activeView === "settings" && config && (
- <SettingsView
- config={configDraft ?? config}
- setConfig={setConfigDraft}
- saveConfig={saveConfig}
- login={login}
- logout={async () => {
- setAuthFeedback(undefined);
- setSession(await window.smqjhAgent.logout());
- }}
- busy={busy}
- session={session}
- feedback={authFeedback}
- focusTick={loginFocusTick}
- />
- )}
- {activeView === "logs" && (
- <LogsView logs={logs} clear={async () => {
- await window.smqjhAgent.clearLogs();
- setLogs([]);
- }} />
- )}
- </main>
- </div>
- );
- }
- function matchTaskIds(text: string, tasks: AgentTask[]): string[] {
- const normalized = text.toLowerCase();
- const matchedIds: string[] = [];
- if (isOrderCountIntent(text)) {
- matchedIds.push("order.count.query");
- }
- if (isProductLookupIntent(text)) {
- matchedIds.push("product.lookup.summary");
- }
- if (isProductPriceIntent(text, "")) {
- matchedIds.push("product.price.compare");
- }
- if (text.includes("管理员") || text.includes("当前用户") || text.includes("当前账号") || normalized.includes("me")) {
- matchedIds.push("user.current");
- }
- if (text.includes("网关") || text.includes("连通") || text.includes("验证码") || normalized.includes("health")) {
- matchedIds.push("cloud.health");
- }
- const taskIds = matchedIds.filter((id, index) => matchedIds.indexOf(id) === index && tasks.some((task) => task.id === id));
- if (taskIds.length === 0) {
- taskIds.push(...["product.price.compare", "order.count.query", "cloud.health"].filter((id) => tasks.some((task) => task.id === id)));
- }
- return taskIds;
- }
- function buildAssistantReply(text: string, tasks: AgentTask[], session: SessionStatus): { text: string; taskIds: string[] } {
- const taskIds = matchTaskIds(text, tasks);
- const authText = session.authenticated ? "当前后台账号已登录。" : "当前还没有后台登录,涉及后台接口的动作会先要求登录。";
- return {
- taskIds,
- text: `${authText} 我理解这个需求可以先走 ${taskIds.length} 个动作:${taskIds.map((id) => tasks.find((task) => task.id === id)?.title ?? id).join("、")}。你可以点右侧动作进入参数确认。`
- };
- }
- function buildOrderCountParams(text: string, messages: ChatMessage[]): Record<string, string> | undefined {
- if (!isOrderCountIntent(text, messages)) {
- return undefined;
- }
- const mobile = extractMobile(text) || getLatestOrderMobile(messages);
- const dateRange = inferOrderDateRange(text);
- return {
- mobile: mobile ?? "",
- dateFrom: dateRange?.dateFrom ?? "",
- dateTo: dateRange?.dateTo ?? ""
- };
- }
- function isOrderCountIntent(text: string, messages: ChatMessage[] = []): boolean {
- const normalized = text.trim();
- if (!normalized) {
- return false;
- }
- const hasOrderWord = /订单|下单|订购|购买|买了|消费记录|交易记录|订单数|订单数量|多少单/.test(normalized);
- const hasAmountWord = /销售额|营业额|营收|金额|订单金额|实付|支付金额|收款|收入/.test(normalized);
- const hasCountWord = /多少|几|数量|总数|统计|计数|条|笔|单|次|下了|汇报|汇总/.test(normalized);
- const followsOrderContext = hasAmountWord && Boolean(getLatestOrderStats(messages));
- return (hasOrderWord && (hasCountWord || hasAmountWord)) || hasAmountWord || followsOrderContext;
- }
- function extractMobile(text: string): string | undefined {
- return text.match(/1\d{10}/)?.[0];
- }
- function getLatestOrderMobile(messages: ChatMessage[]): string | undefined {
- for (let index = messages.length - 1; index >= 0; index -= 1) {
- const mobile = extractMobile(messages[index].text);
- if (mobile) {
- return mobile;
- }
- }
- return undefined;
- }
- function isOrderStatsTableIntent(text: string): boolean {
- return /表格|表格视图|列表|列出来|整理一下|汇总一下/.test(text) && !isComparisonExportIntent(text);
- }
- function getLatestOrderStats(messages: ChatMessage[]): OrderStatsTableData | undefined {
- for (let index = messages.length - 1; index >= 0; index -= 1) {
- if (messages[index].orderStats?.rows.length) {
- return messages[index].orderStats;
- }
- }
- return undefined;
- }
- function inferOrderDateRange(text: string): { dateFrom: string; dateTo: string } | undefined {
- const today = startOfLocalDay(new Date());
- if (/今天|今日/.test(text)) {
- return { dateFrom: formatIsoDate(today), dateTo: formatIsoDate(addDays(today, 1)) };
- }
- if (/昨天|昨日/.test(text)) {
- const yesterday = addDays(today, -1);
- return { dateFrom: formatIsoDate(yesterday), dateTo: formatIsoDate(today) };
- }
- const recentDays = text.match(/近\s*(\d+)\s*天|最近\s*(\d+)\s*天/);
- if (recentDays) {
- const days = Number(recentDays[1] || recentDays[2]);
- if (Number.isFinite(days) && days > 0) {
- return { dateFrom: formatIsoDate(addDays(today, -days)), dateTo: formatIsoDate(addDays(today, 1)) };
- }
- }
- if (/近一年|最近一年|一年内|过去一年/.test(text)) {
- const from = new Date(today);
- from.setFullYear(from.getFullYear() - 1);
- return { dateFrom: formatIsoDate(from), dateTo: formatIsoDate(addDays(today, 1)) };
- }
- if (/本月|这个月|当月/.test(text)) {
- return { dateFrom: formatIsoDate(new Date(today.getFullYear(), today.getMonth(), 1)), dateTo: formatIsoDate(addDays(today, 1)) };
- }
- if (/今年|本年/.test(text)) {
- return { dateFrom: formatIsoDate(new Date(today.getFullYear(), 0, 1)), dateTo: formatIsoDate(addDays(today, 1)) };
- }
- if (/去年|上一年/.test(text)) {
- return { dateFrom: formatIsoDate(new Date(today.getFullYear() - 1, 0, 1)), dateTo: formatIsoDate(new Date(today.getFullYear(), 0, 1)) };
- }
- const explicitRange = text.match(/(\d{4}-\d{1,2}-\d{1,2}).{0,8}(?:到|至|-|~).{0,8}(\d{4}-\d{1,2}-\d{1,2})/);
- if (explicitRange) {
- return {
- dateFrom: normalizeDateText(explicitRange[1]),
- dateTo: formatIsoDate(addDays(parseLocalDate(normalizeDateText(explicitRange[2])), 1))
- };
- }
- return undefined;
- }
- function formatOrderCountResult(result: TaskRunResult, params: Record<string, string>): string {
- if (!result.success) {
- return `订单数量查询未完成:${result.message}`;
- }
- const data = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
- const scope = buildOrderScopeLabel(params);
- return `已通过 MySQL 只读查询${scope},订单总数:${numberText(data.totalCount)} 单,已支付销售额:${moneyText(data.paidAmount)}。下方已整理成表格视图。`;
- }
- function buildOrderStatsTable(result: TaskRunResult, params: Record<string, string>): OrderStatsTableData | undefined {
- if (!result.success || !result.data || typeof result.data !== "object") {
- return undefined;
- }
- const data = result.data as Record<string, unknown>;
- const scope = buildOrderScopeLabel(params);
- const evidence = String(data.evidence ?? "").trim();
- const rows: OrderStatRow[] = [
- {
- metric: "订单总数",
- value: `${numberText(data.totalCount)} 单`,
- note: "满足当前筛选条件且未逻辑删除的订单数量",
- evidence
- },
- {
- metric: "订单金额合计",
- value: moneyText(data.totalAmount),
- note: `金额口径:${String(data.amountField ?? "COALESCE(actual_total, total, order_money)")}`,
- evidence
- },
- {
- metric: "已支付销售额",
- value: moneyText(data.paidAmount),
- note: "is_payed = 1 的订单金额合计",
- evidence: "SUM(CASE WHEN is_payed = 1 THEN orderAmount ELSE 0 END)"
- },
- {
- metric: "未支付金额",
- value: moneyText(data.unpaidAmount),
- note: "is_payed = 0 或为空的订单金额合计",
- evidence: "SUM(CASE WHEN COALESCE(is_payed, 0) = 0 THEN orderAmount ELSE 0 END)"
- },
- {
- metric: "已完成金额",
- value: moneyText(data.completedAmount),
- note: "完成口径订单金额合计",
- evidence: "hb_order_status IN (80, 90, 100)"
- },
- {
- metric: "已取消金额",
- value: moneyText(data.canceledAmount),
- note: "取消口径订单金额合计",
- evidence: "hb_order_status IN (-1, 50, 60)"
- }
- ];
- if (params.mobile) {
- rows.push(
- {
- metric: "收货手机号匹配",
- value: `${numberText(data.consigneeMobileCount)} 单`,
- note: `consignee_mobile 匹配 ${params.mobile}`,
- evidence: `oms_order.consignee_mobile LIKE "%${params.mobile}%"`
- },
- {
- metric: "会员手机号匹配",
- value: `${numberText(data.memberMobileCount)} 单`,
- note: `会员手机号或权益账号手机号匹配 ${params.mobile}`,
- evidence: `sm_member.mobile / ums_member_account.mobile LIKE "%${params.mobile}%"`
- },
- {
- metric: "买家手机号匹配",
- value: `${numberText(data.buyerMobileCount)} 单`,
- note: `买家会员手机号匹配 ${params.mobile}`,
- evidence: `sm_member.mobile LIKE "%${params.mobile}%"`
- }
- );
- }
- rows.push(
- {
- metric: "已支付",
- value: `${numberText(data.paidCount)} 单`,
- note: "is_payed = 1",
- evidence: "COUNT DISTINCT order_id"
- },
- {
- metric: "未支付",
- value: `${numberText(data.unpaidCount)} 单`,
- note: "is_payed = 0 或为空",
- evidence: "COUNT DISTINCT order_id"
- },
- {
- metric: "已完成",
- value: `${numberText(data.completedCount)} 单`,
- note: "hb_order_status 属于完成口径",
- evidence: "hb_order_status IN (80, 90, 100)"
- },
- {
- metric: "已取消",
- value: `${numberText(data.canceledCount)} 单`,
- note: "hb_order_status 属于取消口径",
- evidence: "hb_order_status IN (-1, 50, 60)"
- }
- );
- const latestOrderTime = String(data.latestOrderTime ?? "").trim();
- if (latestOrderTime) {
- rows.push({
- metric: "最近下单时间",
- value: latestOrderTime,
- note: "当前筛选范围内 create_time 最大值",
- evidence: "MAX(oms_order.create_time)"
- });
- }
- return {
- title: `订单统计${scope}`,
- rows
- };
- }
- function buildOrderScopeLabel(params: Record<string, string>): string {
- const parts: string[] = [];
- if (params.mobile) {
- parts.push(`手机号 ${params.mobile}`);
- }
- if (params.dateFrom && params.dateTo) {
- parts.push(`${params.dateFrom} 至 ${formatIsoDate(addDays(parseLocalDate(params.dateTo), -1))}`);
- } else if (params.dateFrom) {
- parts.push(`${params.dateFrom} 起`);
- } else if (params.dateTo) {
- parts.push(`${formatIsoDate(addDays(parseLocalDate(params.dateTo), -1))} 前`);
- }
- return parts.length ? `(${parts.join(",")})` : "全部非删除订单";
- }
- function numberText(value: unknown): string {
- const number = Number(value);
- return Number.isFinite(number) ? String(number) : "0";
- }
- function moneyText(value: unknown): string {
- const number = Number(value);
- return Number.isFinite(number) ? `¥${number.toFixed(2)}` : "¥0.00";
- }
- function startOfLocalDay(date: Date): Date {
- return new Date(date.getFullYear(), date.getMonth(), date.getDate());
- }
- function addDays(date: Date, days: number): Date {
- const next = new Date(date);
- next.setDate(next.getDate() + days);
- return next;
- }
- function formatIsoDate(date: Date): string {
- const year = date.getFullYear();
- const month = String(date.getMonth() + 1).padStart(2, "0");
- const day = String(date.getDate()).padStart(2, "0");
- return `${year}-${month}-${day}`;
- }
- function normalizeDateText(value: string): string {
- return value
- .split("-")
- .map((part, index) => (index === 0 ? part.padStart(4, "0") : part.padStart(2, "0")))
- .join("-");
- }
- function parseLocalDate(value: string): Date {
- const [year, month, day] = normalizeDateText(value).split("-").map(Number);
- return new Date(year, month - 1, day);
- }
- async function resolveProductLookupParams(text: string, currentMessages: ChatMessage[]): Promise<Record<string, string> | undefined> {
- return buildProductLookupParams(text, currentMessages);
- }
- function buildProductLookupParams(text: string, messages: ChatMessage[]): Record<string, string> | undefined {
- if (!isProductLookupIntent(text)) {
- return undefined;
- }
- const keyword = extractProductKeyword(text) || getLatestComparisonProduct(messages);
- return keyword ? { productKeyword: keyword } : undefined;
- }
- function isProductLookupIntent(text: string): boolean {
- if (isObviousNonProductTopic(text)) {
- return false;
- }
- const hasProductContext = /商品|产品|sku|spu|商城|业务系统|系统|后台|smqjh/i.test(text);
- const hasLookupWord = /描述|说明|详情|资料|信息|简介|规格|品牌|状态|叫什么|是什么|查一下|查询|查看/.test(text);
- const keyword = extractProductKeyword(text);
- return Boolean(keyword) && hasLookupWord && (hasProductContext || /ml|mL|ML|毫升|瓶|罐|盒|箱|可乐|饮料|牛奶|茶|水/.test(text));
- }
- function formatProductLookupResult(result: TaskRunResult, params: Record<string, string>): string {
- if (!result.success) {
- return `商品资料查询未完成:${result.message}。`;
- }
- const data = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
- return [
- `已通过 MySQL 只读查询“${params.productKeyword}”的商品资料。`,
- "",
- "| 项目 | 内容 |",
- "| --- | --- |",
- `| 商品名称 | ${escapeMarkdownCell(String(data.productName ?? ""))} |`,
- `| 品牌 | ${escapeMarkdownCell(String(data.brandName ?? "未设置"))} |`,
- `| 价格 | ${escapeMarkdownCell(String(data.price ?? "未获取"))} |`,
- `| 状态 | ${escapeMarkdownCell(String(data.status ?? "未设置"))} |`,
- `| 商品描述 | ${escapeMarkdownCell(String(data.description ?? "未读取到描述"))} |`,
- `| 备注 | ${escapeMarkdownCell(String(data.note ?? ""))} |`,
- `| 依据 | ${escapeMarkdownCell(String(data.evidence ?? ""))} |`
- ].join("\n");
- }
- function escapeMarkdownCell(value: string): string {
- return value
- .replace(/\|/g, "\\|")
- .replace(/\r?\n/g, " ")
- .trim();
- }
- function formatReadOnlyQueryResult(result: TaskRunResult, params: Record<string, string>): string {
- if (!result.success) {
- const data = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
- const reason = String(data.reason ?? result.message ?? "只读查询失败");
- const sql = String(data.sql ?? params.sql ?? "").trim();
- return [
- `数据库智能只读查询未完成:${reason}`,
- sql ? `依据 SQL:${sql}` : ""
- ]
- .filter(Boolean)
- .join("\n\n");
- }
- const data = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
- const columns = Array.isArray(data.columns) ? data.columns.map((column) => String(column)) : [];
- const rows = Array.isArray(data.rows) ? data.rows.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object") : [];
- const executedSql = String(data.executedSql ?? params.sql ?? "").trim();
- const rowCount = Number(data.rowCount ?? rows.length);
- return [
- `已根据你的问题执行数据库只读查询,共返回 ${Number.isFinite(rowCount) ? rowCount : rows.length} 行。`,
- toMarkdownResultTable(columns, rows),
- executedSql ? `依据 SQL:${executedSql}` : ""
- ]
- .filter(Boolean)
- .join("\n\n");
- }
- function toMarkdownResultTable(columns: string[], rows: Record<string, unknown>[]): string {
- if (columns.length === 0) {
- return "| 结果 | 说明 |\n| --- | --- |\n| 无返回行 | 查询执行成功,但结果集为空 |";
- }
- const displayColumns = columns.slice(0, 12);
- const overflowCount = Math.max(columns.length - displayColumns.length, 0);
- const header = `| ${displayColumns.map(escapeMarkdownCell).join(" | ")}${overflowCount > 0 ? " | 其他列 |" : " |"}`;
- const divider = `| ${displayColumns.map(() => "---").join(" | ")}${overflowCount > 0 ? " | --- |" : " |"}`;
- const body = rows.slice(0, 100).map((row) => {
- const cells = displayColumns.map((column) => escapeMarkdownCell(formatQueryCell(row[column])));
- if (overflowCount > 0) {
- cells.push(`还有 ${overflowCount} 列未展开`);
- }
- return `| ${cells.join(" | ")} |`;
- });
- return [header, divider, ...body].join("\n");
- }
- function formatQueryCell(value: unknown): string {
- if (value === null || value === undefined) {
- return "";
- }
- if (value instanceof Date) {
- return value.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false });
- }
- if (typeof value === "object") {
- return JSON.stringify(value);
- }
- return String(value);
- }
- function buildProductPriceParams(text: string, messages: ChatMessage[]): Record<string, string> | undefined {
- const standaloneIntent = isStandaloneProductPriceIntent(text);
- const followUpIntent = !standaloneIntent && isProductPriceFollowUp(text, messages);
- if (!standaloneIntent && !followUpIntent) {
- return undefined;
- }
- const productFromCurrent = extractProductKeyword(text);
- const productFromContext = followUpIntent ? getLatestComparisonProduct(messages) : "";
- let productKeyword = productFromCurrent || productFromContext;
- if (!productKeyword) {
- return undefined;
- }
- const spec = inferProductSpec(text);
- if (spec && !productKeyword.includes(spec)) {
- productKeyword = `${productKeyword} ${spec}`;
- }
- return {
- productKeyword,
- productType: "auto",
- openBrowser: /打开浏览器|弹出页面|人工复核|手动查看/.test(text) ? "true" : "false",
- notes: spec ? `按${spec}口径查询,对比同规格到手价。` : "按同规格、同口径到手价进行对比。"
- };
- }
- function shouldAskDeepSeekForProductIntent(text: string, messages: ChatMessage[]): boolean {
- return isStandaloneProductPriceIntent(text) || isProductPriceFollowUp(text, messages);
- }
- function normalizeProductIntentParams(
- params: Record<string, string> | undefined,
- text: string,
- messages: ChatMessage[]
- ): Record<string, string> | undefined {
- if (!params) {
- return undefined;
- }
- const fallback = buildProductPriceParams(text, messages);
- if (!fallback) {
- return undefined;
- }
- const rawKeyword = (params.productKeyword || params.keyword || "").trim();
- const keyword = extractProductKeyword(rawKeyword) || rawKeyword || fallback.productKeyword || "";
- if (!keyword) {
- return undefined;
- }
- const spec = inferProductSpec(`${text} ${keyword}`);
- const productKeyword = spec && !keyword.includes(spec) ? `${keyword} ${spec}` : keyword;
- const notes = (params.notes || "").trim() || (spec ? `按${spec}口径查询,对比同规格到手价。` : "按同规格、同口径到手价进行对比。");
- return {
- productKeyword,
- productType: params.productType || "auto",
- openBrowser: String(params.openBrowser).toLowerCase() === "true" || /打开浏览器|弹出页面|人工复核|手动查看/.test(text) ? "true" : "false",
- notes
- };
- }
- function isProductPriceIntent(text: string, historyText: string): boolean {
- return isStandaloneProductPriceIntent(text) || isStandaloneProductPriceIntent(historyText);
- }
- function isStandaloneProductPriceIntent(text: string): boolean {
- if (isObviousNonProductTopic(text)) {
- return false;
- }
- const keyword = extractProductKeyword(text);
- if (!keyword || isGenericProductKeyword(keyword)) {
- return false;
- }
- const hasPriceWord = /价格|定价|售价|比价|到手价|多少钱|对比|比较|电商价|系统价/.test(text);
- const hasProductContext = /商品|产品|系统价|业务系统|sku|spu|淘宝|京东|拼多多|电商|市面|平台|商城|同规格|smqjh/i.test(text);
- const hasConcreteSpec = Boolean(inferProductSpec(text)) || /单瓶|一瓶|1瓶|单件|一件|箱装|整箱|瓶装|盒装|罐装|ml|mL|ML|毫升|升|L\b/.test(text);
- return hasPriceWord && (hasProductContext || hasConcreteSpec);
- }
- function isProductPriceFollowUp(text: string, messages: ChatMessage[]): boolean {
- if (isObviousNonProductTopic(text) || !getLatestComparisonProduct(messages)) {
- return false;
- }
- const looksLikeFollowUp = /^(再|继续|换成|改成|按|只|也|那|这个|这款|它|同规格|单瓶|单件|一瓶|一件)/.test(text.trim())
- || /单瓶|一瓶|1瓶|单件|一件|同规格|口径|规格|\d+(?:\.\d+)?\s*(?:ml|mL|ML|毫升|l|L|升)/.test(text);
- const hasPriceAction = /价格|定价|售价|比价|到手价|多少钱|对比|比较|查|看|跑/.test(text);
- return looksLikeFollowUp && hasPriceAction;
- }
- function isObviousNonProductTopic(text: string): boolean {
- if (/天气|气温|下雨/.test(text)) {
- return true;
- }
- const hasPriceOrProduct = /商品|产品|价格|定价|售价|比价|到手价|多少钱|系统价|电商|淘宝|京东|拼多多|市面/.test(text);
- return !hasPriceOrProduct && /订单|订购|支付|退款|售后|物流|快递|会员|手机号|登录|验证码|日志|网关|接口|token|权限|菜单|配置|数据库|报错|启动|退出/.test(text);
- }
- function isGenericProductKeyword(keyword: string): boolean {
- return /^(单瓶|一瓶|1瓶|单件|一件|单个|规格|同规格|口径|价格|比价|对比|好|好的|行|可以|只|这个|这款|它)$/i.test(keyword.trim());
- }
- function getLatestComparisonProduct(messages: ChatMessage[]): string {
- const rows = getLatestComparisonRows(messages);
- if (!rows) {
- return "";
- }
- const systemRow = rows.find((row) => row.platform === "smqjh系统" && row.productName && row.price !== "未获取");
- return (systemRow ?? rows.find((row) => row.productName))?.productName ?? "";
- }
- function inferProductSpec(text: string): string {
- if (/单瓶|一瓶|1瓶/.test(text)) {
- return "单瓶";
- }
- const volume = text.match(/\d+(?:\.\d+)?\s*(?:ml|mL|ML|毫升|l|L|升)(?:\s*[xX*×]\s*\d+\s*(?:瓶|桶|罐|支|盒)?)?/);
- return volume?.[0].replace(/\s+/g, "") ?? "";
- }
- function extractProductKeyword(text: string): string {
- const beforeCompare = text
- .replace(/(?:和|与|跟|同|及|以及)?\s*(?:其它|其他|其余)?\s*(?:电商平台|电商|平台|淘宝|京东|拼多多|市面).*$/s, " ")
- .replace(/市面上.*$/s, " ")
- .replace(/其它?电商平台.*$/s, " ");
- const cleaned = beforeCompare
- .replace(/https?:\/\/\S+/g, " ")
- .replace(/[??。!,,;;::\n\r]/g, " ")
- .replace(/(?:帮我|麻烦|我要|我想|可以)?\s*(?:在|从)?\s*(?:业务系统|系统|后台|smqjh)(?:里面|里|中|的)?/gi, " ")
- .replace(/我们|咱们|我方|我司|本方|己方|我要|我想|帮我|麻烦|可以|一下|这个|某个|当前|只/g, " ")
- .replace(/(?:只)?(?:查询一下|查一下|查询|查看|查|看看)/g, " ")
- .replace(/商品描述|商品说明|商品资料|商品信息|描述|说明|资料|信息|简介|详情|是什么|还是什么|商品|产品|价格是多少|定价是多少|售价是多少|多少钱|价格|定价|售价|到手价|比价|对比一下|对比下|对比|比较一下|比较下|比较|做个|做一下/g, " ")
- .replace(/淘宝|京东|拼多多|电商平台|电商|市面|平台/g, " ")
- .replace(/的/g, " ")
- .replace(/\s+/g, " ")
- .replace(/^(?:在|从|里|里面|中)\s+/g, "")
- .replace(/^(?:和|与|跟|同|及|以及)\s*/g, "")
- .replace(/\s*(?:和|与|跟|同|及|以及)$/g, "")
- .trim();
- const genericOnly = /^(单瓶|一瓶|1瓶|单个|规格|同规格|口径|好|好的|行|可以|只)$/;
- return genericOnly.test(cleaned) ? "" : cleaned;
- }
- function formatProductPriceResult(result: TaskRunResult, params: Record<string, string>): string {
- if (!result.success) {
- return `商品价格对比调研未完成:${result.message}。我已把“${params.productKeyword}”填入任务参数,可以在任务页调整后重新运行。`;
- }
- const data = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
- const rows = getComparisonRows(result.data) ?? [];
- const externalRows = rows.filter((row) => row.platform !== "smqjh系统");
- const blockedCount = externalRows.filter((row) => row.status === "未采集到").length;
- const systemMissing = rows.some((row) => row.platform === "smqjh系统" && row.price === "未获取");
- const browserResults = Array.isArray(data.browserResults) ? data.browserResults : [];
- const openedCount = browserResults.filter((item) => Boolean((item as { opened?: boolean }).opened)).length;
- const browserText = openedCount > 0 ? `另外按要求打开了 ${openedCount} 个外部平台页面。` : "未弹出外部平台页面。";
- const systemText = systemMissing ? "smqjh 系统价暂未匹配到,通常需要更精确的商品ID、SKU ID、商品编码,或确认后台商品名称。" : "已拿到 smqjh 系统价。";
- const marketText =
- blockedCount === externalRows.length && externalRows.length > 0
- ? "外部平台公开页面触发登录/风控或没有可解析价格,这不是你还需要手工查,而是需要接入平台官方接口、采购接口或授权采集账号。"
- : "外部平台已尽量后台采集,表格里会标出采集状态。";
- return [
- `已按“${params.productKeyword}”完成商品价格对比调研,${browserText}`,
- `${systemText}${marketText}`,
- "下方是表格视图,“依据”列会标出数据库只读查询范围或外部浏览器请求地址。"
- ]
- .filter(Boolean)
- .join("\n\n");
- }
- function isComparisonExportIntent(text: string): boolean {
- const normalized = text.toLowerCase();
- const hasExportAction = /导出|下载|保存|生成|做成|做个|做一个|整理成|转成|给我一个|给我份/.test(text);
- const hasSheetTarget = /excel|xlsx|xls|exlc|表格|电子表格|文件/.test(normalized);
- return hasExportAction && hasSheetTarget;
- }
- function getLatestComparisonRows(messages: ChatMessage[]): PriceComparisonRow[] | undefined {
- for (let index = messages.length - 1; index >= 0; index -= 1) {
- const rows = messages[index].comparisonRows;
- if (rows?.length) {
- return rows;
- }
- }
- return undefined;
- }
- function getLatestMcpTables(messages: ChatMessage[]): AssistantResultTable[] | undefined {
- for (let index = messages.length - 1; index >= 0; index -= 1) {
- const tables = messages[index].mcpTables?.filter((table) => table.columns.length > 0);
- if (tables?.length) {
- return tables;
- }
- }
- return undefined;
- }
- function buildComparisonExportTitle(rows: PriceComparisonRow[]): string {
- const systemRow = rows.find((row) => row.platform === "smqjh系统") ?? rows[0];
- return `商品价格对比_${systemRow?.productName || "导出"}`;
- }
- function buildTableExportTitle(tables: AssistantResultTable[]): string {
- const title = tables[0]?.title?.trim();
- return title ? `智能体查询结果_${title}` : "智能体查询结果";
- }
- function formatExportResult(result: PriceComparisonExportResult): string {
- return `已经按上一轮价格对比结果生成 Excel:${result.fileName}\n\n共导出 ${result.rowCount} 行数据,文件已保存到本机下载目录。`;
- }
- function formatTableExportResult(result: TableExportResult): string {
- return `已经把上一轮表格结果生成 Excel:${result.fileName}\n\n共导出 ${result.tableCount} 个表、${result.rowCount} 行数据,文件已保存到本机下载目录。`;
- }
- function NavButton(props: { active: boolean; icon: JSX.Element; label: string; onClick: () => void }) {
- return (
- <button className={props.active ? "nav-button active" : "nav-button"} type="button" aria-pressed={props.active} onClick={props.onClick}>
- {props.icon}
- <span>{props.label}</span>
- </button>
- );
- }
- function StatusPill({ ok, label }: { ok: boolean; label: string }) {
- return <span className={ok ? "pill ok" : "pill muted"}>{label}</span>;
- }
- function friendlyError(error: unknown): string {
- const raw = error instanceof Error ? error.message : String(error);
- const cleaned = raw
- .replace(/^Error invoking remote method '[^']+': Error: /, "")
- .replace(/^Error: /, "")
- .trim();
- if (cleaned === "Bad credentials") {
- return "登录失败:账号、密码或验证码错误";
- }
- return cleaned;
- }
- function stringifyParams(params: Record<string, unknown>): Record<string, string> {
- return Object.fromEntries(
- Object.entries(params).map(([key, value]) => [key, value === undefined || value === null ? "" : String(value)])
- );
- }
- function AssistantView(props: {
- messages: ChatMessage[];
- prompt: string;
- setPrompt: (value: string) => void;
- sendPrompt: (event: FormEvent) => void;
- chatBusy: boolean;
- dashboard: DashboardSnapshot;
- session: SessionStatus;
- tasks: AgentTask[];
- onPickTask: (taskId: string, presetParams?: Record<string, string>) => void;
- }) {
- const reviewTasks = props.tasks.filter((task) => task.dangerLevel === "review").length;
- const conversationEndRef = useRef<HTMLDivElement>(null);
- useEffect(() => {
- conversationEndRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
- }, [props.messages, props.chatBusy]);
- return (
- <section className="assistant-grid">
- <section className="panel conversation-panel">
- <div className="conversation">
- {props.messages.map((message) => (
- <div key={message.id} className={`message ${message.role}`}>
- <div className="avatar">
- {message.role === "assistant" ? <img src={logoUrl} alt="" /> : <UserRound size={18} />}
- </div>
- <div className="bubble">
- <MessageContent text={message.text} />
- {message.comparisonRows && <PriceComparisonTable rows={message.comparisonRows} />}
- {message.orderStats && <OrderStatsTable stats={message.orderStats} />}
- {message.mcpTables && <AssistantResultTables tables={message.mcpTables} />}
- {message.attachment && <AttachmentCard attachment={message.attachment} />}
- {message.taskIds && (
- <div className="message-actions">
- {message.taskIds.map((taskId) => {
- const task = props.tasks.find((item) => item.id === taskId);
- return task ? (
- <button key={taskId} className="secondary-button" onClick={() => props.onPickTask(taskId, message.taskParams?.[taskId])}>
- <Play size={15} />
- <span>{task.title}</span>
- </button>
- ) : null;
- })}
- </div>
- )}
- </div>
- </div>
- ))}
- {props.chatBusy && (
- <div className="message assistant pending">
- <div className="avatar">
- <img src={logoUrl} alt="" />
- </div>
- <div className="bubble thinking-bubble">
- <div className="thinking-row">
- <span>正在处理</span>
- <span className="thinking-dots" aria-hidden="true">
- <i />
- <i />
- <i />
- </span>
- </div>
- </div>
- </div>
- )}
- <div ref={conversationEndRef} />
- </div>
- <form className="composer" onSubmit={props.sendPrompt}>
- <div className="composer-input">
- <textarea
- value={props.prompt}
- placeholder="输入管理员想完成的操作,例如:帮我查这个手机号的企业会员,或者记录一个批量发券需求"
- disabled={props.chatBusy}
- onChange={(event) => props.setPrompt(event.target.value)}
- onKeyDown={(event) => {
- if (event.ctrlKey && event.key === "Enter") {
- event.preventDefault();
- event.currentTarget.form?.requestSubmit();
- }
- }}
- />
- <div className="composer-footer">
- <span>{props.chatBusy ? "处理中" : "就绪"}</span>
- <span>{props.prompt.length} 字</span>
- </div>
- </div>
- <button className="primary-button send-button" type="submit" title="发送" disabled={props.chatBusy || !props.prompt.trim()}>
- <Send size={16} />
- <span>{props.chatBusy ? "思考中" : "发送"}</span>
- </button>
- </form>
- </section>
- <aside className="context-panel">
- <section className="panel">
- <div className="panel-title">
- <h2>执行上下文</h2>
- </div>
- <div className="metric-stack">
- <ContextMetric icon={<Wifi size={18} />} label="网关" value={props.dashboard.config.baseUrl} />
- <ContextMetric icon={<ShieldCheck size={18} />} label="会话" value={props.session.authenticated ? "后台已登录" : "等待登录"} />
- <ContextMetric icon={<Database size={18} />} label="任务" value={`${props.tasks.length} 个`} />
- <ContextMetric icon={<AlertTriangle size={18} />} label="需复核" value={`${reviewTasks} 个`} />
- </div>
- </section>
- <section className="panel">
- <div className="panel-title">
- <h2>快捷动作</h2>
- </div>
- <div className="quick-actions">
- {props.tasks.slice(0, 6).map((task) => (
- <button key={task.id} className="quick-action" type="button" onClick={() => props.onPickTask(task.id)}>
- <span className={`danger ${task.dangerLevel}`}>{categoryNames[task.category]}</span>
- <strong>{task.title}</strong>
- <small>{task.endpoint ?? "local"}</small>
- </button>
- ))}
- </div>
- </section>
- </aside>
- </section>
- );
- }
- function ContextMetric({ icon, label, value }: { icon: JSX.Element; label: string; value: string }) {
- return (
- <div className="context-metric">
- {icon}
- <span>{label}</span>
- <strong>{value}</strong>
- </div>
- );
- }
- function DashboardView(props: { dashboard: DashboardSnapshot; session: SessionStatus; tasks: AgentTask[]; onOpenTasks: () => void }) {
- const reviewTasks = props.tasks.filter((task) => task.dangerLevel === "review").length;
- return (
- <section className="dashboard-grid">
- <div className="metric">
- <Wifi size={20} />
- <span>网关</span>
- <strong>{props.dashboard.config.baseUrl}</strong>
- </div>
- <div className="metric">
- <ShieldCheck size={20} />
- <span>会话</span>
- <strong>{props.session.authenticated ? "后台已登录" : "等待登录"}</strong>
- </div>
- <div className="metric">
- <Database size={20} />
- <span>任务</span>
- <strong>{props.tasks.length} 个</strong>
- </div>
- <div className="metric">
- <AlertTriangle size={20} />
- <span>需复核</span>
- <strong>{reviewTasks} 个</strong>
- </div>
- <section className="panel wide">
- <div className="panel-title">
- <h2>当前框架</h2>
- <button className="primary-button" onClick={props.onOpenTasks}>
- <Play size={16} />
- <span>执行任务</span>
- </button>
- </div>
- <div className="blueprint">
- <BlueprintItem title="连接层" text="OAuth 登录、Bearer Token、统一 Result 解包、超时控制" />
- <BlueprintItem title="任务层" text="任务注册表、参数表单、预演模式、执行日志" />
- <BlueprintItem title="配置层" text="网关地址、环境名、客户端配置、本地 JSON 持久化" />
- <BlueprintItem title="需求池" text="管理员诉求先记录为草稿,再补接口与审批策略" />
- </div>
- </section>
- <section className="panel">
- <div className="panel-title">
- <h2>接口线索</h2>
- </div>
- <ul className="plain-list">
- <li>/oauth2/token</li>
- <li>/api/v1/users/me</li>
- <li>/api/v1/member/export/task/start</li>
- <li>/api/v1/pointsRecharge/exportTemplate</li>
- <li>/api/v1/members/enterprise/getByMobile</li>
- </ul>
- </section>
- <section className="panel">
- <div className="panel-title">
- <h2>最近日志</h2>
- </div>
- <div className="compact-logs">
- {props.dashboard.lastLogs.length === 0 ? <span className="empty">暂无日志</span> : props.dashboard.lastLogs.map((log) => <LogLine key={log.id} log={log} />)}
- </div>
- </section>
- </section>
- );
- }
- function BlueprintItem({ title, text }: { title: string; text: string }) {
- return (
- <div className="blueprint-item">
- <strong>{title}</strong>
- <span>{text}</span>
- </div>
- );
- }
- function TasksView(props: {
- tasks: AgentTask[];
- selectedTask?: AgentTask;
- selectedTaskId: string;
- setSelectedTaskId: (taskId: string) => void;
- params: Record<string, string>;
- setParams: (params: Record<string, string>) => void;
- runTask: () => void;
- busy: boolean;
- lastResult?: TaskRunResult;
- }) {
- const comparisonRows = getComparisonRows(props.lastResult?.data);
- return (
- <section className="task-layout">
- <div className="task-list">
- {props.tasks.map((task) => (
- <button
- key={task.id}
- className={task.id === props.selectedTaskId ? "task-card selected" : "task-card"}
- type="button"
- onClick={() => props.setSelectedTaskId(task.id)}
- >
- <span className={`danger ${task.dangerLevel}`}>{categoryNames[task.category]}</span>
- <strong>{task.title}</strong>
- <small>{task.endpoint ?? "local"}</small>
- </button>
- ))}
- </div>
- <div className="panel task-detail">
- {props.selectedTask && (
- <>
- <div className="panel-title">
- <div>
- <h2>{props.selectedTask.title}</h2>
- <p>{props.selectedTask.description}</p>
- </div>
- <span className={`danger ${props.selectedTask.dangerLevel}`}>{props.selectedTask.dangerLevel}</span>
- </div>
- <div className="endpoint-row">
- <code>{props.selectedTask.method ?? "LOCAL"}</code>
- <span>{props.selectedTask.endpoint ?? props.selectedTask.id}</span>
- </div>
- <div className="param-grid">
- {props.selectedTask.params.length === 0 && <span className="empty">无需参数</span>}
- {props.selectedTask.params.map((param) => (
- <label key={param.key} className="field">
- <span>{param.label}{param.required ? " *" : ""}</span>
- {param.type === "textarea" ? (
- <textarea
- value={props.params[param.key] ?? ""}
- placeholder={param.placeholder}
- onChange={(event) => props.setParams({ ...props.params, [param.key]: event.target.value })}
- />
- ) : (
- <input
- type={param.type === "password" ? "password" : param.type === "number" ? "number" : "text"}
- value={props.params[param.key] ?? ""}
- placeholder={param.placeholder}
- onChange={(event) => props.setParams({ ...props.params, [param.key]: event.target.value })}
- />
- )}
- </label>
- ))}
- </div>
- <div className="action-row">
- <button className="primary-button" type="button" disabled={props.busy} onClick={props.runTask}>
- <Play size={16} />
- <span>{props.busy ? "执行中" : "运行"}</span>
- </button>
- </div>
- {props.lastResult && (
- <>
- {comparisonRows && <PriceComparisonTable rows={comparisonRows} />}
- {comparisonRows ? (
- <details className="result-details">
- <summary>查看原始结果</summary>
- <pre className={props.lastResult.success ? "result success" : "result error"}>
- {JSON.stringify(props.lastResult, null, 2)}
- </pre>
- </details>
- ) : (
- <pre className={props.lastResult.success ? "result success" : "result error"}>
- {JSON.stringify(props.lastResult, null, 2)}
- </pre>
- )}
- </>
- )}
- </>
- )}
- </div>
- </section>
- );
- }
- function MessageContent({ text }: { text: string }) {
- const blocks = parseMarkdownBlocks(text);
- return (
- <>
- {blocks.map((block, index) =>
- block.type === "table" ? (
- <MarkdownTable key={`table-${index}`} headers={block.headers} rows={block.rows} />
- ) : (
- <p key={`text-${index}`}>{block.text}</p>
- )
- )}
- </>
- );
- }
- type MarkdownBlock =
- | { type: "text"; text: string }
- | { type: "table"; headers: string[]; rows: string[][] };
- function parseMarkdownBlocks(text: string): MarkdownBlock[] {
- const lines = text.split(/\r?\n/);
- const blocks: MarkdownBlock[] = [];
- let textBuffer: string[] = [];
- function flushText() {
- const value = textBuffer.join("\n").trim();
- if (value) {
- blocks.push({ type: "text", text: value });
- }
- textBuffer = [];
- }
- for (let index = 0; index < lines.length; index += 1) {
- if (isMarkdownTableRow(lines[index]) && isMarkdownDelimiter(lines[index + 1] ?? "")) {
- flushText();
- const headers = parseMarkdownTableRow(lines[index]);
- index += 2;
- const rows: string[][] = [];
- while (index < lines.length && isMarkdownTableRow(lines[index])) {
- rows.push(parseMarkdownTableRow(lines[index]));
- index += 1;
- }
- index -= 1;
- if (headers.length && rows.length) {
- blocks.push({ type: "table", headers, rows });
- }
- } else {
- textBuffer.push(lines[index]);
- }
- }
- flushText();
- return blocks.length ? blocks : [{ type: "text", text }];
- }
- function isMarkdownTableRow(line: string): boolean {
- return /^\s*\|.+\|\s*$/.test(line);
- }
- function isMarkdownDelimiter(line: string): boolean {
- return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
- }
- function parseMarkdownTableRow(line: string): string[] {
- return line
- .trim()
- .replace(/^\|/, "")
- .replace(/\|$/, "")
- .split("|")
- .map((cell) => cell.trim());
- }
- function MarkdownTable({ headers, rows }: { headers: string[]; rows: string[][] }) {
- return (
- <div className="markdown-table-wrap">
- <table className="markdown-table">
- <thead>
- <tr>
- {headers.map((header, index) => (
- <th key={`${header}-${index}`}>{header}</th>
- ))}
- </tr>
- </thead>
- <tbody>
- {rows.map((row, rowIndex) => (
- <tr key={`row-${rowIndex}`}>
- {headers.map((_, cellIndex) => (
- <td key={`cell-${rowIndex}-${cellIndex}`}>{row[cellIndex] ?? ""}</td>
- ))}
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- );
- }
- function PriceComparisonTable({ rows }: { rows: PriceComparisonRow[] }) {
- return (
- <div className="comparison-wrap">
- <table className="comparison-table">
- <thead>
- <tr>
- <th>平台</th>
- <th>商品/规格</th>
- <th>价格</th>
- <th>折算单价</th>
- <th>状态</th>
- <th>来源</th>
- <th>备注</th>
- <th>依据</th>
- </tr>
- </thead>
- <tbody>
- {rows.map((row) => (
- <tr key={`${row.platform}-${row.productName}-${row.price}-${row.status}`}>
- <td>{row.platform}</td>
- <td>{row.productName}</td>
- <td className="price-cell">{row.price}</td>
- <td className="unit-price-cell">{row.unitPrice || "-"}</td>
- <td>
- <span className={`table-status ${statusTone(row.status)}`}>{row.status}</span>
- </td>
- <td>{row.source}</td>
- <td>{row.note}</td>
- <td>
- <EvidenceCell evidence={row.evidence} sourceUrl={row.sourceUrl} />
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- );
- }
- function AssistantResultTables({ tables }: { tables: AssistantResultTable[] }) {
- return (
- <>
- {tables.map((table) => (
- <div key={`${table.title}-${table.columns.join(",")}`} className="markdown-table-wrap">
- <div className="table-caption">{table.title}</div>
- <table className="markdown-table">
- <thead>
- <tr>
- {table.columns.map((column) => (
- <th key={column}>{column}</th>
- ))}
- </tr>
- </thead>
- <tbody>
- {table.rows.length ? (
- table.rows.map((row, rowIndex) => (
- <tr key={`mcp-row-${rowIndex}`}>
- {table.columns.map((column) => (
- <td key={`${rowIndex}-${column}`}>{formatQueryCell(row[column])}</td>
- ))}
- </tr>
- ))
- ) : (
- <tr>
- <td colSpan={Math.max(table.columns.length, 1)}>没有查询到数据</td>
- </tr>
- )}
- </tbody>
- </table>
- </div>
- ))}
- </>
- );
- }
- function OrderStatsTable({ stats }: { stats: OrderStatsTableData }) {
- return (
- <div className="order-stats-wrap">
- <div className="table-caption">{stats.title}</div>
- <table className="order-stats-table">
- <thead>
- <tr>
- <th>统计项</th>
- <th>数值</th>
- <th>口径说明</th>
- <th>依据</th>
- </tr>
- </thead>
- <tbody>
- {stats.rows.map((row) => (
- <tr key={`${row.metric}-${row.value}`}>
- <td>{row.metric}</td>
- <td className="stat-value">{row.value}</td>
- <td>{row.note}</td>
- <td>{row.evidence}</td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- );
- }
- function statusTone(status: string): string {
- if (/已匹配|价格线索|成功|已登录/.test(status)) {
- return "ok";
- }
- if (/未|待|失败|风控|错误/.test(status)) {
- return "warn";
- }
- return "muted";
- }
- function AttachmentCard({ attachment }: { attachment: ChatAttachment }) {
- return (
- <div className="attachment-card">
- <div className="attachment-main">
- <FileSpreadsheet size={20} />
- <div>
- <strong>{attachment.fileName}</strong>
- <span>{attachment.tableCount ? `${attachment.tableCount} 个表,` : ""}{attachment.rowCount} 行数据</span>
- </div>
- </div>
- <div className="attachment-actions">
- <button className="secondary-button" type="button" onClick={() => void window.smqjhAgent.openExportedFile(attachment.filePath)}>
- <FileSpreadsheet size={15} />
- <span>打开</span>
- </button>
- <button className="secondary-button" type="button" onClick={() => void window.smqjhAgent.showExportedFile(attachment.filePath)}>
- <FolderOpen size={15} />
- <span>定位</span>
- </button>
- </div>
- </div>
- );
- }
- function EvidenceCell({ evidence, sourceUrl }: { evidence?: string; sourceUrl?: string }) {
- const lines = (evidence || sourceUrl || "无").split(/\r?\n/).filter(Boolean);
- return (
- <div className="evidence-cell">
- {lines.map((line) => {
- const url = line.match(/https?:\/\/\S+/)?.[0];
- const label = url ? evidenceLinkLabel(line, url) : "";
- return (
- <div key={line} className="evidence-item">
- {url ? (
- <>
- <a className="evidence-link" href={url} target="_blank" rel="noreferrer" title={url}>
- {label}
- </a>
- <details className="evidence-detail">
- <summary>查看地址</summary>
- <code>{safeDecodeUrl(url)}</code>
- </details>
- </>
- ) : (
- <span className="evidence-text">{line}</span>
- )}
- </div>
- );
- })}
- </div>
- );
- }
- function evidenceLinkLabel(line: string, url: string): string {
- const prefix = line.replace(url, "").replace(/[::\s]+$/g, "").trim();
- const host = (() => {
- try {
- return new URL(url).hostname.replace(/^www\./, "");
- } catch {
- return "请求地址";
- }
- })();
- const action = prefix.includes("直连") ? "直连请求" : prefix.includes("搜索") ? "搜索请求" : prefix || "打开依据";
- return `${action} · ${host}`;
- }
- function safeDecodeUrl(url: string): string {
- try {
- return decodeURI(url);
- } catch {
- return url;
- }
- }
- function getComparisonRows(data: unknown): PriceComparisonRow[] | undefined {
- if (!data || typeof data !== "object" || !("comparisonRows" in data)) {
- return undefined;
- }
- const rows = (data as { comparisonRows?: unknown }).comparisonRows;
- if (!Array.isArray(rows) || rows.length === 0) {
- return undefined;
- }
- return rows
- .filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object")
- .map((row) => ({
- platform: String(row.platform ?? ""),
- productName: String(row.productName ?? ""),
- price: String(row.price ?? ""),
- unitPrice: row.unitPrice ? String(row.unitPrice) : undefined,
- status: String(row.status ?? ""),
- source: String(row.source ?? ""),
- note: String(row.note ?? ""),
- sourceUrl: row.sourceUrl ? String(row.sourceUrl) : undefined,
- evidence: row.evidence ? String(row.evidence) : undefined
- }));
- }
- function SettingsView(props: {
- config: AppConfig;
- setConfig: (config: AppConfig) => void;
- saveConfig: (config: AppConfig) => Promise<void>;
- login: (request: { username: string; password: string; tenantCode: string; captchaId: string; captchaCode: string }) => Promise<void>;
- logout: () => Promise<void>;
- busy: boolean;
- session: SessionStatus;
- feedback?: Feedback;
- focusTick: number;
- }) {
- const draft = props.config;
- const setDraft = props.setConfig;
- const [username, setUsername] = useState("");
- const [password, setPassword] = useState("");
- const [tenantCode, setTenantCode] = useState(props.config.tenantCode || "zswl");
- const [captchaCode, setCaptchaCode] = useState("");
- const [captcha, setCaptcha] = useState<CaptchaResult>();
- const [captchaError, setCaptchaError] = useState("");
- const [mcpSnapshot, setMcpSnapshot] = useState<McpRuntimeConfigSnapshot>();
- const [mcpError, setMcpError] = useState("");
- const loginPanelRef = useRef<HTMLFormElement>(null);
- const usernameInputRef = useRef<HTMLInputElement>(null);
- useEffect(() => setTenantCode(props.config.tenantCode || "zswl"), [props.config.tenantCode]);
- useEffect(() => {
- void refreshCaptcha();
- void refreshMcpSnapshot();
- }, []);
- useEffect(() => {
- if (props.focusTick === 0) {
- return;
- }
- loginPanelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
- window.setTimeout(() => usernameInputRef.current?.focus(), 120);
- }, [props.focusTick]);
- function submitLogin(event: FormEvent) {
- event.preventDefault();
- if (!captcha?.captchaId) {
- setCaptchaError("请先刷新验证码");
- return;
- }
- if (!captchaCode.trim()) {
- setCaptchaError("请输入验证码");
- return;
- }
- setCaptchaError("");
- void props.login({
- username,
- password,
- tenantCode,
- captchaId: captcha.captchaId,
- captchaCode: captchaCode.trim()
- }).finally(() => {
- setCaptchaCode("");
- void refreshCaptcha();
- });
- }
- async function refreshCaptcha() {
- setCaptchaError("");
- try {
- const nextCaptcha = await window.smqjhAgent.getCaptcha();
- setCaptcha(nextCaptcha);
- } catch (error) {
- const message = friendlyError(error);
- setCaptchaError(message || "验证码获取失败");
- }
- }
- async function refreshMcpSnapshot() {
- setMcpError("");
- try {
- setMcpSnapshot(await window.smqjhAgent.getMcpConfig());
- } catch (error) {
- const message = friendlyError(error);
- setMcpError(message || "MCP 配置状态读取失败");
- setMcpSnapshot(undefined);
- }
- }
- return (
- <section className="settings-grid">
- <form className="panel form-panel" onSubmit={(event) => {
- event.preventDefault();
- void props.saveConfig(draft);
- }}>
- <div className="panel-title">
- <h2>连接配置</h2>
- <button className="primary-button" type="submit">
- <Save size={16} />
- <span>保存</span>
- </button>
- </div>
- <label className="field">
- <span>环境名</span>
- <input value={draft.environmentName} onChange={(event) => setDraft({ ...draft, environmentName: event.target.value })} />
- </label>
- <label className="field">
- <span>网关地址</span>
- <input value={draft.baseUrl} onChange={(event) => setDraft({ ...draft, baseUrl: event.target.value })} />
- </label>
- <label className="field">
- <span>Token 路径</span>
- <input value={draft.auth.tokenPath} onChange={(event) => setDraft({ ...draft, auth: { ...draft.auth, tokenPath: event.target.value } })} />
- </label>
- <div className="two-col">
- <label className="field">
- <span>Client ID</span>
- <input value={draft.auth.clientId} onChange={(event) => setDraft({ ...draft, auth: { ...draft.auth, clientId: event.target.value } })} />
- </label>
- <label className="field">
- <span>Client Secret</span>
- <input value={draft.auth.clientSecret} onChange={(event) => setDraft({ ...draft, auth: { ...draft.auth, clientSecret: event.target.value } })} />
- </label>
- </div>
- <div className="two-col">
- <label className="field">
- <span>默认租户编码</span>
- <input value={draft.tenantCode} onChange={(event) => setDraft({ ...draft, tenantCode: event.target.value })} />
- </label>
- <label className="field">
- <span>租户域</span>
- <input value={draft.tenantDomain} onChange={(event) => setDraft({ ...draft, tenantDomain: event.target.value })} />
- </label>
- </div>
- <div className="two-col">
- <label className="field">
- <span>超时 ms</span>
- <input type="number" value={draft.requestTimeoutMs} onChange={(event) => setDraft({ ...draft, requestTimeoutMs: Number(event.target.value) })} />
- </label>
- <label className="toggle">
- <input type="checkbox" checked={draft.dryRun} onChange={(event) => setDraft({ ...draft, dryRun: event.target.checked })} />
- <span>预演模式</span>
- </label>
- </div>
- <div className="subsection-title">MCP 服务</div>
- <p className="scope-note">数据库连接、只读查询和业务工具由 MCP 托管,桌面端只保存 MCP 连接地址;敏感信息不在前端配置页展示。</p>
- <label className="toggle compact-toggle">
- <input
- type="checkbox"
- checked={draft.mcp.enabled}
- onChange={(event) => setDraft({ ...draft, mcp: { ...draft.mcp, enabled: event.target.checked } })}
- />
- <span>启用 MCP 工具调用</span>
- </label>
- <div className="two-col">
- <label className="field">
- <span>MCP 地址</span>
- <input value={draft.mcp.host} onChange={(event) => setDraft({ ...draft, mcp: { ...draft.mcp, host: event.target.value } })} />
- </label>
- <label className="field">
- <span>端口</span>
- <input type="number" value={draft.mcp.port} onChange={(event) => setDraft({ ...draft, mcp: { ...draft.mcp, port: Number(event.target.value) } })} />
- </label>
- </div>
- <label className="field">
- <span>MCP 路径</span>
- <input value={draft.mcp.path} onChange={(event) => setDraft({ ...draft, mcp: { ...draft.mcp, path: event.target.value } })} />
- </label>
- <label className="field">
- <span>MCP Token</span>
- <input
- type="password"
- value={draft.mcp.authToken}
- autoComplete="off"
- placeholder="未配置时本机直连"
- onChange={(event) => setDraft({ ...draft, mcp: { ...draft.mcp, authToken: event.target.value } })}
- onBlur={() => void props.saveConfig(draft)}
- />
- </label>
- <div className="mcp-status-card">
- <div className="mcp-status-header">
- <strong>MCP 托管配置状态</strong>
- <button className="secondary-button" type="button" onClick={() => void refreshMcpSnapshot()}>
- <RefreshCcw size={15} />
- <span>刷新</span>
- </button>
- </div>
- {mcpError ? <div className="feedback error">{mcpError}</div> : <McpConfigSummary snapshot={mcpSnapshot} />}
- </div>
- <div className="subsection-title">DeepSeek 智能体</div>
- <p className="scope-note">DeepSeek 已迁移到 MCP 侧托管。桌面端不再保存或展示 API Key,模型和推理强度请在 MCP 本地配置或环境变量中维护。</p>
- </form>
- <form className="panel form-panel" ref={loginPanelRef} onSubmit={submitLogin}>
- <div className="panel-title">
- <h2>后台登录</h2>
- <StatusPill ok={props.session.authenticated} label={props.session.authenticated ? "已登录" : "未登录"} />
- </div>
- {props.session.authenticated ? (
- <div className="logged-in-actions">
- <button className="secondary-button" type="button" onClick={() => void props.logout()}>
- <UserRound size={16} />
- <span>退出登录</span>
- </button>
- </div>
- ) : (
- <>
- {props.feedback && <div className={`feedback ${props.feedback.type}`}>{props.feedback.text}</div>}
- {captchaError && <div className="feedback error">{captchaError}</div>}
- <label className="field">
- <span>租户编码</span>
- <input value={tenantCode} onChange={(event) => setTenantCode(event.target.value)} />
- </label>
- <label className="field">
- <span>账号</span>
- <input ref={usernameInputRef} value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" />
- </label>
- <label className="field">
- <span>密码</span>
- <input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" />
- </label>
- <label className="field">
- <span>验证码</span>
- <div className="captcha-row">
- <input value={captchaCode} onChange={(event) => setCaptchaCode(event.target.value)} />
- <button className="captcha-button" type="button" onClick={() => void refreshCaptcha()} title="刷新验证码">
- {captcha?.captchaBase64 ? <img src={captcha.captchaBase64} alt="验证码" /> : "刷新"}
- </button>
- </div>
- </label>
- <div className="action-row">
- <button className="primary-button" disabled={props.busy} type="submit">
- <KeyRound size={16} />
- <span>{props.busy ? "登录中" : "登录"}</span>
- </button>
- </div>
- </>
- )}
- </form>
- </section>
- );
- }
- function McpConfigSummary({ snapshot }: { snapshot?: McpRuntimeConfigSnapshot }) {
- if (!snapshot) {
- return <div className="mcp-empty">尚未读取 MCP 配置状态</div>;
- }
- const db = snapshot.database ?? {};
- const ai = snapshot.deepSeek ?? {};
- const mcp = snapshot.mcp ?? {};
- return (
- <div className="mcp-summary-grid">
- <ConfigSummaryItem label="运行环境" value={stringValue(snapshot.environmentName) || "-"} />
- <ConfigSummaryItem label="网关" value={stringValue(snapshot.baseUrl) || "-"} />
- <ConfigSummaryItem label="MCP 地址" value={`${stringValue(mcp.host) || "127.0.0.1"}:${stringValue(mcp.port) || "8765"}${stringValue(mcp.path) || "/mcp"}`} />
- <ConfigSummaryItem label="MCP Token" value={truthyValue(mcp.tokenConfigured) ? "已配置" : "未配置"} />
- <ConfigSummaryItem label="数据库" value={truthyValue(db.enabled) ? "已启用" : "未启用"} tone={truthyValue(db.enabled) ? "ok" : "warn"} />
- <ConfigSummaryItem label="数据库名" value={stringValue(db.database) || "-"} />
- <ConfigSummaryItem label="数据库地址" value={`${stringValue(db.host) || "-"}:${stringValue(db.port) || "3306"}`} />
- <ConfigSummaryItem label="数据库账号" value={stringValue(db.username) || "-"} />
- <ConfigSummaryItem label="DeepSeek" value={truthyValue(ai.enabled) ? "已启用" : "未启用"} tone={truthyValue(ai.enabled) ? "ok" : "warn"} />
- <ConfigSummaryItem label="模型" value={stringValue(ai.model) || "-"} />
- <ConfigSummaryItem label="API Key" value={truthyValue(ai.apiKeyConfigured) ? "已配置" : "未配置"} tone={truthyValue(ai.apiKeyConfigured) ? "ok" : "warn"} />
- <ConfigSummaryItem label="配置来源" value={stringValue(snapshot.desktopConfigPath) || "MCP 本地配置"} wide />
- </div>
- );
- }
- function ConfigSummaryItem({ label, value, tone = "muted", wide = false }: { label: string; value: string; tone?: "ok" | "warn" | "muted"; wide?: boolean }) {
- return (
- <div className={`mcp-summary-item ${wide ? "wide" : ""}`}>
- <span>{label}</span>
- <strong className={tone}>{value}</strong>
- </div>
- );
- }
- function stringValue(value: unknown): string {
- return value === undefined || value === null ? "" : String(value);
- }
- function truthyValue(value: unknown): boolean {
- return value === true || value === "true" || value === "1" || value === 1;
- }
- function LogsView({ logs, clear }: { logs: LogEntry[]; clear: () => Promise<void> }) {
- return (
- <section className="panel logs-panel">
- <div className="panel-title">
- <h2>日志</h2>
- <button className="secondary-button" onClick={() => void clear()}>
- <Trash2 size={16} />
- <span>清空</span>
- </button>
- </div>
- <div className="logs-list">
- {logs.length === 0 ? <span className="empty">暂无日志</span> : logs.map((log) => <LogLine key={log.id} log={log} />)}
- </div>
- </section>
- );
- }
- function LogLine({ log }: { log: LogEntry }) {
- return (
- <div className={`log-line ${log.level}`}>
- <time>{new Date(log.timestamp).toLocaleString()}</time>
- <span>{log.level}</span>
- <strong>{log.message}</strong>
- </div>
- );
- }
|