App.tsx 88 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433
  1. import {
  2. AlertTriangle,
  3. Bot,
  4. ClipboardList,
  5. Database,
  6. FileSpreadsheet,
  7. FolderOpen,
  8. KeyRound,
  9. Play,
  10. RefreshCcw,
  11. Save,
  12. Send,
  13. Settings,
  14. ShieldCheck,
  15. Terminal,
  16. Trash2,
  17. UserRound,
  18. Wifi
  19. } from "lucide-react";
  20. import { Component, type ErrorInfo, type ReactNode } from "react";
  21. import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
  22. import type {
  23. AgentTask,
  24. AppConfig,
  25. AssistantResultTable,
  26. CaptchaResult,
  27. DashboardSnapshot,
  28. LogEntry,
  29. McpRuntimeConfigSnapshot,
  30. PriceComparisonExportResult,
  31. PriceComparisonRow,
  32. SessionStatus,
  33. TableExportResult,
  34. TaskRunResult
  35. } from "../shared/types";
  36. import logoUrl from "./assets/logo.png";
  37. type View = "assistant" | "tasks" | "settings" | "logs";
  38. interface ChatMessage {
  39. id: string;
  40. role: "user" | "assistant";
  41. text: string;
  42. taskIds?: string[];
  43. taskParams?: Record<string, Record<string, string>>;
  44. comparisonRows?: PriceComparisonRow[];
  45. orderStats?: OrderStatsTableData;
  46. mcpTables?: AssistantResultTable[];
  47. attachment?: ChatAttachment;
  48. }
  49. interface OrderStatsTableData {
  50. title: string;
  51. rows: OrderStatRow[];
  52. }
  53. interface OrderStatRow {
  54. metric: string;
  55. value: string;
  56. note: string;
  57. evidence: string;
  58. }
  59. interface ChatAttachment {
  60. kind: "excel";
  61. fileName: string;
  62. filePath: string;
  63. rowCount: number;
  64. tableCount?: number;
  65. }
  66. interface Feedback {
  67. type: "success" | "error" | "info";
  68. text: string;
  69. }
  70. interface ErrorBoundaryState {
  71. error?: Error;
  72. }
  73. const categoryNames: Record<AgentTask["category"], string> = {
  74. system: "系统",
  75. member: "会员",
  76. points: "积分",
  77. coupon: "券",
  78. order: "订单",
  79. product: "商品",
  80. ops: "运维"
  81. };
  82. export class AppErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundaryState> {
  83. state: ErrorBoundaryState = {};
  84. static getDerivedStateFromError(error: Error): ErrorBoundaryState {
  85. return { error };
  86. }
  87. componentDidCatch(error: Error, info: ErrorInfo) {
  88. console.error("Renderer crashed", error, info.componentStack);
  89. }
  90. render() {
  91. if (this.state.error) {
  92. return (
  93. <div className="error-screen">
  94. <img src={logoUrl} alt="市民请集合" />
  95. <h1>界面渲染异常</h1>
  96. <p>{this.state.error.message || "当前页面遇到异常,已拦截白屏。"}</p>
  97. <button className="primary-button" type="button" onClick={() => window.location.reload()}>
  98. <RefreshCcw size={16} />
  99. <span>重新加载</span>
  100. </button>
  101. </div>
  102. );
  103. }
  104. return this.props.children;
  105. }
  106. }
  107. export function App() {
  108. const [activeView, setActiveView] = useState<View>("assistant");
  109. const [dashboard, setDashboard] = useState<DashboardSnapshot>();
  110. const [config, setConfig] = useState<AppConfig>();
  111. const [configDraft, setConfigDraft] = useState<AppConfig>();
  112. const [session, setSession] = useState<SessionStatus>({ authenticated: false });
  113. const [tasks, setTasks] = useState<AgentTask[]>([]);
  114. const [logs, setLogs] = useState<LogEntry[]>([]);
  115. const [selectedTaskId, setSelectedTaskId] = useState("product.price.compare");
  116. const [params, setParams] = useState<Record<string, string>>({});
  117. const [lastResult, setLastResult] = useState<TaskRunResult>();
  118. const [busy, setBusy] = useState(false);
  119. const [chatBusy, setChatBusy] = useState(false);
  120. const [authFeedback, setAuthFeedback] = useState<Feedback>();
  121. const [loginFocusTick, setLoginFocusTick] = useState(0);
  122. const [prompt, setPrompt] = useState("");
  123. const [messages, setMessages] = useState<ChatMessage[]>([
  124. {
  125. id: crypto.randomUUID(),
  126. role: "assistant",
  127. text: "你好,我是市民请集合智能助手。你可以直接说要查会员、导出数据、处理积分或记录一个管理员操作需求,我会先给出可执行动作。"
  128. }
  129. ]);
  130. const selectedTask = useMemo(
  131. () => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0],
  132. [selectedTaskId, tasks]
  133. );
  134. async function refresh() {
  135. const [nextDashboard, nextConfig, nextTasks, nextLogs, nextSession] = await Promise.all([
  136. window.smqjhAgent.getDashboard(),
  137. window.smqjhAgent.getConfig(),
  138. window.smqjhAgent.listTasks(),
  139. window.smqjhAgent.listLogs(),
  140. window.smqjhAgent.getSession()
  141. ]);
  142. setDashboard(nextDashboard);
  143. setConfig(nextConfig);
  144. setConfigDraft((current) => current ?? nextConfig);
  145. setTasks(nextTasks);
  146. setLogs(nextLogs);
  147. setSession(nextSession);
  148. }
  149. useEffect(() => {
  150. void refresh();
  151. return window.smqjhAgent.onLog((entry) => setLogs((current) => [entry, ...current].slice(0, 300)));
  152. }, []);
  153. async function runTask() {
  154. if (!selectedTask) {
  155. return;
  156. }
  157. setBusy(true);
  158. try {
  159. const result = await window.smqjhAgent.runTask({ taskId: selectedTask.id, params });
  160. setLastResult(result);
  161. await refresh();
  162. } finally {
  163. setBusy(false);
  164. }
  165. }
  166. async function saveConfig(nextConfig: AppConfig) {
  167. const saved = await window.smqjhAgent.saveConfig(nextConfig);
  168. setConfig(saved);
  169. setConfigDraft(saved);
  170. await refresh();
  171. }
  172. async function login(request: { username: string; password: string; tenantCode: string; captchaId: string; captchaCode: string }) {
  173. setBusy(true);
  174. setAuthFeedback({ type: "info", text: "正在登录..." });
  175. try {
  176. const nextSession = await window.smqjhAgent.login(request);
  177. setSession(nextSession);
  178. setAuthFeedback({ type: "success", text: "登录成功" });
  179. await refresh();
  180. } catch (error) {
  181. const message = friendlyError(error);
  182. setAuthFeedback({ type: "error", text: message || "登录失败" });
  183. } finally {
  184. setBusy(false);
  185. }
  186. }
  187. async function resolveProductPriceParams(text: string, currentMessages: ChatMessage[]): Promise<Record<string, string> | undefined> {
  188. const localParams = buildProductPriceParams(text, currentMessages);
  189. if (!shouldAskDeepSeekForProductIntent(text, currentMessages)) {
  190. return localParams;
  191. }
  192. try {
  193. const result = await window.smqjhAgent.extractIntent({
  194. message: text,
  195. history: currentMessages.slice(-8).map((message) => ({
  196. role: message.role,
  197. content: message.text
  198. })),
  199. authenticated: session.authenticated,
  200. username: session.username,
  201. environmentName: config?.environmentName ?? dashboard?.config.environmentName ?? "test-gateway",
  202. baseUrl: config?.baseUrl ?? dashboard?.config.baseUrl ?? "http://192.168.1.242:8080",
  203. availableTasks: tasks.map(({ id, title, category, description, dangerLevel }) => ({
  204. id,
  205. title,
  206. category,
  207. description,
  208. dangerLevel
  209. }))
  210. });
  211. const params = normalizeProductIntentParams(result.params, text, currentMessages);
  212. if (result.taskId === "product.price.compare" && result.confidence >= 0.5 && params?.productKeyword) {
  213. return params;
  214. }
  215. return undefined;
  216. } catch {
  217. return localParams;
  218. }
  219. }
  220. async function resolveDatabaseQueryParams(text: string, currentMessages: ChatMessage[]): Promise<Record<string, string> | undefined> {
  221. if (!config?.deepSeek.enabled && !dashboard?.config.deepSeek.enabled) {
  222. return undefined;
  223. }
  224. try {
  225. const result = await window.smqjhAgent.extractIntent({
  226. message: text,
  227. history: currentMessages.slice(-8).map((message) => ({
  228. role: message.role,
  229. content: message.text
  230. })),
  231. authenticated: session.authenticated,
  232. username: session.username,
  233. environmentName: config?.environmentName ?? dashboard?.config.environmentName ?? "test-gateway",
  234. baseUrl: config?.baseUrl ?? dashboard?.config.baseUrl ?? "http://192.168.1.242:8080",
  235. availableTasks: tasks.map(({ id, title, category, description, dangerLevel }) => ({
  236. id,
  237. title,
  238. category,
  239. description,
  240. dangerLevel
  241. }))
  242. });
  243. if (result.taskId !== "database.readonly.query" || result.confidence < 0.55) {
  244. return undefined;
  245. }
  246. const sql = (result.params?.sql ?? "").trim();
  247. if (!sql) {
  248. return undefined;
  249. }
  250. return {
  251. question: result.params?.question?.trim() || text,
  252. sql
  253. };
  254. } catch {
  255. return undefined;
  256. }
  257. }
  258. async function sendPrompt(event: FormEvent) {
  259. event.preventDefault();
  260. const text = prompt.trim();
  261. if (!text || chatBusy) {
  262. return;
  263. }
  264. const userMessage: ChatMessage = { id: crypto.randomUUID(), role: "user", text };
  265. setPrompt("");
  266. setChatBusy(true);
  267. const rowsForExport = isComparisonExportIntent(text) ? getLatestComparisonRows(messages) ?? getComparisonRows(lastResult?.data) : undefined;
  268. const tablesForExport = isComparisonExportIntent(text) ? getLatestMcpTables(messages) : undefined;
  269. if (rowsForExport) {
  270. const actionMessage: ChatMessage = {
  271. id: crypto.randomUUID(),
  272. role: "assistant",
  273. text: "收到,我正在把上一轮价格对比结果整理成 Excel 文件。"
  274. };
  275. setMessages((current) => [...current, userMessage, actionMessage]);
  276. try {
  277. const exported = await window.smqjhAgent.exportPriceComparison({
  278. title: buildComparisonExportTitle(rowsForExport),
  279. rows: rowsForExport
  280. });
  281. setMessages((current) =>
  282. current.map((message) =>
  283. message.id === actionMessage.id
  284. ? {
  285. ...message,
  286. text: formatExportResult(exported),
  287. attachment: {
  288. kind: "excel",
  289. fileName: exported.fileName,
  290. filePath: exported.filePath,
  291. rowCount: exported.rowCount
  292. }
  293. }
  294. : message
  295. )
  296. );
  297. } catch (error) {
  298. const message = friendlyError(error);
  299. setMessages((current) =>
  300. current.map((item) =>
  301. item.id === actionMessage.id
  302. ? {
  303. ...item,
  304. text: `Excel 导出失败:${message || "写入文件失败"}。上一轮表格数据还在当前会话里,可以稍后重试导出。`
  305. }
  306. : item
  307. )
  308. );
  309. } finally {
  310. setChatBusy(false);
  311. }
  312. return;
  313. }
  314. if (tablesForExport) {
  315. const actionMessage: ChatMessage = {
  316. id: crypto.randomUUID(),
  317. role: "assistant",
  318. text: "收到,我正在把上一轮表格结果整理成 Excel 文件。"
  319. };
  320. setMessages((current) => [...current, userMessage, actionMessage]);
  321. try {
  322. const exported = await window.smqjhAgent.exportTables({
  323. title: buildTableExportTitle(tablesForExport),
  324. tables: tablesForExport
  325. });
  326. setMessages((current) =>
  327. current.map((message) =>
  328. message.id === actionMessage.id
  329. ? {
  330. ...message,
  331. text: formatTableExportResult(exported),
  332. attachment: {
  333. kind: "excel",
  334. fileName: exported.fileName,
  335. filePath: exported.filePath,
  336. rowCount: exported.rowCount,
  337. tableCount: exported.tableCount
  338. }
  339. }
  340. : message
  341. )
  342. );
  343. } catch (error) {
  344. const message = friendlyError(error);
  345. setMessages((current) =>
  346. current.map((item) =>
  347. item.id === actionMessage.id
  348. ? {
  349. ...item,
  350. text: `Excel 导出失败:${message || "写入文件失败"}。上一轮表格数据还在当前会话里,可以稍后重试导出。`
  351. }
  352. : item
  353. )
  354. );
  355. } finally {
  356. setChatBusy(false);
  357. }
  358. return;
  359. }
  360. const latestOrderStats = isOrderStatsTableIntent(text) ? getLatestOrderStats(messages) : undefined;
  361. if (latestOrderStats) {
  362. setMessages((current) => [
  363. ...current,
  364. userMessage,
  365. {
  366. id: crypto.randomUUID(),
  367. role: "assistant",
  368. text: "已把上一轮订单统计整理成表格视图。",
  369. orderStats: latestOrderStats
  370. }
  371. ]);
  372. setChatBusy(false);
  373. return;
  374. }
  375. const thinkingMessage: ChatMessage = {
  376. id: crypto.randomUUID(),
  377. role: "assistant",
  378. text: "正在理解需求,并按需要调用 MCP/本地工具..."
  379. };
  380. const requestMessages = [...messages, userMessage].slice(-12).map((message) => ({
  381. role: message.role,
  382. content: message.text
  383. }));
  384. setMessages((current) => [...current, userMessage, thinkingMessage]);
  385. try {
  386. const result = await window.smqjhAgent.runAssistant({
  387. message: text,
  388. history: requestMessages,
  389. authenticated: session.authenticated,
  390. username: session.username,
  391. environmentName: config?.environmentName ?? dashboard?.config.environmentName ?? "test-gateway",
  392. baseUrl: config?.baseUrl ?? dashboard?.config.baseUrl ?? "http://192.168.1.242:8080"
  393. });
  394. const taskIds = result.toolCalls
  395. .map((call) => call.name)
  396. .filter((name) => tasks.some((task) => task.id === name));
  397. const taskParams = Object.fromEntries(
  398. result.toolCalls
  399. .filter((call) => tasks.some((task) => task.id === call.name))
  400. .map((call) => [call.name, stringifyParams(call.arguments)])
  401. );
  402. await refresh();
  403. setMessages((current) =>
  404. current.map((message) =>
  405. message.id === thinkingMessage.id
  406. ? {
  407. ...message,
  408. text: result.content || "已完成处理,结果如下。",
  409. taskIds: taskIds.length ? taskIds : undefined,
  410. taskParams,
  411. comparisonRows: result.comparisonRows?.length ? result.comparisonRows : undefined,
  412. mcpTables: result.tables?.length ? result.tables : undefined
  413. }
  414. : message
  415. )
  416. );
  417. } catch (error) {
  418. const message = friendlyError(error);
  419. setMessages((current) =>
  420. current.map((item) =>
  421. item.id === thinkingMessage.id
  422. ? {
  423. ...item,
  424. text: `智能体执行失败:${message || "调用失败"}。\n\n我没有继续走前端规则兜底,避免再次出现意图被硬匹配带偏。请检查 MCP 服务、MCP 托管的 DeepSeek 配置和数据库只读配置后重试。`
  425. }
  426. : item
  427. )
  428. );
  429. } finally {
  430. setChatBusy(false);
  431. }
  432. return;
  433. const orderCountParams = buildOrderCountParams(text, messages);
  434. if (orderCountParams) {
  435. const actionMessage: ChatMessage = {
  436. id: crypto.randomUUID(),
  437. role: "assistant",
  438. text: "收到,我直接走数据库只读查询订单数量,并排除逻辑删除订单。"
  439. };
  440. setMessages((current) => [...current, userMessage, actionMessage]);
  441. setSelectedTaskId("order.count.query");
  442. setParams(orderCountParams);
  443. setLastResult(undefined);
  444. try {
  445. const result = await window.smqjhAgent.runTask({
  446. taskId: "order.count.query",
  447. params: orderCountParams
  448. });
  449. setLastResult(result);
  450. await refresh();
  451. setMessages((current) =>
  452. current.map((message) =>
  453. message.id === actionMessage.id
  454. ? {
  455. ...message,
  456. text: formatOrderCountResult(result, orderCountParams),
  457. orderStats: buildOrderStatsTable(result, orderCountParams)
  458. }
  459. : message
  460. )
  461. );
  462. } catch (error) {
  463. const message = friendlyError(error);
  464. setMessages((current) =>
  465. current.map((item) =>
  466. item.id === actionMessage.id
  467. ? {
  468. ...item,
  469. text: `订单数量查询启动失败:${message || "调用失败"}。`
  470. }
  471. : item
  472. )
  473. );
  474. } finally {
  475. setChatBusy(false);
  476. }
  477. return;
  478. }
  479. const productLookupParams = await resolveProductLookupParams(text, messages);
  480. if (productLookupParams) {
  481. const actionMessage: ChatMessage = {
  482. id: crypto.randomUUID(),
  483. role: "assistant",
  484. text: `我判断这个问题需要查商品库,正在按“${productLookupParams.productKeyword}”执行数据库只读查询。`,
  485. taskIds: ["product.lookup.summary"],
  486. taskParams: {
  487. "product.lookup.summary": productLookupParams
  488. }
  489. };
  490. setMessages((current) => [...current, userMessage, actionMessage]);
  491. setSelectedTaskId("product.lookup.summary");
  492. setParams(productLookupParams);
  493. setLastResult(undefined);
  494. try {
  495. const result = await window.smqjhAgent.runTask({
  496. taskId: "product.lookup.summary",
  497. params: productLookupParams
  498. });
  499. setLastResult(result);
  500. await refresh();
  501. setMessages((current) =>
  502. current.map((message) =>
  503. message.id === actionMessage.id
  504. ? {
  505. ...message,
  506. text: formatProductLookupResult(result, productLookupParams)
  507. }
  508. : message
  509. )
  510. );
  511. } catch (error) {
  512. const message = friendlyError(error);
  513. setMessages((current) =>
  514. current.map((item) =>
  515. item.id === actionMessage.id
  516. ? {
  517. ...item,
  518. text: `商品资料查询启动失败:${message || "调用失败"}。`
  519. }
  520. : item
  521. )
  522. );
  523. } finally {
  524. setChatBusy(false);
  525. }
  526. return;
  527. }
  528. const productPriceParams = await resolveProductPriceParams(text, messages);
  529. if (productPriceParams) {
  530. const actionMessage: ChatMessage = {
  531. id: crypto.randomUUID(),
  532. role: "assistant",
  533. text: `DeepSeek 已识别到商品和比价口径,我按“${productPriceParams.productKeyword}”直接发起商品价格对比调研:先用数据库只读查询 smqjh 系统价,再后台采集苏宁易购和慢慢买公开价格线索。`,
  534. taskIds: ["product.price.compare"],
  535. taskParams: {
  536. "product.price.compare": productPriceParams
  537. }
  538. };
  539. setMessages((current) => [...current, userMessage, actionMessage]);
  540. setSelectedTaskId("product.price.compare");
  541. setParams(productPriceParams);
  542. setLastResult(undefined);
  543. try {
  544. const result = await window.smqjhAgent.runTask({
  545. taskId: "product.price.compare",
  546. params: productPriceParams
  547. });
  548. setLastResult(result);
  549. await refresh();
  550. setMessages((current) =>
  551. current.map((message) =>
  552. message.id === actionMessage.id
  553. ? {
  554. ...message,
  555. text: formatProductPriceResult(result, productPriceParams),
  556. comparisonRows: getComparisonRows(result.data)
  557. }
  558. : message
  559. )
  560. );
  561. } catch (error) {
  562. const message = friendlyError(error);
  563. setMessages((current) =>
  564. current.map((item) =>
  565. item.id === actionMessage.id
  566. ? {
  567. ...item,
  568. text: `商品价格对比调研启动失败:${message || "调用失败"}。你也可以点击下方任务手动执行。`
  569. }
  570. : item
  571. )
  572. );
  573. } finally {
  574. setChatBusy(false);
  575. }
  576. return;
  577. }
  578. const databaseQueryParams = await resolveDatabaseQueryParams(text, messages);
  579. if (databaseQueryParams) {
  580. const actionMessage: ChatMessage = {
  581. id: crypto.randomUUID(),
  582. role: "assistant",
  583. text: "DeepSeek 判断这个问题需要查 smqjh 数据库,我会先做只读 SQL 安全校验,再直接执行并返回表格。",
  584. taskIds: ["database.readonly.query"],
  585. taskParams: {
  586. "database.readonly.query": databaseQueryParams
  587. }
  588. };
  589. setMessages((current) => [...current, userMessage, actionMessage]);
  590. setSelectedTaskId("database.readonly.query");
  591. setParams(databaseQueryParams);
  592. setLastResult(undefined);
  593. try {
  594. const result = await window.smqjhAgent.runTask({
  595. taskId: "database.readonly.query",
  596. params: databaseQueryParams
  597. });
  598. setLastResult(result);
  599. await refresh();
  600. setMessages((current) =>
  601. current.map((message) =>
  602. message.id === actionMessage.id
  603. ? {
  604. ...message,
  605. text: formatReadOnlyQueryResult(result, databaseQueryParams)
  606. }
  607. : message
  608. )
  609. );
  610. } catch (error) {
  611. const message = friendlyError(error);
  612. setMessages((current) =>
  613. current.map((item) =>
  614. item.id === actionMessage.id
  615. ? {
  616. ...item,
  617. text: `数据库智能只读查询启动失败:${message || "调用失败"}。`
  618. }
  619. : item
  620. )
  621. );
  622. } finally {
  623. setChatBusy(false);
  624. }
  625. return;
  626. }
  627. const localReply = buildAssistantReply(text, tasks, session);
  628. const legacyThinkingMessage: ChatMessage = {
  629. id: crypto.randomUUID(),
  630. role: "assistant",
  631. text: "正在分析这个需求..."
  632. };
  633. const legacyRequestMessages = [...messages, userMessage].slice(-12).map((message) => ({
  634. role: message.role,
  635. content: message.text
  636. }));
  637. setMessages((current) => [...current, userMessage, legacyThinkingMessage]);
  638. try {
  639. const result = await window.smqjhAgent.chat({
  640. messages: legacyRequestMessages,
  641. authenticated: session.authenticated,
  642. username: session.username,
  643. environmentName: config?.environmentName ?? dashboard?.config.environmentName ?? "test-gateway",
  644. baseUrl: config?.baseUrl ?? dashboard?.config.baseUrl ?? "http://192.168.1.242:8080",
  645. availableTasks: tasks.map(({ id, title, category, description, dangerLevel }) => ({
  646. id,
  647. title,
  648. category,
  649. description,
  650. dangerLevel
  651. }))
  652. });
  653. const taskIds = matchTaskIds(text, tasks);
  654. setMessages((current) =>
  655. current.map((message) =>
  656. message.id === legacyThinkingMessage.id
  657. ? { ...message, text: result.content, taskIds: taskIds.length > 0 ? taskIds : localReply.taskIds }
  658. : message
  659. )
  660. );
  661. } catch (error) {
  662. const message = friendlyError(error);
  663. setMessages((current) =>
  664. current.map((item) =>
  665. item.id === legacyThinkingMessage.id
  666. ? {
  667. ...item,
  668. text: `DeepSeek 暂时没有返回:${message || "调用失败"}\n\n${localReply.text}`,
  669. taskIds: localReply.taskIds
  670. }
  671. : item
  672. )
  673. );
  674. } finally {
  675. setChatBusy(false);
  676. }
  677. }
  678. function openTask(taskId: string, presetParams?: Record<string, string>) {
  679. setSelectedTaskId(taskId);
  680. setParams(presetParams ?? {});
  681. setLastResult(undefined);
  682. setActiveView("tasks");
  683. }
  684. function openLogin() {
  685. setActiveView("settings");
  686. setLoginFocusTick((value) => value + 1);
  687. }
  688. return (
  689. <div className="shell">
  690. <aside className="sidebar">
  691. <div className="brand">
  692. <img className="brand-logo" src={logoUrl} alt="市民请集合" />
  693. <div>
  694. <strong>市民请集合</strong>
  695. <span>智能助手</span>
  696. </div>
  697. </div>
  698. <nav className="nav">
  699. <NavButton active={activeView === "assistant"} icon={<Bot size={18} />} label="对话" onClick={() => setActiveView("assistant")} />
  700. <NavButton active={activeView === "tasks"} icon={<ClipboardList size={18} />} label="任务" onClick={() => setActiveView("tasks")} />
  701. <NavButton active={activeView === "settings"} icon={<Settings size={18} />} label="配置" onClick={() => setActiveView("settings")} />
  702. <NavButton active={activeView === "logs"} icon={<Terminal size={18} />} label="日志" onClick={() => setActiveView("logs")} />
  703. </nav>
  704. <button className="session-box" type="button" onClick={openLogin} title="打开后台登录">
  705. <StatusPill ok={session.authenticated} label={session.authenticated ? "已登录" : "未登录"} />
  706. <span>{session.username ?? "后台账号"}</span>
  707. </button>
  708. </aside>
  709. <main className="content">
  710. <header className="topbar">
  711. <div>
  712. <h1>{activeView === "assistant" ? "市民请集合智能助手" : activeView === "tasks" ? "任务中心" : activeView === "settings" ? "系统配置" : "运行日志"}</h1>
  713. <p>{config?.environmentName ?? "test-gateway"} · {config?.baseUrl ?? "http://192.168.1.242:8080"}</p>
  714. </div>
  715. <button className="icon-button" onClick={() => void refresh()} title="刷新">
  716. <RefreshCcw size={18} />
  717. </button>
  718. </header>
  719. {activeView === "assistant" && dashboard && (
  720. <AssistantView
  721. messages={messages}
  722. prompt={prompt}
  723. setPrompt={setPrompt}
  724. sendPrompt={sendPrompt}
  725. chatBusy={chatBusy}
  726. dashboard={dashboard}
  727. session={session}
  728. tasks={tasks}
  729. onPickTask={openTask}
  730. />
  731. )}
  732. {activeView === "tasks" && (
  733. <TasksView
  734. tasks={tasks}
  735. selectedTask={selectedTask}
  736. selectedTaskId={selectedTaskId}
  737. setSelectedTaskId={(taskId) => {
  738. setSelectedTaskId(taskId);
  739. setParams({});
  740. setLastResult(undefined);
  741. }}
  742. params={params}
  743. setParams={setParams}
  744. runTask={runTask}
  745. busy={busy}
  746. lastResult={lastResult}
  747. />
  748. )}
  749. {activeView === "settings" && config && (
  750. <SettingsView
  751. config={configDraft ?? config}
  752. setConfig={setConfigDraft}
  753. saveConfig={saveConfig}
  754. login={login}
  755. logout={async () => {
  756. setAuthFeedback(undefined);
  757. setSession(await window.smqjhAgent.logout());
  758. }}
  759. busy={busy}
  760. session={session}
  761. feedback={authFeedback}
  762. focusTick={loginFocusTick}
  763. />
  764. )}
  765. {activeView === "logs" && (
  766. <LogsView logs={logs} clear={async () => {
  767. await window.smqjhAgent.clearLogs();
  768. setLogs([]);
  769. }} />
  770. )}
  771. </main>
  772. </div>
  773. );
  774. }
  775. function matchTaskIds(text: string, tasks: AgentTask[]): string[] {
  776. const normalized = text.toLowerCase();
  777. const matchedIds: string[] = [];
  778. if (isOrderCountIntent(text)) {
  779. matchedIds.push("order.count.query");
  780. }
  781. if (isProductLookupIntent(text)) {
  782. matchedIds.push("product.lookup.summary");
  783. }
  784. if (isProductPriceIntent(text, "")) {
  785. matchedIds.push("product.price.compare");
  786. }
  787. if (text.includes("管理员") || text.includes("当前用户") || text.includes("当前账号") || normalized.includes("me")) {
  788. matchedIds.push("user.current");
  789. }
  790. if (text.includes("网关") || text.includes("连通") || text.includes("验证码") || normalized.includes("health")) {
  791. matchedIds.push("cloud.health");
  792. }
  793. const taskIds = matchedIds.filter((id, index) => matchedIds.indexOf(id) === index && tasks.some((task) => task.id === id));
  794. if (taskIds.length === 0) {
  795. taskIds.push(...["product.price.compare", "order.count.query", "cloud.health"].filter((id) => tasks.some((task) => task.id === id)));
  796. }
  797. return taskIds;
  798. }
  799. function buildAssistantReply(text: string, tasks: AgentTask[], session: SessionStatus): { text: string; taskIds: string[] } {
  800. const taskIds = matchTaskIds(text, tasks);
  801. const authText = session.authenticated ? "当前后台账号已登录。" : "当前还没有后台登录,涉及后台接口的动作会先要求登录。";
  802. return {
  803. taskIds,
  804. text: `${authText} 我理解这个需求可以先走 ${taskIds.length} 个动作:${taskIds.map((id) => tasks.find((task) => task.id === id)?.title ?? id).join("、")}。你可以点右侧动作进入参数确认。`
  805. };
  806. }
  807. function buildOrderCountParams(text: string, messages: ChatMessage[]): Record<string, string> | undefined {
  808. if (!isOrderCountIntent(text, messages)) {
  809. return undefined;
  810. }
  811. const mobile = extractMobile(text) || getLatestOrderMobile(messages);
  812. const dateRange = inferOrderDateRange(text);
  813. return {
  814. mobile: mobile ?? "",
  815. dateFrom: dateRange?.dateFrom ?? "",
  816. dateTo: dateRange?.dateTo ?? ""
  817. };
  818. }
  819. function isOrderCountIntent(text: string, messages: ChatMessage[] = []): boolean {
  820. const normalized = text.trim();
  821. if (!normalized) {
  822. return false;
  823. }
  824. const hasOrderWord = /订单|下单|订购|购买|买了|消费记录|交易记录|订单数|订单数量|多少单/.test(normalized);
  825. const hasAmountWord = /销售额|营业额|营收|金额|订单金额|实付|支付金额|收款|收入/.test(normalized);
  826. const hasCountWord = /多少|几|数量|总数|统计|计数|条|笔|单|次|下了|汇报|汇总/.test(normalized);
  827. const followsOrderContext = hasAmountWord && Boolean(getLatestOrderStats(messages));
  828. return (hasOrderWord && (hasCountWord || hasAmountWord)) || hasAmountWord || followsOrderContext;
  829. }
  830. function extractMobile(text: string): string | undefined {
  831. return text.match(/1\d{10}/)?.[0];
  832. }
  833. function getLatestOrderMobile(messages: ChatMessage[]): string | undefined {
  834. for (let index = messages.length - 1; index >= 0; index -= 1) {
  835. const mobile = extractMobile(messages[index].text);
  836. if (mobile) {
  837. return mobile;
  838. }
  839. }
  840. return undefined;
  841. }
  842. function isOrderStatsTableIntent(text: string): boolean {
  843. return /表格|表格视图|列表|列出来|整理一下|汇总一下/.test(text) && !isComparisonExportIntent(text);
  844. }
  845. function getLatestOrderStats(messages: ChatMessage[]): OrderStatsTableData | undefined {
  846. for (let index = messages.length - 1; index >= 0; index -= 1) {
  847. if (messages[index].orderStats?.rows.length) {
  848. return messages[index].orderStats;
  849. }
  850. }
  851. return undefined;
  852. }
  853. function inferOrderDateRange(text: string): { dateFrom: string; dateTo: string } | undefined {
  854. const today = startOfLocalDay(new Date());
  855. if (/今天|今日/.test(text)) {
  856. return { dateFrom: formatIsoDate(today), dateTo: formatIsoDate(addDays(today, 1)) };
  857. }
  858. if (/昨天|昨日/.test(text)) {
  859. const yesterday = addDays(today, -1);
  860. return { dateFrom: formatIsoDate(yesterday), dateTo: formatIsoDate(today) };
  861. }
  862. const recentDays = text.match(/近\s*(\d+)\s*天|最近\s*(\d+)\s*天/);
  863. if (recentDays) {
  864. const days = Number(recentDays[1] || recentDays[2]);
  865. if (Number.isFinite(days) && days > 0) {
  866. return { dateFrom: formatIsoDate(addDays(today, -days)), dateTo: formatIsoDate(addDays(today, 1)) };
  867. }
  868. }
  869. if (/近一年|最近一年|一年内|过去一年/.test(text)) {
  870. const from = new Date(today);
  871. from.setFullYear(from.getFullYear() - 1);
  872. return { dateFrom: formatIsoDate(from), dateTo: formatIsoDate(addDays(today, 1)) };
  873. }
  874. if (/本月|这个月|当月/.test(text)) {
  875. return { dateFrom: formatIsoDate(new Date(today.getFullYear(), today.getMonth(), 1)), dateTo: formatIsoDate(addDays(today, 1)) };
  876. }
  877. if (/今年|本年/.test(text)) {
  878. return { dateFrom: formatIsoDate(new Date(today.getFullYear(), 0, 1)), dateTo: formatIsoDate(addDays(today, 1)) };
  879. }
  880. if (/去年|上一年/.test(text)) {
  881. return { dateFrom: formatIsoDate(new Date(today.getFullYear() - 1, 0, 1)), dateTo: formatIsoDate(new Date(today.getFullYear(), 0, 1)) };
  882. }
  883. const explicitRange = text.match(/(\d{4}-\d{1,2}-\d{1,2}).{0,8}(?:到|至|-|~).{0,8}(\d{4}-\d{1,2}-\d{1,2})/);
  884. if (explicitRange) {
  885. return {
  886. dateFrom: normalizeDateText(explicitRange[1]),
  887. dateTo: formatIsoDate(addDays(parseLocalDate(normalizeDateText(explicitRange[2])), 1))
  888. };
  889. }
  890. return undefined;
  891. }
  892. function formatOrderCountResult(result: TaskRunResult, params: Record<string, string>): string {
  893. if (!result.success) {
  894. return `订单数量查询未完成:${result.message}`;
  895. }
  896. const data = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
  897. const scope = buildOrderScopeLabel(params);
  898. return `已通过 MySQL 只读查询${scope},订单总数:${numberText(data.totalCount)} 单,已支付销售额:${moneyText(data.paidAmount)}。下方已整理成表格视图。`;
  899. }
  900. function buildOrderStatsTable(result: TaskRunResult, params: Record<string, string>): OrderStatsTableData | undefined {
  901. if (!result.success || !result.data || typeof result.data !== "object") {
  902. return undefined;
  903. }
  904. const data = result.data as Record<string, unknown>;
  905. const scope = buildOrderScopeLabel(params);
  906. const evidence = String(data.evidence ?? "").trim();
  907. const rows: OrderStatRow[] = [
  908. {
  909. metric: "订单总数",
  910. value: `${numberText(data.totalCount)} 单`,
  911. note: "满足当前筛选条件且未逻辑删除的订单数量",
  912. evidence
  913. },
  914. {
  915. metric: "订单金额合计",
  916. value: moneyText(data.totalAmount),
  917. note: `金额口径:${String(data.amountField ?? "COALESCE(actual_total, total, order_money)")}`,
  918. evidence
  919. },
  920. {
  921. metric: "已支付销售额",
  922. value: moneyText(data.paidAmount),
  923. note: "is_payed = 1 的订单金额合计",
  924. evidence: "SUM(CASE WHEN is_payed = 1 THEN orderAmount ELSE 0 END)"
  925. },
  926. {
  927. metric: "未支付金额",
  928. value: moneyText(data.unpaidAmount),
  929. note: "is_payed = 0 或为空的订单金额合计",
  930. evidence: "SUM(CASE WHEN COALESCE(is_payed, 0) = 0 THEN orderAmount ELSE 0 END)"
  931. },
  932. {
  933. metric: "已完成金额",
  934. value: moneyText(data.completedAmount),
  935. note: "完成口径订单金额合计",
  936. evidence: "hb_order_status IN (80, 90, 100)"
  937. },
  938. {
  939. metric: "已取消金额",
  940. value: moneyText(data.canceledAmount),
  941. note: "取消口径订单金额合计",
  942. evidence: "hb_order_status IN (-1, 50, 60)"
  943. }
  944. ];
  945. if (params.mobile) {
  946. rows.push(
  947. {
  948. metric: "收货手机号匹配",
  949. value: `${numberText(data.consigneeMobileCount)} 单`,
  950. note: `consignee_mobile 匹配 ${params.mobile}`,
  951. evidence: `oms_order.consignee_mobile LIKE "%${params.mobile}%"`
  952. },
  953. {
  954. metric: "会员手机号匹配",
  955. value: `${numberText(data.memberMobileCount)} 单`,
  956. note: `会员手机号或权益账号手机号匹配 ${params.mobile}`,
  957. evidence: `sm_member.mobile / ums_member_account.mobile LIKE "%${params.mobile}%"`
  958. },
  959. {
  960. metric: "买家手机号匹配",
  961. value: `${numberText(data.buyerMobileCount)} 单`,
  962. note: `买家会员手机号匹配 ${params.mobile}`,
  963. evidence: `sm_member.mobile LIKE "%${params.mobile}%"`
  964. }
  965. );
  966. }
  967. rows.push(
  968. {
  969. metric: "已支付",
  970. value: `${numberText(data.paidCount)} 单`,
  971. note: "is_payed = 1",
  972. evidence: "COUNT DISTINCT order_id"
  973. },
  974. {
  975. metric: "未支付",
  976. value: `${numberText(data.unpaidCount)} 单`,
  977. note: "is_payed = 0 或为空",
  978. evidence: "COUNT DISTINCT order_id"
  979. },
  980. {
  981. metric: "已完成",
  982. value: `${numberText(data.completedCount)} 单`,
  983. note: "hb_order_status 属于完成口径",
  984. evidence: "hb_order_status IN (80, 90, 100)"
  985. },
  986. {
  987. metric: "已取消",
  988. value: `${numberText(data.canceledCount)} 单`,
  989. note: "hb_order_status 属于取消口径",
  990. evidence: "hb_order_status IN (-1, 50, 60)"
  991. }
  992. );
  993. const latestOrderTime = String(data.latestOrderTime ?? "").trim();
  994. if (latestOrderTime) {
  995. rows.push({
  996. metric: "最近下单时间",
  997. value: latestOrderTime,
  998. note: "当前筛选范围内 create_time 最大值",
  999. evidence: "MAX(oms_order.create_time)"
  1000. });
  1001. }
  1002. return {
  1003. title: `订单统计${scope}`,
  1004. rows
  1005. };
  1006. }
  1007. function buildOrderScopeLabel(params: Record<string, string>): string {
  1008. const parts: string[] = [];
  1009. if (params.mobile) {
  1010. parts.push(`手机号 ${params.mobile}`);
  1011. }
  1012. if (params.dateFrom && params.dateTo) {
  1013. parts.push(`${params.dateFrom} 至 ${formatIsoDate(addDays(parseLocalDate(params.dateTo), -1))}`);
  1014. } else if (params.dateFrom) {
  1015. parts.push(`${params.dateFrom} 起`);
  1016. } else if (params.dateTo) {
  1017. parts.push(`${formatIsoDate(addDays(parseLocalDate(params.dateTo), -1))} 前`);
  1018. }
  1019. return parts.length ? `(${parts.join(",")})` : "全部非删除订单";
  1020. }
  1021. function numberText(value: unknown): string {
  1022. const number = Number(value);
  1023. return Number.isFinite(number) ? String(number) : "0";
  1024. }
  1025. function moneyText(value: unknown): string {
  1026. const number = Number(value);
  1027. return Number.isFinite(number) ? `¥${number.toFixed(2)}` : "¥0.00";
  1028. }
  1029. function startOfLocalDay(date: Date): Date {
  1030. return new Date(date.getFullYear(), date.getMonth(), date.getDate());
  1031. }
  1032. function addDays(date: Date, days: number): Date {
  1033. const next = new Date(date);
  1034. next.setDate(next.getDate() + days);
  1035. return next;
  1036. }
  1037. function formatIsoDate(date: Date): string {
  1038. const year = date.getFullYear();
  1039. const month = String(date.getMonth() + 1).padStart(2, "0");
  1040. const day = String(date.getDate()).padStart(2, "0");
  1041. return `${year}-${month}-${day}`;
  1042. }
  1043. function normalizeDateText(value: string): string {
  1044. return value
  1045. .split("-")
  1046. .map((part, index) => (index === 0 ? part.padStart(4, "0") : part.padStart(2, "0")))
  1047. .join("-");
  1048. }
  1049. function parseLocalDate(value: string): Date {
  1050. const [year, month, day] = normalizeDateText(value).split("-").map(Number);
  1051. return new Date(year, month - 1, day);
  1052. }
  1053. async function resolveProductLookupParams(text: string, currentMessages: ChatMessage[]): Promise<Record<string, string> | undefined> {
  1054. return buildProductLookupParams(text, currentMessages);
  1055. }
  1056. function buildProductLookupParams(text: string, messages: ChatMessage[]): Record<string, string> | undefined {
  1057. if (!isProductLookupIntent(text)) {
  1058. return undefined;
  1059. }
  1060. const keyword = extractProductKeyword(text) || getLatestComparisonProduct(messages);
  1061. return keyword ? { productKeyword: keyword } : undefined;
  1062. }
  1063. function isProductLookupIntent(text: string): boolean {
  1064. if (isObviousNonProductTopic(text)) {
  1065. return false;
  1066. }
  1067. const hasProductContext = /商品|产品|sku|spu|商城|业务系统|系统|后台|smqjh/i.test(text);
  1068. const hasLookupWord = /描述|说明|详情|资料|信息|简介|规格|品牌|状态|叫什么|是什么|查一下|查询|查看/.test(text);
  1069. const keyword = extractProductKeyword(text);
  1070. return Boolean(keyword) && hasLookupWord && (hasProductContext || /ml|mL|ML|毫升|瓶|罐|盒|箱|可乐|饮料|牛奶|茶|水/.test(text));
  1071. }
  1072. function formatProductLookupResult(result: TaskRunResult, params: Record<string, string>): string {
  1073. if (!result.success) {
  1074. return `商品资料查询未完成:${result.message}。`;
  1075. }
  1076. const data = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
  1077. return [
  1078. `已通过 MySQL 只读查询“${params.productKeyword}”的商品资料。`,
  1079. "",
  1080. "| 项目 | 内容 |",
  1081. "| --- | --- |",
  1082. `| 商品名称 | ${escapeMarkdownCell(String(data.productName ?? ""))} |`,
  1083. `| 品牌 | ${escapeMarkdownCell(String(data.brandName ?? "未设置"))} |`,
  1084. `| 价格 | ${escapeMarkdownCell(String(data.price ?? "未获取"))} |`,
  1085. `| 状态 | ${escapeMarkdownCell(String(data.status ?? "未设置"))} |`,
  1086. `| 商品描述 | ${escapeMarkdownCell(String(data.description ?? "未读取到描述"))} |`,
  1087. `| 备注 | ${escapeMarkdownCell(String(data.note ?? ""))} |`,
  1088. `| 依据 | ${escapeMarkdownCell(String(data.evidence ?? ""))} |`
  1089. ].join("\n");
  1090. }
  1091. function escapeMarkdownCell(value: string): string {
  1092. return value
  1093. .replace(/\|/g, "\\|")
  1094. .replace(/\r?\n/g, " ")
  1095. .trim();
  1096. }
  1097. function formatReadOnlyQueryResult(result: TaskRunResult, params: Record<string, string>): string {
  1098. if (!result.success) {
  1099. const data = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
  1100. const reason = String(data.reason ?? result.message ?? "只读查询失败");
  1101. const sql = String(data.sql ?? params.sql ?? "").trim();
  1102. return [
  1103. `数据库智能只读查询未完成:${reason}`,
  1104. sql ? `依据 SQL:${sql}` : ""
  1105. ]
  1106. .filter(Boolean)
  1107. .join("\n\n");
  1108. }
  1109. const data = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
  1110. const columns = Array.isArray(data.columns) ? data.columns.map((column) => String(column)) : [];
  1111. const rows = Array.isArray(data.rows) ? data.rows.filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object") : [];
  1112. const executedSql = String(data.executedSql ?? params.sql ?? "").trim();
  1113. const rowCount = Number(data.rowCount ?? rows.length);
  1114. return [
  1115. `已根据你的问题执行数据库只读查询,共返回 ${Number.isFinite(rowCount) ? rowCount : rows.length} 行。`,
  1116. toMarkdownResultTable(columns, rows),
  1117. executedSql ? `依据 SQL:${executedSql}` : ""
  1118. ]
  1119. .filter(Boolean)
  1120. .join("\n\n");
  1121. }
  1122. function toMarkdownResultTable(columns: string[], rows: Record<string, unknown>[]): string {
  1123. if (columns.length === 0) {
  1124. return "| 结果 | 说明 |\n| --- | --- |\n| 无返回行 | 查询执行成功,但结果集为空 |";
  1125. }
  1126. const displayColumns = columns.slice(0, 12);
  1127. const overflowCount = Math.max(columns.length - displayColumns.length, 0);
  1128. const header = `| ${displayColumns.map(escapeMarkdownCell).join(" | ")}${overflowCount > 0 ? " | 其他列 |" : " |"}`;
  1129. const divider = `| ${displayColumns.map(() => "---").join(" | ")}${overflowCount > 0 ? " | --- |" : " |"}`;
  1130. const body = rows.slice(0, 100).map((row) => {
  1131. const cells = displayColumns.map((column) => escapeMarkdownCell(formatQueryCell(row[column])));
  1132. if (overflowCount > 0) {
  1133. cells.push(`还有 ${overflowCount} 列未展开`);
  1134. }
  1135. return `| ${cells.join(" | ")} |`;
  1136. });
  1137. return [header, divider, ...body].join("\n");
  1138. }
  1139. function formatQueryCell(value: unknown): string {
  1140. if (value === null || value === undefined) {
  1141. return "";
  1142. }
  1143. if (value instanceof Date) {
  1144. return value.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false });
  1145. }
  1146. if (typeof value === "object") {
  1147. return JSON.stringify(value);
  1148. }
  1149. return String(value);
  1150. }
  1151. function buildProductPriceParams(text: string, messages: ChatMessage[]): Record<string, string> | undefined {
  1152. const standaloneIntent = isStandaloneProductPriceIntent(text);
  1153. const followUpIntent = !standaloneIntent && isProductPriceFollowUp(text, messages);
  1154. if (!standaloneIntent && !followUpIntent) {
  1155. return undefined;
  1156. }
  1157. const productFromCurrent = extractProductKeyword(text);
  1158. const productFromContext = followUpIntent ? getLatestComparisonProduct(messages) : "";
  1159. let productKeyword = productFromCurrent || productFromContext;
  1160. if (!productKeyword) {
  1161. return undefined;
  1162. }
  1163. const spec = inferProductSpec(text);
  1164. if (spec && !productKeyword.includes(spec)) {
  1165. productKeyword = `${productKeyword} ${spec}`;
  1166. }
  1167. return {
  1168. productKeyword,
  1169. productType: "auto",
  1170. openBrowser: /打开浏览器|弹出页面|人工复核|手动查看/.test(text) ? "true" : "false",
  1171. notes: spec ? `按${spec}口径查询,对比同规格到手价。` : "按同规格、同口径到手价进行对比。"
  1172. };
  1173. }
  1174. function shouldAskDeepSeekForProductIntent(text: string, messages: ChatMessage[]): boolean {
  1175. return isStandaloneProductPriceIntent(text) || isProductPriceFollowUp(text, messages);
  1176. }
  1177. function normalizeProductIntentParams(
  1178. params: Record<string, string> | undefined,
  1179. text: string,
  1180. messages: ChatMessage[]
  1181. ): Record<string, string> | undefined {
  1182. if (!params) {
  1183. return undefined;
  1184. }
  1185. const fallback = buildProductPriceParams(text, messages);
  1186. if (!fallback) {
  1187. return undefined;
  1188. }
  1189. const rawKeyword = (params.productKeyword || params.keyword || "").trim();
  1190. const keyword = extractProductKeyword(rawKeyword) || rawKeyword || fallback.productKeyword || "";
  1191. if (!keyword) {
  1192. return undefined;
  1193. }
  1194. const spec = inferProductSpec(`${text} ${keyword}`);
  1195. const productKeyword = spec && !keyword.includes(spec) ? `${keyword} ${spec}` : keyword;
  1196. const notes = (params.notes || "").trim() || (spec ? `按${spec}口径查询,对比同规格到手价。` : "按同规格、同口径到手价进行对比。");
  1197. return {
  1198. productKeyword,
  1199. productType: params.productType || "auto",
  1200. openBrowser: String(params.openBrowser).toLowerCase() === "true" || /打开浏览器|弹出页面|人工复核|手动查看/.test(text) ? "true" : "false",
  1201. notes
  1202. };
  1203. }
  1204. function isProductPriceIntent(text: string, historyText: string): boolean {
  1205. return isStandaloneProductPriceIntent(text) || isStandaloneProductPriceIntent(historyText);
  1206. }
  1207. function isStandaloneProductPriceIntent(text: string): boolean {
  1208. if (isObviousNonProductTopic(text)) {
  1209. return false;
  1210. }
  1211. const keyword = extractProductKeyword(text);
  1212. if (!keyword || isGenericProductKeyword(keyword)) {
  1213. return false;
  1214. }
  1215. const hasPriceWord = /价格|定价|售价|比价|到手价|多少钱|对比|比较|电商价|系统价/.test(text);
  1216. const hasProductContext = /商品|产品|系统价|业务系统|sku|spu|淘宝|京东|拼多多|电商|市面|平台|商城|同规格|smqjh/i.test(text);
  1217. const hasConcreteSpec = Boolean(inferProductSpec(text)) || /单瓶|一瓶|1瓶|单件|一件|箱装|整箱|瓶装|盒装|罐装|ml|mL|ML|毫升|升|L\b/.test(text);
  1218. return hasPriceWord && (hasProductContext || hasConcreteSpec);
  1219. }
  1220. function isProductPriceFollowUp(text: string, messages: ChatMessage[]): boolean {
  1221. if (isObviousNonProductTopic(text) || !getLatestComparisonProduct(messages)) {
  1222. return false;
  1223. }
  1224. const looksLikeFollowUp = /^(再|继续|换成|改成|按|只|也|那|这个|这款|它|同规格|单瓶|单件|一瓶|一件)/.test(text.trim())
  1225. || /单瓶|一瓶|1瓶|单件|一件|同规格|口径|规格|\d+(?:\.\d+)?\s*(?:ml|mL|ML|毫升|l|L|升)/.test(text);
  1226. const hasPriceAction = /价格|定价|售价|比价|到手价|多少钱|对比|比较|查|看|跑/.test(text);
  1227. return looksLikeFollowUp && hasPriceAction;
  1228. }
  1229. function isObviousNonProductTopic(text: string): boolean {
  1230. if (/天气|气温|下雨/.test(text)) {
  1231. return true;
  1232. }
  1233. const hasPriceOrProduct = /商品|产品|价格|定价|售价|比价|到手价|多少钱|系统价|电商|淘宝|京东|拼多多|市面/.test(text);
  1234. return !hasPriceOrProduct && /订单|订购|支付|退款|售后|物流|快递|会员|手机号|登录|验证码|日志|网关|接口|token|权限|菜单|配置|数据库|报错|启动|退出/.test(text);
  1235. }
  1236. function isGenericProductKeyword(keyword: string): boolean {
  1237. return /^(单瓶|一瓶|1瓶|单件|一件|单个|规格|同规格|口径|价格|比价|对比|好|好的|行|可以|只|这个|这款|它)$/i.test(keyword.trim());
  1238. }
  1239. function getLatestComparisonProduct(messages: ChatMessage[]): string {
  1240. const rows = getLatestComparisonRows(messages);
  1241. if (!rows) {
  1242. return "";
  1243. }
  1244. const systemRow = rows.find((row) => row.platform === "smqjh系统" && row.productName && row.price !== "未获取");
  1245. return (systemRow ?? rows.find((row) => row.productName))?.productName ?? "";
  1246. }
  1247. function inferProductSpec(text: string): string {
  1248. if (/单瓶|一瓶|1瓶/.test(text)) {
  1249. return "单瓶";
  1250. }
  1251. const volume = text.match(/\d+(?:\.\d+)?\s*(?:ml|mL|ML|毫升|l|L|升)(?:\s*[xX*×]\s*\d+\s*(?:瓶|桶|罐|支|盒)?)?/);
  1252. return volume?.[0].replace(/\s+/g, "") ?? "";
  1253. }
  1254. function extractProductKeyword(text: string): string {
  1255. const beforeCompare = text
  1256. .replace(/(?:和|与|跟|同|及|以及)?\s*(?:其它|其他|其余)?\s*(?:电商平台|电商|平台|淘宝|京东|拼多多|市面).*$/s, " ")
  1257. .replace(/市面上.*$/s, " ")
  1258. .replace(/其它?电商平台.*$/s, " ");
  1259. const cleaned = beforeCompare
  1260. .replace(/https?:\/\/\S+/g, " ")
  1261. .replace(/[??。!,,;;::\n\r]/g, " ")
  1262. .replace(/(?:帮我|麻烦|我要|我想|可以)?\s*(?:在|从)?\s*(?:业务系统|系统|后台|smqjh)(?:里面|里|中|的)?/gi, " ")
  1263. .replace(/我们|咱们|我方|我司|本方|己方|我要|我想|帮我|麻烦|可以|一下|这个|某个|当前|只/g, " ")
  1264. .replace(/(?:只)?(?:查询一下|查一下|查询|查看|查|看看)/g, " ")
  1265. .replace(/商品描述|商品说明|商品资料|商品信息|描述|说明|资料|信息|简介|详情|是什么|还是什么|商品|产品|价格是多少|定价是多少|售价是多少|多少钱|价格|定价|售价|到手价|比价|对比一下|对比下|对比|比较一下|比较下|比较|做个|做一下/g, " ")
  1266. .replace(/淘宝|京东|拼多多|电商平台|电商|市面|平台/g, " ")
  1267. .replace(/的/g, " ")
  1268. .replace(/\s+/g, " ")
  1269. .replace(/^(?:在|从|里|里面|中)\s+/g, "")
  1270. .replace(/^(?:和|与|跟|同|及|以及)\s*/g, "")
  1271. .replace(/\s*(?:和|与|跟|同|及|以及)$/g, "")
  1272. .trim();
  1273. const genericOnly = /^(单瓶|一瓶|1瓶|单个|规格|同规格|口径|好|好的|行|可以|只)$/;
  1274. return genericOnly.test(cleaned) ? "" : cleaned;
  1275. }
  1276. function formatProductPriceResult(result: TaskRunResult, params: Record<string, string>): string {
  1277. if (!result.success) {
  1278. return `商品价格对比调研未完成:${result.message}。我已把“${params.productKeyword}”填入任务参数,可以在任务页调整后重新运行。`;
  1279. }
  1280. const data = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
  1281. const rows = getComparisonRows(result.data) ?? [];
  1282. const externalRows = rows.filter((row) => row.platform !== "smqjh系统");
  1283. const blockedCount = externalRows.filter((row) => row.status === "未采集到").length;
  1284. const systemMissing = rows.some((row) => row.platform === "smqjh系统" && row.price === "未获取");
  1285. const browserResults = Array.isArray(data.browserResults) ? data.browserResults : [];
  1286. const openedCount = browserResults.filter((item) => Boolean((item as { opened?: boolean }).opened)).length;
  1287. const browserText = openedCount > 0 ? `另外按要求打开了 ${openedCount} 个外部平台页面。` : "未弹出外部平台页面。";
  1288. const systemText = systemMissing ? "smqjh 系统价暂未匹配到,通常需要更精确的商品ID、SKU ID、商品编码,或确认后台商品名称。" : "已拿到 smqjh 系统价。";
  1289. const marketText =
  1290. blockedCount === externalRows.length && externalRows.length > 0
  1291. ? "外部平台公开页面触发登录/风控或没有可解析价格,这不是你还需要手工查,而是需要接入平台官方接口、采购接口或授权采集账号。"
  1292. : "外部平台已尽量后台采集,表格里会标出采集状态。";
  1293. return [
  1294. `已按“${params.productKeyword}”完成商品价格对比调研,${browserText}`,
  1295. `${systemText}${marketText}`,
  1296. "下方是表格视图,“依据”列会标出数据库只读查询范围或外部浏览器请求地址。"
  1297. ]
  1298. .filter(Boolean)
  1299. .join("\n\n");
  1300. }
  1301. function isComparisonExportIntent(text: string): boolean {
  1302. const normalized = text.toLowerCase();
  1303. const hasExportAction = /导出|下载|保存|生成|做成|做个|做一个|整理成|转成|给我一个|给我份/.test(text);
  1304. const hasSheetTarget = /excel|xlsx|xls|exlc|表格|电子表格|文件/.test(normalized);
  1305. return hasExportAction && hasSheetTarget;
  1306. }
  1307. function getLatestComparisonRows(messages: ChatMessage[]): PriceComparisonRow[] | undefined {
  1308. for (let index = messages.length - 1; index >= 0; index -= 1) {
  1309. const rows = messages[index].comparisonRows;
  1310. if (rows?.length) {
  1311. return rows;
  1312. }
  1313. }
  1314. return undefined;
  1315. }
  1316. function getLatestMcpTables(messages: ChatMessage[]): AssistantResultTable[] | undefined {
  1317. for (let index = messages.length - 1; index >= 0; index -= 1) {
  1318. const tables = messages[index].mcpTables?.filter((table) => table.columns.length > 0);
  1319. if (tables?.length) {
  1320. return tables;
  1321. }
  1322. }
  1323. return undefined;
  1324. }
  1325. function buildComparisonExportTitle(rows: PriceComparisonRow[]): string {
  1326. const systemRow = rows.find((row) => row.platform === "smqjh系统") ?? rows[0];
  1327. return `商品价格对比_${systemRow?.productName || "导出"}`;
  1328. }
  1329. function buildTableExportTitle(tables: AssistantResultTable[]): string {
  1330. const title = tables[0]?.title?.trim();
  1331. return title ? `智能体查询结果_${title}` : "智能体查询结果";
  1332. }
  1333. function formatExportResult(result: PriceComparisonExportResult): string {
  1334. return `已经按上一轮价格对比结果生成 Excel:${result.fileName}\n\n共导出 ${result.rowCount} 行数据,文件已保存到本机下载目录。`;
  1335. }
  1336. function formatTableExportResult(result: TableExportResult): string {
  1337. return `已经把上一轮表格结果生成 Excel:${result.fileName}\n\n共导出 ${result.tableCount} 个表、${result.rowCount} 行数据,文件已保存到本机下载目录。`;
  1338. }
  1339. function NavButton(props: { active: boolean; icon: JSX.Element; label: string; onClick: () => void }) {
  1340. return (
  1341. <button className={props.active ? "nav-button active" : "nav-button"} type="button" aria-pressed={props.active} onClick={props.onClick}>
  1342. {props.icon}
  1343. <span>{props.label}</span>
  1344. </button>
  1345. );
  1346. }
  1347. function StatusPill({ ok, label }: { ok: boolean; label: string }) {
  1348. return <span className={ok ? "pill ok" : "pill muted"}>{label}</span>;
  1349. }
  1350. function friendlyError(error: unknown): string {
  1351. const raw = error instanceof Error ? error.message : String(error);
  1352. const cleaned = raw
  1353. .replace(/^Error invoking remote method '[^']+': Error: /, "")
  1354. .replace(/^Error: /, "")
  1355. .trim();
  1356. if (cleaned === "Bad credentials") {
  1357. return "登录失败:账号、密码或验证码错误";
  1358. }
  1359. return cleaned;
  1360. }
  1361. function stringifyParams(params: Record<string, unknown>): Record<string, string> {
  1362. return Object.fromEntries(
  1363. Object.entries(params).map(([key, value]) => [key, value === undefined || value === null ? "" : String(value)])
  1364. );
  1365. }
  1366. function AssistantView(props: {
  1367. messages: ChatMessage[];
  1368. prompt: string;
  1369. setPrompt: (value: string) => void;
  1370. sendPrompt: (event: FormEvent) => void;
  1371. chatBusy: boolean;
  1372. dashboard: DashboardSnapshot;
  1373. session: SessionStatus;
  1374. tasks: AgentTask[];
  1375. onPickTask: (taskId: string, presetParams?: Record<string, string>) => void;
  1376. }) {
  1377. const reviewTasks = props.tasks.filter((task) => task.dangerLevel === "review").length;
  1378. const conversationEndRef = useRef<HTMLDivElement>(null);
  1379. useEffect(() => {
  1380. conversationEndRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
  1381. }, [props.messages, props.chatBusy]);
  1382. return (
  1383. <section className="assistant-grid">
  1384. <section className="panel conversation-panel">
  1385. <div className="conversation">
  1386. {props.messages.map((message) => (
  1387. <div key={message.id} className={`message ${message.role}`}>
  1388. <div className="avatar">
  1389. {message.role === "assistant" ? <img src={logoUrl} alt="" /> : <UserRound size={18} />}
  1390. </div>
  1391. <div className="bubble">
  1392. <MessageContent text={message.text} />
  1393. {message.comparisonRows && <PriceComparisonTable rows={message.comparisonRows} />}
  1394. {message.orderStats && <OrderStatsTable stats={message.orderStats} />}
  1395. {message.mcpTables && <AssistantResultTables tables={message.mcpTables} />}
  1396. {message.attachment && <AttachmentCard attachment={message.attachment} />}
  1397. {message.taskIds && (
  1398. <div className="message-actions">
  1399. {message.taskIds.map((taskId) => {
  1400. const task = props.tasks.find((item) => item.id === taskId);
  1401. return task ? (
  1402. <button key={taskId} className="secondary-button" onClick={() => props.onPickTask(taskId, message.taskParams?.[taskId])}>
  1403. <Play size={15} />
  1404. <span>{task.title}</span>
  1405. </button>
  1406. ) : null;
  1407. })}
  1408. </div>
  1409. )}
  1410. </div>
  1411. </div>
  1412. ))}
  1413. {props.chatBusy && (
  1414. <div className="message assistant pending">
  1415. <div className="avatar">
  1416. <img src={logoUrl} alt="" />
  1417. </div>
  1418. <div className="bubble thinking-bubble">
  1419. <div className="thinking-row">
  1420. <span>正在处理</span>
  1421. <span className="thinking-dots" aria-hidden="true">
  1422. <i />
  1423. <i />
  1424. <i />
  1425. </span>
  1426. </div>
  1427. </div>
  1428. </div>
  1429. )}
  1430. <div ref={conversationEndRef} />
  1431. </div>
  1432. <form className="composer" onSubmit={props.sendPrompt}>
  1433. <div className="composer-input">
  1434. <textarea
  1435. value={props.prompt}
  1436. placeholder="输入管理员想完成的操作,例如:帮我查这个手机号的企业会员,或者记录一个批量发券需求"
  1437. disabled={props.chatBusy}
  1438. onChange={(event) => props.setPrompt(event.target.value)}
  1439. onKeyDown={(event) => {
  1440. if (event.ctrlKey && event.key === "Enter") {
  1441. event.preventDefault();
  1442. event.currentTarget.form?.requestSubmit();
  1443. }
  1444. }}
  1445. />
  1446. <div className="composer-footer">
  1447. <span>{props.chatBusy ? "处理中" : "就绪"}</span>
  1448. <span>{props.prompt.length} 字</span>
  1449. </div>
  1450. </div>
  1451. <button className="primary-button send-button" type="submit" title="发送" disabled={props.chatBusy || !props.prompt.trim()}>
  1452. <Send size={16} />
  1453. <span>{props.chatBusy ? "思考中" : "发送"}</span>
  1454. </button>
  1455. </form>
  1456. </section>
  1457. <aside className="context-panel">
  1458. <section className="panel">
  1459. <div className="panel-title">
  1460. <h2>执行上下文</h2>
  1461. </div>
  1462. <div className="metric-stack">
  1463. <ContextMetric icon={<Wifi size={18} />} label="网关" value={props.dashboard.config.baseUrl} />
  1464. <ContextMetric icon={<ShieldCheck size={18} />} label="会话" value={props.session.authenticated ? "后台已登录" : "等待登录"} />
  1465. <ContextMetric icon={<Database size={18} />} label="任务" value={`${props.tasks.length} 个`} />
  1466. <ContextMetric icon={<AlertTriangle size={18} />} label="需复核" value={`${reviewTasks} 个`} />
  1467. </div>
  1468. </section>
  1469. <section className="panel">
  1470. <div className="panel-title">
  1471. <h2>快捷动作</h2>
  1472. </div>
  1473. <div className="quick-actions">
  1474. {props.tasks.slice(0, 6).map((task) => (
  1475. <button key={task.id} className="quick-action" type="button" onClick={() => props.onPickTask(task.id)}>
  1476. <span className={`danger ${task.dangerLevel}`}>{categoryNames[task.category]}</span>
  1477. <strong>{task.title}</strong>
  1478. <small>{task.endpoint ?? "local"}</small>
  1479. </button>
  1480. ))}
  1481. </div>
  1482. </section>
  1483. </aside>
  1484. </section>
  1485. );
  1486. }
  1487. function ContextMetric({ icon, label, value }: { icon: JSX.Element; label: string; value: string }) {
  1488. return (
  1489. <div className="context-metric">
  1490. {icon}
  1491. <span>{label}</span>
  1492. <strong>{value}</strong>
  1493. </div>
  1494. );
  1495. }
  1496. function DashboardView(props: { dashboard: DashboardSnapshot; session: SessionStatus; tasks: AgentTask[]; onOpenTasks: () => void }) {
  1497. const reviewTasks = props.tasks.filter((task) => task.dangerLevel === "review").length;
  1498. return (
  1499. <section className="dashboard-grid">
  1500. <div className="metric">
  1501. <Wifi size={20} />
  1502. <span>网关</span>
  1503. <strong>{props.dashboard.config.baseUrl}</strong>
  1504. </div>
  1505. <div className="metric">
  1506. <ShieldCheck size={20} />
  1507. <span>会话</span>
  1508. <strong>{props.session.authenticated ? "后台已登录" : "等待登录"}</strong>
  1509. </div>
  1510. <div className="metric">
  1511. <Database size={20} />
  1512. <span>任务</span>
  1513. <strong>{props.tasks.length} 个</strong>
  1514. </div>
  1515. <div className="metric">
  1516. <AlertTriangle size={20} />
  1517. <span>需复核</span>
  1518. <strong>{reviewTasks} 个</strong>
  1519. </div>
  1520. <section className="panel wide">
  1521. <div className="panel-title">
  1522. <h2>当前框架</h2>
  1523. <button className="primary-button" onClick={props.onOpenTasks}>
  1524. <Play size={16} />
  1525. <span>执行任务</span>
  1526. </button>
  1527. </div>
  1528. <div className="blueprint">
  1529. <BlueprintItem title="连接层" text="OAuth 登录、Bearer Token、统一 Result 解包、超时控制" />
  1530. <BlueprintItem title="任务层" text="任务注册表、参数表单、预演模式、执行日志" />
  1531. <BlueprintItem title="配置层" text="网关地址、环境名、客户端配置、本地 JSON 持久化" />
  1532. <BlueprintItem title="需求池" text="管理员诉求先记录为草稿,再补接口与审批策略" />
  1533. </div>
  1534. </section>
  1535. <section className="panel">
  1536. <div className="panel-title">
  1537. <h2>接口线索</h2>
  1538. </div>
  1539. <ul className="plain-list">
  1540. <li>/oauth2/token</li>
  1541. <li>/api/v1/users/me</li>
  1542. <li>/api/v1/member/export/task/start</li>
  1543. <li>/api/v1/pointsRecharge/exportTemplate</li>
  1544. <li>/api/v1/members/enterprise/getByMobile</li>
  1545. </ul>
  1546. </section>
  1547. <section className="panel">
  1548. <div className="panel-title">
  1549. <h2>最近日志</h2>
  1550. </div>
  1551. <div className="compact-logs">
  1552. {props.dashboard.lastLogs.length === 0 ? <span className="empty">暂无日志</span> : props.dashboard.lastLogs.map((log) => <LogLine key={log.id} log={log} />)}
  1553. </div>
  1554. </section>
  1555. </section>
  1556. );
  1557. }
  1558. function BlueprintItem({ title, text }: { title: string; text: string }) {
  1559. return (
  1560. <div className="blueprint-item">
  1561. <strong>{title}</strong>
  1562. <span>{text}</span>
  1563. </div>
  1564. );
  1565. }
  1566. function TasksView(props: {
  1567. tasks: AgentTask[];
  1568. selectedTask?: AgentTask;
  1569. selectedTaskId: string;
  1570. setSelectedTaskId: (taskId: string) => void;
  1571. params: Record<string, string>;
  1572. setParams: (params: Record<string, string>) => void;
  1573. runTask: () => void;
  1574. busy: boolean;
  1575. lastResult?: TaskRunResult;
  1576. }) {
  1577. const comparisonRows = getComparisonRows(props.lastResult?.data);
  1578. return (
  1579. <section className="task-layout">
  1580. <div className="task-list">
  1581. {props.tasks.map((task) => (
  1582. <button
  1583. key={task.id}
  1584. className={task.id === props.selectedTaskId ? "task-card selected" : "task-card"}
  1585. type="button"
  1586. onClick={() => props.setSelectedTaskId(task.id)}
  1587. >
  1588. <span className={`danger ${task.dangerLevel}`}>{categoryNames[task.category]}</span>
  1589. <strong>{task.title}</strong>
  1590. <small>{task.endpoint ?? "local"}</small>
  1591. </button>
  1592. ))}
  1593. </div>
  1594. <div className="panel task-detail">
  1595. {props.selectedTask && (
  1596. <>
  1597. <div className="panel-title">
  1598. <div>
  1599. <h2>{props.selectedTask.title}</h2>
  1600. <p>{props.selectedTask.description}</p>
  1601. </div>
  1602. <span className={`danger ${props.selectedTask.dangerLevel}`}>{props.selectedTask.dangerLevel}</span>
  1603. </div>
  1604. <div className="endpoint-row">
  1605. <code>{props.selectedTask.method ?? "LOCAL"}</code>
  1606. <span>{props.selectedTask.endpoint ?? props.selectedTask.id}</span>
  1607. </div>
  1608. <div className="param-grid">
  1609. {props.selectedTask.params.length === 0 && <span className="empty">无需参数</span>}
  1610. {props.selectedTask.params.map((param) => (
  1611. <label key={param.key} className="field">
  1612. <span>{param.label}{param.required ? " *" : ""}</span>
  1613. {param.type === "textarea" ? (
  1614. <textarea
  1615. value={props.params[param.key] ?? ""}
  1616. placeholder={param.placeholder}
  1617. onChange={(event) => props.setParams({ ...props.params, [param.key]: event.target.value })}
  1618. />
  1619. ) : (
  1620. <input
  1621. type={param.type === "password" ? "password" : param.type === "number" ? "number" : "text"}
  1622. value={props.params[param.key] ?? ""}
  1623. placeholder={param.placeholder}
  1624. onChange={(event) => props.setParams({ ...props.params, [param.key]: event.target.value })}
  1625. />
  1626. )}
  1627. </label>
  1628. ))}
  1629. </div>
  1630. <div className="action-row">
  1631. <button className="primary-button" type="button" disabled={props.busy} onClick={props.runTask}>
  1632. <Play size={16} />
  1633. <span>{props.busy ? "执行中" : "运行"}</span>
  1634. </button>
  1635. </div>
  1636. {props.lastResult && (
  1637. <>
  1638. {comparisonRows && <PriceComparisonTable rows={comparisonRows} />}
  1639. {comparisonRows ? (
  1640. <details className="result-details">
  1641. <summary>查看原始结果</summary>
  1642. <pre className={props.lastResult.success ? "result success" : "result error"}>
  1643. {JSON.stringify(props.lastResult, null, 2)}
  1644. </pre>
  1645. </details>
  1646. ) : (
  1647. <pre className={props.lastResult.success ? "result success" : "result error"}>
  1648. {JSON.stringify(props.lastResult, null, 2)}
  1649. </pre>
  1650. )}
  1651. </>
  1652. )}
  1653. </>
  1654. )}
  1655. </div>
  1656. </section>
  1657. );
  1658. }
  1659. function MessageContent({ text }: { text: string }) {
  1660. const blocks = parseMarkdownBlocks(text);
  1661. return (
  1662. <>
  1663. {blocks.map((block, index) =>
  1664. block.type === "table" ? (
  1665. <MarkdownTable key={`table-${index}`} headers={block.headers} rows={block.rows} />
  1666. ) : (
  1667. <p key={`text-${index}`}>{block.text}</p>
  1668. )
  1669. )}
  1670. </>
  1671. );
  1672. }
  1673. type MarkdownBlock =
  1674. | { type: "text"; text: string }
  1675. | { type: "table"; headers: string[]; rows: string[][] };
  1676. function parseMarkdownBlocks(text: string): MarkdownBlock[] {
  1677. const lines = text.split(/\r?\n/);
  1678. const blocks: MarkdownBlock[] = [];
  1679. let textBuffer: string[] = [];
  1680. function flushText() {
  1681. const value = textBuffer.join("\n").trim();
  1682. if (value) {
  1683. blocks.push({ type: "text", text: value });
  1684. }
  1685. textBuffer = [];
  1686. }
  1687. for (let index = 0; index < lines.length; index += 1) {
  1688. if (isMarkdownTableRow(lines[index]) && isMarkdownDelimiter(lines[index + 1] ?? "")) {
  1689. flushText();
  1690. const headers = parseMarkdownTableRow(lines[index]);
  1691. index += 2;
  1692. const rows: string[][] = [];
  1693. while (index < lines.length && isMarkdownTableRow(lines[index])) {
  1694. rows.push(parseMarkdownTableRow(lines[index]));
  1695. index += 1;
  1696. }
  1697. index -= 1;
  1698. if (headers.length && rows.length) {
  1699. blocks.push({ type: "table", headers, rows });
  1700. }
  1701. } else {
  1702. textBuffer.push(lines[index]);
  1703. }
  1704. }
  1705. flushText();
  1706. return blocks.length ? blocks : [{ type: "text", text }];
  1707. }
  1708. function isMarkdownTableRow(line: string): boolean {
  1709. return /^\s*\|.+\|\s*$/.test(line);
  1710. }
  1711. function isMarkdownDelimiter(line: string): boolean {
  1712. return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
  1713. }
  1714. function parseMarkdownTableRow(line: string): string[] {
  1715. return line
  1716. .trim()
  1717. .replace(/^\|/, "")
  1718. .replace(/\|$/, "")
  1719. .split("|")
  1720. .map((cell) => cell.trim());
  1721. }
  1722. function MarkdownTable({ headers, rows }: { headers: string[]; rows: string[][] }) {
  1723. return (
  1724. <div className="markdown-table-wrap">
  1725. <table className="markdown-table">
  1726. <thead>
  1727. <tr>
  1728. {headers.map((header, index) => (
  1729. <th key={`${header}-${index}`}>{header}</th>
  1730. ))}
  1731. </tr>
  1732. </thead>
  1733. <tbody>
  1734. {rows.map((row, rowIndex) => (
  1735. <tr key={`row-${rowIndex}`}>
  1736. {headers.map((_, cellIndex) => (
  1737. <td key={`cell-${rowIndex}-${cellIndex}`}>{row[cellIndex] ?? ""}</td>
  1738. ))}
  1739. </tr>
  1740. ))}
  1741. </tbody>
  1742. </table>
  1743. </div>
  1744. );
  1745. }
  1746. function PriceComparisonTable({ rows }: { rows: PriceComparisonRow[] }) {
  1747. return (
  1748. <div className="comparison-wrap">
  1749. <table className="comparison-table">
  1750. <thead>
  1751. <tr>
  1752. <th>平台</th>
  1753. <th>商品/规格</th>
  1754. <th>价格</th>
  1755. <th>折算单价</th>
  1756. <th>状态</th>
  1757. <th>来源</th>
  1758. <th>备注</th>
  1759. <th>依据</th>
  1760. </tr>
  1761. </thead>
  1762. <tbody>
  1763. {rows.map((row) => (
  1764. <tr key={`${row.platform}-${row.productName}-${row.price}-${row.status}`}>
  1765. <td>{row.platform}</td>
  1766. <td>{row.productName}</td>
  1767. <td className="price-cell">{row.price}</td>
  1768. <td className="unit-price-cell">{row.unitPrice || "-"}</td>
  1769. <td>
  1770. <span className={`table-status ${statusTone(row.status)}`}>{row.status}</span>
  1771. </td>
  1772. <td>{row.source}</td>
  1773. <td>{row.note}</td>
  1774. <td>
  1775. <EvidenceCell evidence={row.evidence} sourceUrl={row.sourceUrl} />
  1776. </td>
  1777. </tr>
  1778. ))}
  1779. </tbody>
  1780. </table>
  1781. </div>
  1782. );
  1783. }
  1784. function AssistantResultTables({ tables }: { tables: AssistantResultTable[] }) {
  1785. return (
  1786. <>
  1787. {tables.map((table) => (
  1788. <div key={`${table.title}-${table.columns.join(",")}`} className="markdown-table-wrap">
  1789. <div className="table-caption">{table.title}</div>
  1790. <table className="markdown-table">
  1791. <thead>
  1792. <tr>
  1793. {table.columns.map((column) => (
  1794. <th key={column}>{column}</th>
  1795. ))}
  1796. </tr>
  1797. </thead>
  1798. <tbody>
  1799. {table.rows.length ? (
  1800. table.rows.map((row, rowIndex) => (
  1801. <tr key={`mcp-row-${rowIndex}`}>
  1802. {table.columns.map((column) => (
  1803. <td key={`${rowIndex}-${column}`}>{formatQueryCell(row[column])}</td>
  1804. ))}
  1805. </tr>
  1806. ))
  1807. ) : (
  1808. <tr>
  1809. <td colSpan={Math.max(table.columns.length, 1)}>没有查询到数据</td>
  1810. </tr>
  1811. )}
  1812. </tbody>
  1813. </table>
  1814. </div>
  1815. ))}
  1816. </>
  1817. );
  1818. }
  1819. function OrderStatsTable({ stats }: { stats: OrderStatsTableData }) {
  1820. return (
  1821. <div className="order-stats-wrap">
  1822. <div className="table-caption">{stats.title}</div>
  1823. <table className="order-stats-table">
  1824. <thead>
  1825. <tr>
  1826. <th>统计项</th>
  1827. <th>数值</th>
  1828. <th>口径说明</th>
  1829. <th>依据</th>
  1830. </tr>
  1831. </thead>
  1832. <tbody>
  1833. {stats.rows.map((row) => (
  1834. <tr key={`${row.metric}-${row.value}`}>
  1835. <td>{row.metric}</td>
  1836. <td className="stat-value">{row.value}</td>
  1837. <td>{row.note}</td>
  1838. <td>{row.evidence}</td>
  1839. </tr>
  1840. ))}
  1841. </tbody>
  1842. </table>
  1843. </div>
  1844. );
  1845. }
  1846. function statusTone(status: string): string {
  1847. if (/已匹配|价格线索|成功|已登录/.test(status)) {
  1848. return "ok";
  1849. }
  1850. if (/未|待|失败|风控|错误/.test(status)) {
  1851. return "warn";
  1852. }
  1853. return "muted";
  1854. }
  1855. function AttachmentCard({ attachment }: { attachment: ChatAttachment }) {
  1856. return (
  1857. <div className="attachment-card">
  1858. <div className="attachment-main">
  1859. <FileSpreadsheet size={20} />
  1860. <div>
  1861. <strong>{attachment.fileName}</strong>
  1862. <span>{attachment.tableCount ? `${attachment.tableCount} 个表,` : ""}{attachment.rowCount} 行数据</span>
  1863. </div>
  1864. </div>
  1865. <div className="attachment-actions">
  1866. <button className="secondary-button" type="button" onClick={() => void window.smqjhAgent.openExportedFile(attachment.filePath)}>
  1867. <FileSpreadsheet size={15} />
  1868. <span>打开</span>
  1869. </button>
  1870. <button className="secondary-button" type="button" onClick={() => void window.smqjhAgent.showExportedFile(attachment.filePath)}>
  1871. <FolderOpen size={15} />
  1872. <span>定位</span>
  1873. </button>
  1874. </div>
  1875. </div>
  1876. );
  1877. }
  1878. function EvidenceCell({ evidence, sourceUrl }: { evidence?: string; sourceUrl?: string }) {
  1879. const lines = (evidence || sourceUrl || "无").split(/\r?\n/).filter(Boolean);
  1880. return (
  1881. <div className="evidence-cell">
  1882. {lines.map((line) => {
  1883. const url = line.match(/https?:\/\/\S+/)?.[0];
  1884. const label = url ? evidenceLinkLabel(line, url) : "";
  1885. return (
  1886. <div key={line} className="evidence-item">
  1887. {url ? (
  1888. <>
  1889. <a className="evidence-link" href={url} target="_blank" rel="noreferrer" title={url}>
  1890. {label}
  1891. </a>
  1892. <details className="evidence-detail">
  1893. <summary>查看地址</summary>
  1894. <code>{safeDecodeUrl(url)}</code>
  1895. </details>
  1896. </>
  1897. ) : (
  1898. <span className="evidence-text">{line}</span>
  1899. )}
  1900. </div>
  1901. );
  1902. })}
  1903. </div>
  1904. );
  1905. }
  1906. function evidenceLinkLabel(line: string, url: string): string {
  1907. const prefix = line.replace(url, "").replace(/[::\s]+$/g, "").trim();
  1908. const host = (() => {
  1909. try {
  1910. return new URL(url).hostname.replace(/^www\./, "");
  1911. } catch {
  1912. return "请求地址";
  1913. }
  1914. })();
  1915. const action = prefix.includes("直连") ? "直连请求" : prefix.includes("搜索") ? "搜索请求" : prefix || "打开依据";
  1916. return `${action} · ${host}`;
  1917. }
  1918. function safeDecodeUrl(url: string): string {
  1919. try {
  1920. return decodeURI(url);
  1921. } catch {
  1922. return url;
  1923. }
  1924. }
  1925. function getComparisonRows(data: unknown): PriceComparisonRow[] | undefined {
  1926. if (!data || typeof data !== "object" || !("comparisonRows" in data)) {
  1927. return undefined;
  1928. }
  1929. const rows = (data as { comparisonRows?: unknown }).comparisonRows;
  1930. if (!Array.isArray(rows) || rows.length === 0) {
  1931. return undefined;
  1932. }
  1933. return rows
  1934. .filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object")
  1935. .map((row) => ({
  1936. platform: String(row.platform ?? ""),
  1937. productName: String(row.productName ?? ""),
  1938. price: String(row.price ?? ""),
  1939. unitPrice: row.unitPrice ? String(row.unitPrice) : undefined,
  1940. status: String(row.status ?? ""),
  1941. source: String(row.source ?? ""),
  1942. note: String(row.note ?? ""),
  1943. sourceUrl: row.sourceUrl ? String(row.sourceUrl) : undefined,
  1944. evidence: row.evidence ? String(row.evidence) : undefined
  1945. }));
  1946. }
  1947. function SettingsView(props: {
  1948. config: AppConfig;
  1949. setConfig: (config: AppConfig) => void;
  1950. saveConfig: (config: AppConfig) => Promise<void>;
  1951. login: (request: { username: string; password: string; tenantCode: string; captchaId: string; captchaCode: string }) => Promise<void>;
  1952. logout: () => Promise<void>;
  1953. busy: boolean;
  1954. session: SessionStatus;
  1955. feedback?: Feedback;
  1956. focusTick: number;
  1957. }) {
  1958. const draft = props.config;
  1959. const setDraft = props.setConfig;
  1960. const [username, setUsername] = useState("");
  1961. const [password, setPassword] = useState("");
  1962. const [tenantCode, setTenantCode] = useState(props.config.tenantCode || "zswl");
  1963. const [captchaCode, setCaptchaCode] = useState("");
  1964. const [captcha, setCaptcha] = useState<CaptchaResult>();
  1965. const [captchaError, setCaptchaError] = useState("");
  1966. const [mcpSnapshot, setMcpSnapshot] = useState<McpRuntimeConfigSnapshot>();
  1967. const [mcpError, setMcpError] = useState("");
  1968. const loginPanelRef = useRef<HTMLFormElement>(null);
  1969. const usernameInputRef = useRef<HTMLInputElement>(null);
  1970. useEffect(() => setTenantCode(props.config.tenantCode || "zswl"), [props.config.tenantCode]);
  1971. useEffect(() => {
  1972. void refreshCaptcha();
  1973. void refreshMcpSnapshot();
  1974. }, []);
  1975. useEffect(() => {
  1976. if (props.focusTick === 0) {
  1977. return;
  1978. }
  1979. loginPanelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
  1980. window.setTimeout(() => usernameInputRef.current?.focus(), 120);
  1981. }, [props.focusTick]);
  1982. function submitLogin(event: FormEvent) {
  1983. event.preventDefault();
  1984. if (!captcha?.captchaId) {
  1985. setCaptchaError("请先刷新验证码");
  1986. return;
  1987. }
  1988. if (!captchaCode.trim()) {
  1989. setCaptchaError("请输入验证码");
  1990. return;
  1991. }
  1992. setCaptchaError("");
  1993. void props.login({
  1994. username,
  1995. password,
  1996. tenantCode,
  1997. captchaId: captcha.captchaId,
  1998. captchaCode: captchaCode.trim()
  1999. }).finally(() => {
  2000. setCaptchaCode("");
  2001. void refreshCaptcha();
  2002. });
  2003. }
  2004. async function refreshCaptcha() {
  2005. setCaptchaError("");
  2006. try {
  2007. const nextCaptcha = await window.smqjhAgent.getCaptcha();
  2008. setCaptcha(nextCaptcha);
  2009. } catch (error) {
  2010. const message = friendlyError(error);
  2011. setCaptchaError(message || "验证码获取失败");
  2012. }
  2013. }
  2014. async function refreshMcpSnapshot() {
  2015. setMcpError("");
  2016. try {
  2017. setMcpSnapshot(await window.smqjhAgent.getMcpConfig());
  2018. } catch (error) {
  2019. const message = friendlyError(error);
  2020. setMcpError(message || "MCP 配置状态读取失败");
  2021. setMcpSnapshot(undefined);
  2022. }
  2023. }
  2024. return (
  2025. <section className="settings-grid">
  2026. <form className="panel form-panel" onSubmit={(event) => {
  2027. event.preventDefault();
  2028. void props.saveConfig(draft);
  2029. }}>
  2030. <div className="panel-title">
  2031. <h2>连接配置</h2>
  2032. <button className="primary-button" type="submit">
  2033. <Save size={16} />
  2034. <span>保存</span>
  2035. </button>
  2036. </div>
  2037. <label className="field">
  2038. <span>环境名</span>
  2039. <input value={draft.environmentName} onChange={(event) => setDraft({ ...draft, environmentName: event.target.value })} />
  2040. </label>
  2041. <label className="field">
  2042. <span>网关地址</span>
  2043. <input value={draft.baseUrl} onChange={(event) => setDraft({ ...draft, baseUrl: event.target.value })} />
  2044. </label>
  2045. <label className="field">
  2046. <span>Token 路径</span>
  2047. <input value={draft.auth.tokenPath} onChange={(event) => setDraft({ ...draft, auth: { ...draft.auth, tokenPath: event.target.value } })} />
  2048. </label>
  2049. <div className="two-col">
  2050. <label className="field">
  2051. <span>Client ID</span>
  2052. <input value={draft.auth.clientId} onChange={(event) => setDraft({ ...draft, auth: { ...draft.auth, clientId: event.target.value } })} />
  2053. </label>
  2054. <label className="field">
  2055. <span>Client Secret</span>
  2056. <input value={draft.auth.clientSecret} onChange={(event) => setDraft({ ...draft, auth: { ...draft.auth, clientSecret: event.target.value } })} />
  2057. </label>
  2058. </div>
  2059. <div className="two-col">
  2060. <label className="field">
  2061. <span>默认租户编码</span>
  2062. <input value={draft.tenantCode} onChange={(event) => setDraft({ ...draft, tenantCode: event.target.value })} />
  2063. </label>
  2064. <label className="field">
  2065. <span>租户域</span>
  2066. <input value={draft.tenantDomain} onChange={(event) => setDraft({ ...draft, tenantDomain: event.target.value })} />
  2067. </label>
  2068. </div>
  2069. <div className="two-col">
  2070. <label className="field">
  2071. <span>超时 ms</span>
  2072. <input type="number" value={draft.requestTimeoutMs} onChange={(event) => setDraft({ ...draft, requestTimeoutMs: Number(event.target.value) })} />
  2073. </label>
  2074. <label className="toggle">
  2075. <input type="checkbox" checked={draft.dryRun} onChange={(event) => setDraft({ ...draft, dryRun: event.target.checked })} />
  2076. <span>预演模式</span>
  2077. </label>
  2078. </div>
  2079. <div className="subsection-title">MCP 服务</div>
  2080. <p className="scope-note">数据库连接、只读查询和业务工具由 MCP 托管,桌面端只保存 MCP 连接地址;敏感信息不在前端配置页展示。</p>
  2081. <label className="toggle compact-toggle">
  2082. <input
  2083. type="checkbox"
  2084. checked={draft.mcp.enabled}
  2085. onChange={(event) => setDraft({ ...draft, mcp: { ...draft.mcp, enabled: event.target.checked } })}
  2086. />
  2087. <span>启用 MCP 工具调用</span>
  2088. </label>
  2089. <div className="two-col">
  2090. <label className="field">
  2091. <span>MCP 地址</span>
  2092. <input value={draft.mcp.host} onChange={(event) => setDraft({ ...draft, mcp: { ...draft.mcp, host: event.target.value } })} />
  2093. </label>
  2094. <label className="field">
  2095. <span>端口</span>
  2096. <input type="number" value={draft.mcp.port} onChange={(event) => setDraft({ ...draft, mcp: { ...draft.mcp, port: Number(event.target.value) } })} />
  2097. </label>
  2098. </div>
  2099. <label className="field">
  2100. <span>MCP 路径</span>
  2101. <input value={draft.mcp.path} onChange={(event) => setDraft({ ...draft, mcp: { ...draft.mcp, path: event.target.value } })} />
  2102. </label>
  2103. <label className="field">
  2104. <span>MCP Token</span>
  2105. <input
  2106. type="password"
  2107. value={draft.mcp.authToken}
  2108. autoComplete="off"
  2109. placeholder="未配置时本机直连"
  2110. onChange={(event) => setDraft({ ...draft, mcp: { ...draft.mcp, authToken: event.target.value } })}
  2111. onBlur={() => void props.saveConfig(draft)}
  2112. />
  2113. </label>
  2114. <div className="mcp-status-card">
  2115. <div className="mcp-status-header">
  2116. <strong>MCP 托管配置状态</strong>
  2117. <button className="secondary-button" type="button" onClick={() => void refreshMcpSnapshot()}>
  2118. <RefreshCcw size={15} />
  2119. <span>刷新</span>
  2120. </button>
  2121. </div>
  2122. {mcpError ? <div className="feedback error">{mcpError}</div> : <McpConfigSummary snapshot={mcpSnapshot} />}
  2123. </div>
  2124. <div className="subsection-title">DeepSeek 智能体</div>
  2125. <p className="scope-note">DeepSeek 已迁移到 MCP 侧托管。桌面端不再保存或展示 API Key,模型和推理强度请在 MCP 本地配置或环境变量中维护。</p>
  2126. </form>
  2127. <form className="panel form-panel" ref={loginPanelRef} onSubmit={submitLogin}>
  2128. <div className="panel-title">
  2129. <h2>后台登录</h2>
  2130. <StatusPill ok={props.session.authenticated} label={props.session.authenticated ? "已登录" : "未登录"} />
  2131. </div>
  2132. {props.session.authenticated ? (
  2133. <div className="logged-in-actions">
  2134. <button className="secondary-button" type="button" onClick={() => void props.logout()}>
  2135. <UserRound size={16} />
  2136. <span>退出登录</span>
  2137. </button>
  2138. </div>
  2139. ) : (
  2140. <>
  2141. {props.feedback && <div className={`feedback ${props.feedback.type}`}>{props.feedback.text}</div>}
  2142. {captchaError && <div className="feedback error">{captchaError}</div>}
  2143. <label className="field">
  2144. <span>租户编码</span>
  2145. <input value={tenantCode} onChange={(event) => setTenantCode(event.target.value)} />
  2146. </label>
  2147. <label className="field">
  2148. <span>账号</span>
  2149. <input ref={usernameInputRef} value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" />
  2150. </label>
  2151. <label className="field">
  2152. <span>密码</span>
  2153. <input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" />
  2154. </label>
  2155. <label className="field">
  2156. <span>验证码</span>
  2157. <div className="captcha-row">
  2158. <input value={captchaCode} onChange={(event) => setCaptchaCode(event.target.value)} />
  2159. <button className="captcha-button" type="button" onClick={() => void refreshCaptcha()} title="刷新验证码">
  2160. {captcha?.captchaBase64 ? <img src={captcha.captchaBase64} alt="验证码" /> : "刷新"}
  2161. </button>
  2162. </div>
  2163. </label>
  2164. <div className="action-row">
  2165. <button className="primary-button" disabled={props.busy} type="submit">
  2166. <KeyRound size={16} />
  2167. <span>{props.busy ? "登录中" : "登录"}</span>
  2168. </button>
  2169. </div>
  2170. </>
  2171. )}
  2172. </form>
  2173. </section>
  2174. );
  2175. }
  2176. function McpConfigSummary({ snapshot }: { snapshot?: McpRuntimeConfigSnapshot }) {
  2177. if (!snapshot) {
  2178. return <div className="mcp-empty">尚未读取 MCP 配置状态</div>;
  2179. }
  2180. const db = snapshot.database ?? {};
  2181. const ai = snapshot.deepSeek ?? {};
  2182. const mcp = snapshot.mcp ?? {};
  2183. return (
  2184. <div className="mcp-summary-grid">
  2185. <ConfigSummaryItem label="运行环境" value={stringValue(snapshot.environmentName) || "-"} />
  2186. <ConfigSummaryItem label="网关" value={stringValue(snapshot.baseUrl) || "-"} />
  2187. <ConfigSummaryItem label="MCP 地址" value={`${stringValue(mcp.host) || "127.0.0.1"}:${stringValue(mcp.port) || "8765"}${stringValue(mcp.path) || "/mcp"}`} />
  2188. <ConfigSummaryItem label="MCP Token" value={truthyValue(mcp.tokenConfigured) ? "已配置" : "未配置"} />
  2189. <ConfigSummaryItem label="数据库" value={truthyValue(db.enabled) ? "已启用" : "未启用"} tone={truthyValue(db.enabled) ? "ok" : "warn"} />
  2190. <ConfigSummaryItem label="数据库名" value={stringValue(db.database) || "-"} />
  2191. <ConfigSummaryItem label="数据库地址" value={`${stringValue(db.host) || "-"}:${stringValue(db.port) || "3306"}`} />
  2192. <ConfigSummaryItem label="数据库账号" value={stringValue(db.username) || "-"} />
  2193. <ConfigSummaryItem label="DeepSeek" value={truthyValue(ai.enabled) ? "已启用" : "未启用"} tone={truthyValue(ai.enabled) ? "ok" : "warn"} />
  2194. <ConfigSummaryItem label="模型" value={stringValue(ai.model) || "-"} />
  2195. <ConfigSummaryItem label="API Key" value={truthyValue(ai.apiKeyConfigured) ? "已配置" : "未配置"} tone={truthyValue(ai.apiKeyConfigured) ? "ok" : "warn"} />
  2196. <ConfigSummaryItem label="配置来源" value={stringValue(snapshot.desktopConfigPath) || "MCP 本地配置"} wide />
  2197. </div>
  2198. );
  2199. }
  2200. function ConfigSummaryItem({ label, value, tone = "muted", wide = false }: { label: string; value: string; tone?: "ok" | "warn" | "muted"; wide?: boolean }) {
  2201. return (
  2202. <div className={`mcp-summary-item ${wide ? "wide" : ""}`}>
  2203. <span>{label}</span>
  2204. <strong className={tone}>{value}</strong>
  2205. </div>
  2206. );
  2207. }
  2208. function stringValue(value: unknown): string {
  2209. return value === undefined || value === null ? "" : String(value);
  2210. }
  2211. function truthyValue(value: unknown): boolean {
  2212. return value === true || value === "true" || value === "1" || value === 1;
  2213. }
  2214. function LogsView({ logs, clear }: { logs: LogEntry[]; clear: () => Promise<void> }) {
  2215. return (
  2216. <section className="panel logs-panel">
  2217. <div className="panel-title">
  2218. <h2>日志</h2>
  2219. <button className="secondary-button" onClick={() => void clear()}>
  2220. <Trash2 size={16} />
  2221. <span>清空</span>
  2222. </button>
  2223. </div>
  2224. <div className="logs-list">
  2225. {logs.length === 0 ? <span className="empty">暂无日志</span> : logs.map((log) => <LogLine key={log.id} log={log} />)}
  2226. </div>
  2227. </section>
  2228. );
  2229. }
  2230. function LogLine({ log }: { log: LogEntry }) {
  2231. return (
  2232. <div className={`log-line ${log.level}`}>
  2233. <time>{new Date(log.timestamp).toLocaleString()}</time>
  2234. <span>{log.level}</span>
  2235. <strong>{log.message}</strong>
  2236. </div>
  2237. );
  2238. }