Add Cloud Tour Libo knowledge graph platform

This commit is contained in:
xuelong committed 2026-07-30 10:09:16 +08:00
1 parent 9e05b09a38
commit bae5197d62
103 files changed
+14036 -160

No files matched your search

+18
View File
@@ -175,8 +175,26 @@ export const plazaOverview = () => api.get("/plaza/overview");
export const plazaUsage = () => api.get("/plaza/usage");
export const plazaAlerts = () => api.get("/plaza/alerts");
export const plazaAmapConfig = () => api.get("/plaza/amap-config");
export const plazaMapPois = () => api.get("/plaza/map-pois");
export const plazaBusRoutes = () => api.get("/plaza/bus-routes");
export const plazaMapPoiDetail = (placeId: string) =>
api.get(`/plaza/map-pois/${encodeURIComponent(placeId)}`);
export const plazaUserQuery = (data: unknown) => api.post("/plaza/user-query", data);
export const travelAssistantQuery = (data: unknown) => api.post("/travel/assistant-query", data);
// ── OCR (旅行社名单录入) ──
export const ocrPassport = (file: File) => {
const form = new FormData();
form.append("file", file);
return api.post("/ocr/passport", form, {
headers: { "Content-Type": "multipart/form-data" },
});
};
export const ocrPassportByUrl = (url: string) => {
const form = new FormData();
form.append("url", url);
return api.post("/ocr/passport", form);
};
export const graphOverview = () => api.get("/graph/overview");
export const graphQuery = (cypher: string, limit = 200) =>
api.post("/graph/query", { cypher, limit });
+50 -6
View File
@@ -9,11 +9,11 @@ import * as echarts from "echarts";
import api, { displayGraphName, getProjectContext } from "../../api";
const CATEGORY_COLORS: Record<string, string> = {
Place: "#00D4AA", Area: "#5B8FF9", Station: "#F6BD16",
Place: "#00D4AA", Area: "#5B8FF9", GeoCell: "#5B8FF9", 片区: "#5B8FF9", Station: "#F6BD16",
BusStop: "#F6BD16", MetroStation: "#1677FF", TransitStop: "#F6BD16",
BusLine: "#9270CA",
地点: "#00D4AA", 景点: "#00D4AA", 美食: "#FF9D4D", 酒店: "#6DC8EC",
商场: "#F6BD16", 公交站: "#F6BD16", 地铁站: "#1677FF", 交通站点: "#F6BD16",
地点: "#00D4AA", 景点: "#1677FF", 景区: "#1677FF", 美食: "#FF5A1F", 酒店: "#722ED1",
交通设施: "#13A8A8", 商场: "#F6BD16", 公交站: "#F6BD16", 地铁站: "#1677FF", 交通站点: "#F6BD16",
RouteTemplate: "#E8684A", ExperienceTag: "#6DC8EC",
Source: "#9270CA", Evidence: "#FF99C3",
TourProduct: "#4C6FFF", ProductDay: "#6DC8EC", RouteStop: "#F6BD16",
@@ -207,6 +207,15 @@ const FULL_GRAPH_LIMIT = 2500;
const FULL_GRAPH_BATCH_SIZE = 1000;
const FULL_GRAPH_BASE_CYPHER = "MATCH (n)-[r]->(m) RETURN n,r,m";
const GENERIC_DEFAULT_CYPHER = `${FULL_GRAPH_BASE_CYPHER} LIMIT ${FULL_GRAPH_LIMIT}`;
const LIBO_DEFAULT_CYPHER = [
"MATCH (n:Hotel)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,null AS parentRel,null AS parent LIMIT 100",
"UNION",
"MATCH (n:FoodPlace)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,null AS parentRel,null AS parent LIMIT 100",
"UNION",
"MATCH (n:TransitFacility)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,null AS parentRel,null AS parent LIMIT 60",
"UNION",
"MATCH (n:ScenicSpot)-[parentRel:PART_OF_SCENIC_AREA]->(parent) MATCH (n)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN n,r,cell,areaRel,area,parentRel,parent LIMIT 50",
].join(" ");
const TRAVEL_CORE_CYPHER = [
"MATCH (p:TourProduct)-[lineStop:PRODUCT_HAS_ORDERED_STOP]->(s:RouteStop)",
"OPTIONAL MATCH (s)-[next:ROUTE_STOP_NEXT]->(s2:RouteStop)",
@@ -342,8 +351,19 @@ function isAnyTravelGraphName(value?: any): boolean {
return name.includes("travel") || name.includes("旅行社") || name.includes("旅游");
}
function isYunyouLiboGraphName(value?: any): boolean {
const name = String(value || "").toLowerCase();
return name.includes("yunyou_libo") || name.includes("云游荔波");
}
function defaultCypherForGraph(value?: any): string {
return isTravelItemGraphName(value) ? TRAVEL_ITEM_CORE_CYPHER : (isAnyTravelGraphName(value) ? TRAVEL_DEFAULT_CYPHER : GENERIC_DEFAULT_CYPHER);
return isYunyouLiboGraphName(value)
? LIBO_DEFAULT_CYPHER
: isTravelItemGraphName(value)
? TRAVEL_ITEM_CORE_CYPHER
: isAnyTravelGraphName(value)
? TRAVEL_DEFAULT_CYPHER
: GENERIC_DEFAULT_CYPHER;
}
function modeLabel(mode?: string): string {
@@ -1355,7 +1375,25 @@ export default function GraphCanvas() {
const currentGraphName = overview.graph_name || getProjectContext().graphName;
const isTravelGraph = isAnyTravelGraphName(currentGraphName);
const isTravelItemGraph = isTravelItemGraphName(currentGraphName);
const queryTemplates = isTravelItemGraph ? [
const isYunyouLiboGraph = isYunyouLiboGraphName(currentGraphName);
const queryTemplates = isYunyouLiboGraph ? [
{ label: "四类业务全景", cypher: LIBO_DEFAULT_CYPHER },
{ label: "全部关系抽样", cypher: GENERIC_DEFAULT_CYPHER },
{ label: "酒店", cypher: "MATCH (n:Hotel) RETURN n LIMIT 150" },
{ label: "美食", cypher: "MATCH (n:FoodPlace) RETURN n LIMIT 150" },
{ label: "交通设施", cypher: "MATCH (n:TransitFacility) RETURN n LIMIT 150" },
{ label: "景点", cypher: "MATCH (n:ScenicSpot) RETURN n LIMIT 120" },
{ label: "景区", cypher: "MATCH (n:ScenicArea) RETURN n LIMIT 20" },
{ label: "公交线路停靠", cypher: "MATCH (line:BusRoute)-[r:STOPS_AT]->(stop:BusStop) RETURN line,r,stop LIMIT 350" },
{ label: "公交相邻站", cypher: "MATCH (a:BusStop)-[r:NEXT_STOP]->(b:BusStop) RETURN a,r,b LIMIT 320" },
{ label: "景点归属", cypher: "MATCH (spot:ScenicSpot)-[r:PART_OF_SCENIC_AREA]->(parent) RETURN spot,r,parent LIMIT 120" },
{ label: "POI 所在荔波县", cypher: "MATCH (poi:Place)-[r:LOCATED_IN]->(area:Area) WHERE area.adcode = '522722' RETURN poi,r,area LIMIT 300" },
{
label: "POI 所属片区",
cypher: "MATCH (poi:Place)-[r:IN_H3_R9]->(cell:GeoCell)-[areaRel:LOCATED_IN]->(area:Area) RETURN poi,r,cell,areaRel,area LIMIT 50",
},
{ label: "行政区层级", cypher: "MATCH (child:Area)-[r:PART_OF]->(parent:Area) RETURN child,r,parent LIMIT 20" },
] : isTravelItemGraph ? [
{ label: "核心业务图", cypher: TRAVEL_ITEM_CORE_CYPHER },
{ label: "固定线路骨架", cypher: TRAVEL_ITEM_ROUTE_SKELETON_CYPHER },
{ label: "景区附近酒店餐饮", cypher: TRAVEL_ITEM_NEARBY_RESOURCE_CYPHER },
@@ -1408,6 +1446,8 @@ export default function GraphCanvas() {
? "输入自然语言或 Cypher,例如:黄果树天星桥有哪些必付小交通和可选二消?"
: isTravelGraph
? "输入自然语言或 Cypher,例如:一家5人6天想去黄果树小七孔西江梵净山,推荐什么线路?"
: isYunyouLiboGraph
? "输入自然语言或 Cypher,例如:查询荔波县的酒店、美食、交通设施或景点"
: "输入自然语言或 Cypher,例如:花溪公园有哪些历史事件?";
const selectedTrafficInfo = selectedNode ? collectTrafficInfo(selectedNode, dataRef.current) : null;
@@ -1488,7 +1528,11 @@ export default function GraphCanvas() {
<Space split="·" wrap>
{resultInfo && <span style={{ color: "#52c41a", fontSize: 12 }}>{resultInfo}</span>}
<span style={{ color: "#999", fontSize: 12 }}>
{isTravelGraph ? "默认展示固定线路主干;费用/规则/审计关系按需查询" : "支持自然语言和 Cypher"} · 单击看详情 · 双击展开关联 · 滚轮缩放 · 拖拽平移
{isTravelGraph
? "默认展示固定线路主干;费用/规则/审计关系按需查询"
: isYunyouLiboGraph
? "快速查询已按荔波县酒店、美食、交通、景点、公交和空间关系配置"
: "支持自然语言和 Cypher"} · 单击看详情 · 双击展开关联 · 滚轮缩放 · 拖拽平移
</span>
</Space>
{queryMeta?.cypher && (
+177 -11
View File
@@ -21,6 +21,7 @@ import {
import {
ArrowLeftOutlined,
CheckCircleOutlined,
FileMarkdownOutlined,
FileSearchOutlined,
InboxOutlined,
SearchOutlined,
@@ -122,6 +123,7 @@ type ManualTaskRow = {
status: string;
owner: string;
createdAt: string;
markdownDocument?: string;
error?: string;
};
@@ -144,6 +146,8 @@ type ManualIngestSession = {
taskCreatedAt: string;
taskRecord: ManualTaskRow | null;
result: ExtractResult | null;
convertedMarkdownFile?: string;
convertedMarkdownPath?: string;
updatedAt: number;
};
@@ -481,6 +485,32 @@ function SourceHighlighter({ text, hit }: { text: string; hit: EvidenceHit | nul
return <div style={{ whiteSpace: "pre-wrap", lineHeight: 1.8, fontSize: 13 }}>{text}</div>;
}
function MarkdownPreview({ markdown }: { markdown: string }) {
if (!markdown.trim()) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 Markdown 内容" />;
}
return (
<div className="manual-markdown-preview">
{markdown.split(/\r?\n/).map((line, index) => {
const key = `${index}-${line.slice(0, 16)}`;
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
const level = Math.min(heading[1].length, 4) as 1 | 2 | 3 | 4;
return <Title key={key} level={level}>{heading[2]}</Title>;
}
if (!line.trim()) return <div key={key} className="manual-markdown-blank" />;
if (/^\s*([-*+]|\d+[.)])\s+\S/.test(line)) {
return <div key={key} className="manual-markdown-list-item">{line.replace(/^\s*([-*+]|\d+[.)])\s+/, "")}</div>;
}
if (line.includes("|") && line.split("|").length >= 3) {
return <pre key={key} className="manual-markdown-table-line">{line}</pre>;
}
return <p key={key}>{line}</p>;
})}
</div>
);
}
function connectedPreviewGraph(graph?: { nodes: KgRow[]; edges: KgRow[] }) {
const allNodes = graph?.nodes || [];
const nodeIds = new Set(allNodes.map((n) => String(n.id)));
@@ -713,6 +743,14 @@ export default function ManualIngestPanel() {
const [taskRecord, setTaskRecord] = useState<ManualTaskRow | null>(null);
const [hit, setHit] = useState<EvidenceHit | null>(null);
const [sessionHydrated, setSessionHydrated] = useState(false);
const [fileConverting, setFileConverting] = useState(false);
const [convertedMarkdownFile, setConvertedMarkdownFile] = useState("");
const [convertedMarkdownPath, setConvertedMarkdownPath] = useState("");
const [markdownPreviewOpen, setMarkdownPreviewOpen] = useState(false);
const [markdownPreviewLoading, setMarkdownPreviewLoading] = useState(false);
const [markdownPreviewText, setMarkdownPreviewText] = useState("");
const [markdownPreviewPath, setMarkdownPreviewPath] = useState("");
const [markdownPreviewTab, setMarkdownPreviewTab] = useState("preview");
const summary = result?.summary || {};
const hasResult = !!result;
@@ -748,7 +786,10 @@ export default function ManualIngestPanel() {
setTaskCreatedAt(cached.taskCreatedAt || "");
setTaskRecord(cached.taskRecord || null);
setResult(cached.result || null);
setConvertedMarkdownFile(cached.convertedMarkdownFile || "");
setConvertedMarkdownPath(cached.convertedMarkdownPath || "");
setLoading(false);
setFileConverting(false);
setHit(null);
setResultOpen(false);
setSessionHydrated(true);
@@ -815,6 +856,10 @@ export default function ManualIngestPanel() {
};
const runExtract = async () => {
if (fileConverting) {
message.info("请等待文件转换完成");
return;
}
if (!text.trim()) {
message.warning("请先上传或粘贴文档内容");
return;
@@ -838,6 +883,7 @@ export default function ManualIngestPanel() {
status: "执行中",
owner: "111111",
createdAt,
markdownDocument: convertedMarkdownFile,
};
setTaskCreatedAt(createdAt);
setTaskRecord(pendingTask);
@@ -866,6 +912,8 @@ export default function ManualIngestPanel() {
taskCreatedAt: createdAt,
taskRecord: pendingTask,
result: null,
convertedMarkdownFile,
convertedMarkdownPath,
updatedAt: Date.now(),
}, true);
try {
@@ -908,6 +956,8 @@ export default function ManualIngestPanel() {
taskCreatedAt: createdAt,
taskRecord: completedTask,
result: data,
convertedMarkdownFile,
convertedMarkdownPath,
updatedAt: Date.now(),
}, true);
setResultOpen(false);
@@ -937,6 +987,8 @@ export default function ManualIngestPanel() {
taskCreatedAt: createdAt,
taskRecord: failedTask,
result: null,
convertedMarkdownFile,
convertedMarkdownPath,
updatedAt: Date.now(),
}, true);
message.error(errorDetail);
@@ -998,6 +1050,7 @@ export default function ManualIngestPanel() {
const taskRows = taskRecord ? [taskRecord] : [];
const displayTaskName = taskRecord?.name || documentName;
const displayUploadDocument = taskRecord?.document || uploadDocument;
const displayMarkdownDocument = taskRecord?.markdownDocument || convertedMarkdownFile || convertedMarkdownPath;
const displayBusinessScene = taskRecord?.businessScene || selectedScene.label;
const displaySceneRule = taskRecord?.sceneRule || selectedScene.schema;
const displaySegmentStrategy = taskRecord?.segmentStrategy || STRATEGY_LABELS[extractStrategy];
@@ -1008,6 +1061,9 @@ export default function ManualIngestPanel() {
const logLines = [
`${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: create knowledge extraction task:${displayTaskName}`,
`${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: upload document:${displayUploadDocument}`,
displayMarkdownDocument
? `${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: markitdown markdown:${displayMarkdownDocument}`
: "",
`${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: business scene:${displayBusinessScene}`,
`${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: auto schema:${displaySceneRule}`,
`${taskCreatedAt || new Date().toLocaleString("zh-CN", { hour12: false })}: knowledge llm:${displayExtractModel}`,
@@ -1115,6 +1171,8 @@ export default function ManualIngestPanel() {
taskCreatedAt,
taskRecord,
result,
convertedMarkdownFile,
convertedMarkdownPath,
updatedAt: Date.now(),
});
}, [
@@ -1136,6 +1194,8 @@ export default function ManualIngestPanel() {
taskCreatedAt,
taskRecord,
result,
convertedMarkdownFile,
convertedMarkdownPath,
]);
const fillSample = () => {
@@ -1157,6 +1217,11 @@ export default function ManualIngestPanel() {
setAllowSchemaExtra(false);
setDirectImport(false);
setText("");
setConvertedMarkdownFile("");
setConvertedMarkdownPath("");
setMarkdownPreviewOpen(false);
setMarkdownPreviewText("");
setMarkdownPreviewPath("");
setHit(null);
setWizardStep(0);
};
@@ -1190,8 +1255,61 @@ export default function ManualIngestPanel() {
setBusinessScene(nextScene.scene);
};
const convertUploadToMarkdown = async (file: File) => {
setFileConverting(true);
setHit(null);
const formData = new FormData();
formData.append("file", file);
try {
const { data } = await api.post("/manual-ingest/convert-file", formData);
const markdown = String(data?.markdown || "");
if (!markdown.trim()) {
message.warning("文件已上传,但未转换出可抽取文本");
return;
}
const filename = String(data?.filename || file.name);
const markdownPath = String(data?.markdown_path || "");
setText(markdown);
setSourceUrl(filename);
setFormat("markdown");
setConvertedMarkdownFile(markdownPath || `${filename}.markitdown.md`);
setConvertedMarkdownPath(markdownPath);
if (!sourceName.trim()) {
setSourceName(filename.replace(/\.[^.]+$/, ""));
}
message.success(`已转换为 MarkItDown Markdown,共 ${Number(data?.markdown_chars || markdown.length)} 字`);
} catch (err: any) {
message.error(err?.response?.data?.detail || err?.message || "文件转换失败");
} finally {
setFileConverting(false);
}
};
const openMarkdownPreview = async () => {
const path = convertedMarkdownPath || convertedMarkdownFile;
setMarkdownPreviewPath(path);
setMarkdownPreviewText(text);
setMarkdownPreviewTab("preview");
setMarkdownPreviewOpen(true);
if (!path) return;
setMarkdownPreviewLoading(true);
try {
const { data } = await api.get("/manual-ingest/markdown-preview", { params: { path } });
setMarkdownPreviewText(String(data?.markdown || text));
setMarkdownPreviewPath(String(data?.markdown_path || path));
} catch (err: any) {
message.warning(err?.response?.data?.detail || "读取 Markdown 文件失败,先显示当前文档内容");
} finally {
setMarkdownPreviewLoading(false);
}
};
const nextStep = () => {
if (wizardStep === 0) {
if (fileConverting) {
message.info("请等待文件转换完成");
return;
}
if (!sourceName.trim()) {
message.warning("请输入知识任务名称");
return;
@@ -1232,22 +1350,27 @@ export default function ManualIngestPanel() {
<label><span>*</span> 上传文件</label>
<Upload.Dragger
className="manual-task-uploader"
accept=".txt,.csv,.json,.xml,.log,.md,.pdf"
accept=".txt,.csv,.json,.xml,.log,.md,.markdown,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.html,.htm,.zip,.epub,.jpg,.jpeg,.png,.gif,.wav,.mp3"
showUploadList={false}
disabled={fileConverting}
beforeUpload={(file) => {
const reader = new FileReader();
reader.onload = () => {
setText(String(reader.result || ""));
setSourceUrl(file.name);
};
reader.readAsText(file);
return false;
void convertUploadToMarkdown(file as File);
return Upload.LIST_IGNORE;
}}
>
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
<p className="ant-upload-text">点击或将文件拖拽到这里上传</p>
<p className="ant-upload-hint">支持拓展名:txt,csv,json,xml,log,md,pdf</p>
<p className="ant-upload-text">{fileConverting ? "正在转换为 MarkItDown Markdown..." : "点击或将文件拖拽到这里上传"}</p>
<p className="ant-upload-hint">支持 PDF、Word、PPT、Excel、HTML、Markdown、CSV、JSON、XML、图片、音频和 ZIP</p>
</Upload.Dragger>
{convertedMarkdownFile && (
<button type="button" className="manual-markdown-note" onClick={openMarkdownPreview}>
<FileMarkdownOutlined />
<Text type="secondary" ellipsis={{ tooltip: convertedMarkdownFile }}>
已生成 MarkItDown 文件:{convertedMarkdownFile}
</Text>
<span>查看</span>
</button>
)}
</div>
<div className="manual-form-row">
@@ -1256,7 +1379,7 @@ export default function ManualIngestPanel() {
value={text}
onChange={(event) => setText(event.target.value)}
rows={5}
placeholder="也可以直接粘贴文档内容"
placeholder="上传文件会自动转换为 MarkItDown Markdown;也可以直接粘贴文档内容"
/>
</div>
@@ -1286,6 +1409,7 @@ export default function ManualIngestPanel() {
onChange={setFormat}
options={[
{ value: "auto", label: "自动识别格式" },
{ value: "markdown", label: "MarkItDown Markdown" },
{ value: "text", label: "自由文本" },
{ value: "csv", label: "CSV / 表格" },
{ value: "kv", label: "键值对" },
@@ -1495,11 +1619,53 @@ export default function ManualIngestPanel() {
</div>
</Drawer>
<Drawer
title="MarkItDown Markdown 预览"
open={markdownPreviewOpen}
onClose={() => setMarkdownPreviewOpen(false)}
width={820}
>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<Alert
type="info"
showIcon
message={markdownPreviewPath || "当前文档内容"}
description={markdownPreviewLoading ? "正在读取生成的 Markdown 文件..." : `共 ${markdownPreviewText.length} 字`}
/>
<Tabs
activeKey={markdownPreviewTab}
onChange={setMarkdownPreviewTab}
items={[
{
key: "preview",
label: "预览",
children: <MarkdownPreview markdown={markdownPreviewText} />,
},
{
key: "source",
label: "源码",
children: (
<TextArea
value={markdownPreviewText}
readOnly
autoSize={{ minRows: 18, maxRows: 30 }}
className="manual-markdown-source"
/>
),
},
]}
/>
</Space>
</Drawer>
<Drawer title="查看配置" open={configOpen} onClose={() => setConfigOpen(false)} width={520}>
<div className="manual-config-list">
<div><Text type="secondary">任务名称</Text><strong>{displayTaskName}</strong></div>
<div><Text type="secondary">文件类型</Text><strong>{displayFileType}</strong></div>
<div><Text type="secondary">上传文档</Text><strong>{displayUploadDocument}</strong></div>
{displayMarkdownDocument && (
<div><Text type="secondary">MarkItDown 文件</Text><strong>{displayMarkdownDocument}</strong></div>
)}
<div><Text type="secondary">业务场景</Text><strong>{displayBusinessScene}</strong></div>
<div><Text type="secondary">场景规则</Text><strong>{displaySceneRule}</strong></div>
<div><Text type="secondary">分段方式</Text><strong>{displaySegmentStrategy}</strong></div>
@@ -0,0 +1,122 @@
import { useState } from "react";
import {
Alert, Button, Card, Descriptions, Input, Space, Tag, Upload, message,
} from "antd";
import { IdcardOutlined, UploadOutlined } from "@ant-design/icons";
import type { UploadFile } from "antd";
import { ocrPassport, ocrPassportByUrl } from "../../api";
type OcrResult = Record<string, any>;
const PRIMARY_LABELS: Record<string, string> = {
name: "姓名",
given_name: "名 (Given Name)",
family_name: "姓 (Surname)",
sex: "性别",
nationality: "国籍",
country: "国家代码",
passport_no: "护照号",
birth_date: "出生日期",
date_of_birth: "出生日期",
expiry_date: "到期日期",
date_of_expiry: "到期日期",
issue_date: "签发日期",
date_of_issue: "签发日期",
issue_place: "签发地",
place_of_birth: "出生地",
authority: "签发机关",
};
function labelOf(key: string) {
return PRIMARY_LABELS[key] || key;
}
export default function PassportOcrCard() {
const [file, setFile] = useState<UploadFile | null>(null);
const [url, setUrl] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<OcrResult | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const reset = () => {
setResult(null);
setErrorMsg(null);
};
const submit = async () => {
reset();
if (!file && !url.trim()) {
message.warning("请上传护照图片或填写图片 URL");
return;
}
setLoading(true);
try {
const resp = file?.originFileObj
? await ocrPassport(file.originFileObj as File)
: await ocrPassportByUrl(url.trim());
setResult(resp.data?.data || {});
} catch (err: any) {
const detail = err?.response?.data?.detail || err?.message || "识别失败";
setErrorMsg(String(detail));
} finally {
setLoading(false);
}
};
const requestId = result?._request_id;
const fields = result
? Object.entries(result).filter(([k]) => k !== "_request_id")
: [];
return (
<Card
className="travel-panel"
size="small"
title={<><IdcardOutlined /> 护照识别(旅行社名单录入)</>}
extra={requestId && <Tag color="blue">RequestId {String(requestId).slice(0, 12)}…</Tag>}
>
<Space direction="vertical" style={{ width: "100%" }} size="middle">
<Space wrap>
<Upload
beforeUpload={(f) => {
setFile({ uid: f.uid, name: f.name, originFileObj: f } as UploadFile);
return false;
}}
onRemove={() => setFile(null)}
fileList={file ? [file] : []}
maxCount={1}
accept="image/*"
>
<Button icon={<UploadOutlined />}>选择护照图片</Button>
</Upload>
<Input
placeholder="或粘贴公网图片 URL(如 OSS 链接)"
value={url}
onChange={(e) => setUrl(e.target.value)}
style={{ width: 340 }}
allowClear
/>
<Button type="primary" loading={loading} onClick={submit}>
识别
</Button>
</Space>
{errorMsg && <Alert type="error" showIcon message="识别失败" description={errorMsg} />}
{result && (
fields.length ? (
<Descriptions size="small" bordered column={2}>
{fields.map(([key, value]) => (
<Descriptions.Item key={key} label={labelOf(key)}>
{typeof value === "object" ? JSON.stringify(value) : String(value ?? "")}
</Descriptions.Item>
))}
</Descriptions>
) : (
<Alert type="warning" showIcon message="阿里云返回空结果,可能图片质量不足或非护照页" />
)
)}
</Space>
</Card>
);
}
+32 -5
View File
@@ -11,20 +11,30 @@ import {
graphOverview,
graphQuery,
plazaOverview,
superAgentCoverage,
type KgProjectContext,
} from "../../api";
import SuperAgentPanel from "./SuperAgentPanel";
import SuperAgentPanel, { KnowledgeMap, type CoverageSnapshot } from "./SuperAgentPanel";
const STATUS_COLORS: Record<string, string> = {
active: "green", published: "blue", draft: "default", archived: "orange",
};
const GRAPH_CATEGORY_CYPHER =
"MATCH (n:Place) RETURN n.place_type AS place_type, n.station_type AS station_type, count(n) AS count LIMIT 200";
"MATCH (n:Place) WHERE size(labels(n)) = 1 "
+ "RETURN n.place_type AS place_type, n.station_type AS station_type, count(n) AS count LIMIT 200";
const LABEL_NAMES: Record<string, string> = {
Area: "行政区",
BusLine: "公交线路",
BusRoute: "公交运行方向",
BusStop: "公交站",
FoodPlace: "美食",
GeoCell: "空间网格",
Hotel: "酒店",
ScenicArea: "景区",
ScenicSpot: "景点",
TransitFacility: "交通设施",
ExperienceTag: "体验标签",
RouteTemplate: "线路模板",
Source: "来源",
@@ -37,7 +47,9 @@ const PLACE_TYPE_NAMES: Record<string, string> = {
hotel: "酒店",
mall: "商场",
poi: "其他地点",
recreation: "体育休闲",
sight: "景点",
transit: "交通设施",
transit_stop: "交通站点",
walk: "漫步",
};
@@ -58,20 +70,28 @@ export default function PlazaOverview() {
const [data, setData] = useState<Record<string, unknown>>({});
const [graphData, setGraphData] = useState<Record<string, unknown>>({});
const [graphCategories, setGraphCategories] = useState<EntityCategory[]>([]);
const [mapCoverage, setMapCoverage] = useState<CoverageSnapshot | null>(null);
const [projectContext, setProjectContextState] = useState<KgProjectContext>(getProjectContext());
const navigate = useNavigate();
const fetch = async () => {
const currentContext = getProjectContext();
const isCityProject = currentContext.graphName === DEFAULT_PROJECT_CONTEXT.graphName;
const isLiboProject = currentContext.graphName === "yunyou_libo"
|| currentContext.projectId === "yunyou_libo";
const isKnowledgeMapProject = isCityProject || isLiboProject;
const [plaza, graph] = await Promise.all([plazaOverview(), graphOverview()]);
const placeTypeRows = isCityProject
const placeTypeRows = isKnowledgeMapProject
? await graphQuery(GRAPH_CATEGORY_CYPHER).then((res) => res.data.rows || []).catch(() => [])
: [];
const coverage = isLiboProject
? await superAgentCoverage().then((res) => res.data).catch(() => null)
: null;
setProjectContextState(currentContext);
setData(plaza.data);
setGraphData(graph.data);
setGraphCategories(isCityProject ? buildGraphCategories(graph.data, placeTypeRows) : []);
setGraphCategories(isKnowledgeMapProject ? buildGraphCategories(graph.data, placeTypeRows) : []);
setMapCoverage(coverage);
};
useEffect(() => {
@@ -84,13 +104,20 @@ export default function PlazaOverview() {
const categories = graphCategories.length ? graphCategories : ((data.categories as Array<Record<string, any>>) || []);
const releases = (data.graph_releases as Array<Record<string, unknown>>) || [];
const isCityProject = projectContext.graphName === DEFAULT_PROJECT_CONTEXT.graphName;
const pageTitle = isCityProject ? "知识广场 · 馆藏概览" : "业务资料库 · 馆藏概览";
const isLiboProject = projectContext.graphName === "yunyou_libo"
|| projectContext.projectId === "yunyou_libo";
const pageTitle = isCityProject
? "知识广场 · 馆藏概览"
: isLiboProject
? "知识广场 · 云游荔波馆藏概览"
: "业务资料库 · 馆藏概览";
return (
<div>
<h2>{pageTitle}</h2>
{isCityProject && <SuperAgentPanel />}
{isLiboProject && <KnowledgeMap cov={mapCoverage} scope="libo" />}
{/* Stat cards */}
<Row gutter={16} style={{ marginBottom: 24 }}>
File diff suppressed because it is too large. Load diff
@@ -14,6 +14,7 @@ import {
ThunderboltOutlined,
} from "@ant-design/icons";
import { travelAssistantQuery } from "../../api";
import PassportOcrCard from "./PassportOcrCard";
const { Paragraph, Text, Title } = Typography;
const { TextArea } = Input;
@@ -134,7 +135,7 @@ export default function TravelAgencyAssistantPanel() {
const [payload, setPayload] = useState<AssistantPayload | null>(null);
const [returnState, setReturnState] = useState<{ question: string; payload: AssistantPayload } | null>(null);
const [loading, setLoading] = useState(false);
const [useLlm, setUseLlm] = useState(false);
const [useLlm] = useState(true);
const run = async (value = question, options: { preservePrevious?: boolean } = {}) => {
const text = value.trim();
@@ -163,7 +164,7 @@ export default function TravelAgencyAssistantPanel() {
<h2>客服行程方案助手</h2>
<Space wrap>
<Tag color="green">旅行社资料库</Tag>
<Tag color="blue">规则快速解析</Tag>
<Tag color="blue">LLM 图查询</Tag>
<Tag color="purple">知识图谱检索</Tag>
{payload?.trace?.plan_candidates !== undefined && (
<Tag color="cyan">候选方案 {payload.trace.plan_candidates}</Tag>
@@ -180,6 +181,10 @@ export default function TravelAgencyAssistantPanel() {
<AgentTestConsole payload={payload} />
<div style={{ marginBottom: 16 }}>
<PassportOcrCard />
</div>
<Row gutter={16} align="stretch">
<Col xs={24} xl={7}>
<Card className="travel-panel" title={<><SendOutlined /> 客户咨询</>} size="small">
@@ -191,9 +196,9 @@ export default function TravelAgencyAssistantPanel() {
/>
<div className="travel-llm-toggle">
<Space>
<Switch size="small" checked={useLlm} onChange={setUseLlm} />
<Text type="secondary">大模型意图解析</Text>
<Tag color={useLlm ? "blue" : "default"}>{useLlm ? "已开启" : "默认关闭"}</Tag>
<Switch size="small" checked={useLlm} disabled />
<Text type="secondary">大模型图查询</Text>
<Tag color="blue">固定启用</Tag>
</Space>
</div>
<Button
@@ -317,8 +322,8 @@ function AgentTestConsole({ payload }: { payload: AssistantPayload | null }) {
: [
{ name: "接收问题", status: "ready", detail: "客服输入或客服系统 API 传入客户原话。" },
{ name: "意图解析", status: "ready", detail: "抽取人数、天数、景点、酒店、用车和预算。" },
{ name: "图谱召回", status: "ready", detail: "按线路、景点、酒店、餐饮、车辆、费用规则查询。" },
{ name: "排序/组合", status: "ready", detail: "返回固定线路或可替换资源池。" },
{ name: "图谱召回", status: "ready", detail: "LLM 生成 Cypher 后执行 FalkorDB 只读查询。" },
{ name: "答案组织", status: "ready", detail: "基于图查询证据组织客服话术、风险边界和追问。" },
{ name: "证据边界", status: "ready", detail: "输出核价、余位、不可承诺项和追问。" },
];
const summary = trace.quality_summary || {};
@@ -366,7 +366,7 @@ export default function UserExperiencePanel() {
trace,
plans: data?.plans || [],
follow_up_questions: data?.follow_up_questions || [],
risk_notes: warnings,
risk_notes: Array.isArray(data?.risk_notes) && data.risk_notes.length ? data.risk_notes : warnings,
});
setActiveId("");
return;
+391 -3
View File
@@ -1,11 +1,11 @@
import { useEffect, useState, useCallback } from "react";
import {
Card, Form, Input, InputNumber, Switch, Button, message, Row, Col,
Spin, Tag, Space, Divider, Tooltip,
Spin, Tag, Space, Divider, Tooltip, Alert,
} from "antd";
import {
SaveOutlined, RobotOutlined, EyeOutlined, ThunderboltOutlined,
EditOutlined, UpOutlined, PlusOutlined, DeleteOutlined,
EditOutlined, UpOutlined, PlusOutlined, DeleteOutlined, CopyOutlined,
} from "@ant-design/icons";
import { Select } from "antd";
@@ -28,6 +28,19 @@ type DistillModel = {
};
type Config = {
global: { base_url: string; model: string; api_key: string; timeout: number };
api_access: {
api_keys: string;
default_graph: string;
default_use_llm: boolean;
default_llm_fusion: boolean;
qa_llm: {
base_url: string;
model: string;
api_key: string;
timeout: number;
max_tokens?: number | null;
};
};
thresholds: { audit_hit: number; audit_gap: number; aligner_auto_merge: number };
agents: Record<string, AgentCfg>;
distill: {
@@ -70,6 +83,55 @@ export default function AgentSettings() {
const setGlobal = (k: string, v: any) =>
setCfg((c) => c && ({ ...c, global: { ...c.global, [k]: v } }));
const setApiAccess = (k: string, v: any) =>
setCfg((c) => {
if (!c) return c;
const current = c.api_access || {
api_keys: "",
default_graph: "baixinghui_travel_agency",
default_use_llm: true,
default_llm_fusion: true,
qa_llm: {
base_url: "",
model: "deepseek-chat",
api_key: "",
timeout: 30,
max_tokens: 1600,
},
};
return { ...c, api_access: { ...current, [k]: v } };
});
const setQaLlm = (k: string, v: any) =>
setCfg((c) => {
if (!c) return c;
const current = c.api_access || {
api_keys: "",
default_graph: "baixinghui_travel_agency",
default_use_llm: true,
default_llm_fusion: true,
qa_llm: {
base_url: "",
model: "deepseek-chat",
api_key: "",
timeout: 30,
max_tokens: 1600,
},
};
const qaLlm = current.qa_llm || {
base_url: "",
model: "deepseek-chat",
api_key: "",
timeout: 30,
max_tokens: 1600,
};
return {
...c,
api_access: {
...current,
qa_llm: { ...qaLlm, [k]: v },
},
};
});
const setThreshold = (k: string, v: any) =>
setCfg((c) => c && ({ ...c, thresholds: { ...c.thresholds, [k]: v } }));
const setAgent = (key: string, k: string, v: any) =>
@@ -176,6 +238,92 @@ export default function AgentSettings() {
}
const labelStyle = { fontSize: 13, color: "#666", marginBottom: 4 };
const apiOrigin = typeof window !== "undefined" ? window.location.origin : "";
const openApiPath = "/v1/openapi/knowledge-qa/query";
const compatApiPath = "/v1/admin/travel/customer-service-query";
const openApiUrl = `${apiOrigin}${openApiPath}`;
const compatApiUrl = `${apiOrigin}${compatApiPath}`;
const authHeaderExample = "X-KG-API-Key: <由管理员提供的接口 Key>";
const requestExample = `{
"request_id": "crm-msg-20260610-0001",
"question": "黄小西三日游多少钱?",
"graph_name": "baixinghui_travel_agency",
"session_id": "customer-session-001",
"channel": "online_service",
"customer_id": "customer-001",
"use_llm": true,
"llm_fusion": true
}`;
const curlExample = `curl ${openApiUrl} \\
-H 'Content-Type: application/json' \\
-H '${authHeaderExample}' \\
-d '${requestExample}'`;
const responseExample = `{
"request_id": "crm-msg-20260610-0001",
"trace_id": "trc_xxx",
"graph_name": "baixinghui_travel_agency",
"customer_reply": "可直接发给客户的话术",
"answer": "完整问答结果",
"knowledge": {
"plans": [],
"evidence": []
},
"risk_notes": [],
"trace": {
"latency_ms": 24875,
"response_mode": "llm_graph_qa"
}
}`;
const fallbackCopyText = (text: string) => {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
let copied = false;
try {
copied = document.execCommand("copy");
} catch {
copied = false;
} finally {
document.body.removeChild(textarea);
}
return copied;
};
const copyText = async (text: string, successText = "已复制") => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
message.success(successText);
return;
}
if (fallbackCopyText(text)) {
message.success(successText);
return;
}
await navigator.clipboard.writeText(text);
message.success(successText);
} catch {
message.error("浏览器禁止自动复制,请手动选中文本后复制");
}
};
const codeBlockStyle = {
margin: 0,
padding: 12,
background: "#f7f8fa",
border: "1px solid #e8e8e8",
borderRadius: 6,
fontSize: 12,
lineHeight: 1.6,
whiteSpace: "pre-wrap" as const,
wordBreak: "break-word" as const,
};
return (
<div style={{ maxWidth: "100%", paddingRight: 8,
@@ -189,6 +337,9 @@ export default function AgentSettings() {
{/* Global LLM */}
<Card size="small" title="全局 LLM 配置" style={{ marginBottom: 16 }}>
<p style={{ color: "#999", fontSize: 12, marginTop: 0 }}>
这里配置客服问答、LLM 图查询、意图解析和话术融合默认使用的大模型。模型服务需要兼容 OpenAI Chat Completions。
</p>
<Row gutter={16}>
<Col span={18}>
<div style={labelStyle}>基础 URL</div>
@@ -740,7 +891,244 @@ export default function AgentSettings() {
})}
</Row>
<div style={{ textAlign: "right", marginTop: 8, order: 3 }}>
<Card
size="small"
title="外部图谱问答 API"
style={{ marginTop: 8, marginBottom: 16, order: 4 }}
extra={
<Space size={6}>
<Tag color="green">正式开放</Tag>
<Tag color="blue">Key 鉴权</Tag>
</Space>
}
>
<p style={{ color: "#999", fontSize: 12, marginTop: 0 }}>
给第三方客服系统、CRM 或小程序调用百姓惠知识图谱问答时使用。调用方传入用户自然语言问题,接口返回可直接回复客户的话术和图谱证据。请求头必须带
<code style={{ margin: "0 4px" }}>X-KG-API-Key</code>
,服务端会用这里保存的 Key 校验,并优先使用本块的问答 LLM 模型生成客服话术。
</p>
<Row gutter={16}>
<Col span={12}>
<div style={labelStyle}>正式开放接口(给第三方使用)</div>
<Space.Compact style={{ width: "100%" }}>
<Input value={openApiUrl} readOnly />
<Button icon={<CopyOutlined />} onClick={() => copyText(openApiUrl, "接口地址已复制")}>
复制
</Button>
</Space.Compact>
</Col>
<Col span={12}>
<div style={labelStyle}>认证请求头</div>
<Space.Compact style={{ width: "100%" }}>
<Input value={authHeaderExample} readOnly />
<Button icon={<CopyOutlined />} onClick={() => copyText(authHeaderExample, "请求头已复制")}>
复制
</Button>
</Space.Compact>
<div style={{ color: "#999", fontSize: 12, marginTop: 4 }}>
这里的 <code>API Key</code> 由管理员提供;不是问答模型 API Key,也不会在页面明文展示。
</div>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col span={8}>
<div style={labelStyle}>接口协议</div>
<Input value="POST + JSON / UTF-8" readOnly />
</Col>
<Col span={8}>
<div style={labelStyle}>支持的鉴权方式</div>
<Input value="X-KG-API-Key / X-API-Key / Bearer" readOnly />
</Col>
<Col span={8}>
<div style={labelStyle}>超时建议</div>
<Input value="第三方建议设置 90-180 秒" readOnly />
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col span={12}>
<div style={labelStyle}>兼容旧接口(已有系统可继续用)</div>
<Input value={compatApiUrl} readOnly />
</Col>
<Col span={12}>
<div style={labelStyle}>第三方系统主要读取</div>
<Input value="customer_reply;trace_id 用于排查;knowledge.evidence 用于证据展示" readOnly />
</Col>
</Row>
<div style={{ marginTop: 12, padding: 12, background: "#fafafa", border: "1px solid #f0f0f0", borderRadius: 6 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>第三方接入流程</div>
<ol style={{ margin: 0, paddingLeft: 18, color: "#666", fontSize: 13, lineHeight: 1.8 }}>
<li>在下面填写一个或多个 API Key,点击页面右上角或底部“保存配置”。</li>
<li>把“正式开放接口”和 API Key 发给第三方系统,第三方用 POST JSON 调用。</li>
<li>第三方请求体至少传 <code>question</code>;建议同时传 <code>request_id</code>、<code>session_id</code>、<code>channel</code> 方便排查。</li>
<li>第三方优先使用返回里的 <code>customer_reply</code> 展示给客服或客户;需要审计时保存 <code>trace_id</code> 和 <code>knowledge.evidence</code>。</li>
</ol>
</div>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col span={12}>
<div style={labelStyle}>默认查询图谱(请求未传 graph_name 时使用)</div>
<Input
value={cfg.api_access?.default_graph || "baixinghui_travel_agency"}
onChange={(e) => setApiAccess("default_graph", e.target.value)}
placeholder="baixinghui_travel_agency"
/>
<div style={{ color: "#999", fontSize: 12, marginTop: 4 }}>
第三方可在请求体传 <code>graph_name</code> 覆盖默认图谱;兼容 <code>knowledge_graph</code>、<code>kg</code>、<code>graph</code>。
</div>
</Col>
<Col span={12}>
<div style={labelStyle}>默认 LLM 策略</div>
<Space wrap>
<Space size={6}>
<Switch
size="small"
checked={cfg.api_access?.default_use_llm ?? true}
onChange={(v) => setApiAccess("default_use_llm", v)}
/>
<span style={{ fontSize: 13 }}>意图解析</span>
</Space>
<Space size={6}>
<Switch
size="small"
checked={cfg.api_access?.default_llm_fusion ?? true}
onChange={(v) => setApiAccess("default_llm_fusion", v)}
/>
<span style={{ fontSize: 13 }}>客服话术融合</span>
</Space>
</Space>
</Col>
</Row>
<Divider orientation="left" style={{ marginTop: 18 }}>
第三方调用示例
</Divider>
<Row gutter={16}>
<Col span={12}>
<Space style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
<div style={labelStyle}>请求 JSON</div>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(requestExample, "请求 JSON 已复制")}>
复制
</Button>
</Space>
<pre style={codeBlockStyle}>{requestExample}</pre>
</Col>
<Col span={12}>
<Space style={{ display: "flex", justifyContent: "space-between", marginBottom: 6 }}>
<div style={labelStyle}>curl 联调命令</div>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(curlExample, "curl 示例已复制")}>
复制
</Button>
</Space>
<pre style={codeBlockStyle}>{curlExample}</pre>
</Col>
</Row>
<Divider orientation="left" style={{ marginTop: 18 }}>
请求字段说明
</Divider>
<div style={{ display: "grid", gridTemplateColumns: "170px 90px 1fr", rowGap: 8, columnGap: 12, fontSize: 13 }}>
<strong>字段</strong><strong>是否必填</strong><strong>说明</strong>
<code>question</code><Tag color="red">必填</Tag><span>用户或客服输入的自然语言问题,例如“黄小西三日游多少钱”。兼容 <code>text</code>、<code>query</code>。</span>
<code>request_id</code><Tag>可选</Tag><span>第三方系统请求 ID,用于幂等排查和日志关联;不传时服务端自动生成。</span>
<code>session_id</code><Tag>可选</Tag><span>客服会话 ID,便于把多轮咨询归到同一个会话。</span>
<code>channel</code><Tag>可选</Tag><span>来源渠道,例如 <code>web</code>、<code>wechat</code>、<code>online_service</code>。</span>
<code>customer_id</code><Tag>可选</Tag><span>第三方客户 ID;也兼容 <code>external_user_id</code>。</span>
<code>graph_name</code><Tag>可选</Tag><span>指定查询图谱;不传时使用上方默认图谱。兼容 <code>knowledge_graph</code>、<code>kg</code>、<code>graph</code>。</span>
<code>use_llm</code><Tag>可选</Tag><span>是否启用 LLM 图查询和意图理解,默认跟随本页开关。</span>
<code>llm_fusion</code><Tag>可选</Tag><span>是否让 LLM 基于图谱证据组织客服话术,默认跟随本页开关。</span>
</div>
<Divider orientation="left" style={{ marginTop: 18 }}>
返回字段说明
</Divider>
<Row gutter={16}>
<Col span={12}>
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr", rowGap: 8, columnGap: 12, fontSize: 13 }}>
<strong>customer_reply</strong><span>客服可直接发送给客户的简洁话术。</span>
<strong>answer</strong><span>完整回答,适合后台查看。</span>
<strong>knowledge.plans</strong><span>命中的线路、方案或证据卡片。</span>
<strong>knowledge.evidence</strong><span>图谱证据,适合质检和溯源。</span>
<strong>risk_notes</strong><span>不可直接承诺、需要二次核实的事项。</span>
<strong>trace_id</strong><span>排查问题时给后台技术人员定位链路。</span>
</div>
</Col>
<Col span={12}>
<pre style={codeBlockStyle}>{responseExample}</pre>
</Col>
</Row>
<Divider orientation="left" style={{ marginTop: 18 }}>
常见错误码
</Divider>
<Row gutter={16}>
<Col span={6}><Tag color="red">401</Tag> API Key 缺失或无效</Col>
<Col span={6}><Tag color="orange">400</Tag> 未传 question</Col>
<Col span={6}><Tag color="gold">502</Tag> 图查询或 LLM 调用失败</Col>
<Col span={6}><Tag color="blue">503</Tag> 后台未配置 API Key</Col>
</Row>
<Divider orientation="left" style={{ marginTop: 18 }}>
问答环节 LLM 模型
</Divider>
<p style={{ color: "#999", fontSize: 12, marginTop: -8 }}>
这组模型只服务外部客服问答和话术融合。填写后优先使用这里;留空则继承上方全局 LLM 配置。这里的 API Key 是大模型服务 Key,不是第三方访问接口用的 API Key。
</p>
<Row gutter={16}>
<Col span={12}>
<div style={labelStyle}>基础 URL</div>
<Input
value={cfg.api_access?.qa_llm?.base_url || ""}
onChange={(e) => setQaLlm("base_url", e.target.value)}
placeholder={cfg.global.base_url || "https://api.deepseek.com/v1"}
/>
</Col>
<Col span={12}>
<div style={labelStyle}>模型 ID</div>
<Input
value={cfg.api_access?.qa_llm?.model || ""}
onChange={(e) => setQaLlm("model", e.target.value)}
placeholder={cfg.global.model || "deepseek-chat"}
/>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col span={12}>
<div style={labelStyle}>超时 (秒)</div>
<InputNumber
min={5}
max={300}
style={{ width: "100%" }}
value={cfg.api_access?.qa_llm?.timeout ?? 30}
onChange={(v) => setQaLlm("timeout", v ?? 30)}
/>
</Col>
<Col span={12}>
<div style={labelStyle}>最大输出 tokens(数值,不是 API Key)</div>
<InputNumber
min={256}
max={200000}
step={512}
style={{ width: "100%" }}
value={cfg.api_access?.qa_llm?.max_tokens ?? 1600}
placeholder="如 2048 / 8192 / 131072"
onChange={(v) => setQaLlm("max_tokens", v ?? 1600)}
/>
</Col>
</Row>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col span={24}>
<div style={labelStyle}>问答模型 API Key(大模型服务 Key,留空继承全局)</div>
<Input.Password
value={cfg.api_access?.qa_llm?.api_key || ""}
onChange={(e) => setQaLlm("api_key", e.target.value)}
placeholder="如 sk-...;留空则使用全局 LLM API Key"
autoComplete="new-password"
/>
</Col>
</Row>
<Alert
type="info"
showIcon
style={{ marginTop: 8 }}
message="区分两个 Key:上面的“接口访问 API Key”发给第三方系统;这里的“问答模型 API Key”只用于本系统调用大模型,不应提供给第三方。"
/>
</Card>
<div style={{ textAlign: "right", marginTop: 8, order: 5 }}>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={save}>
保存配置
</Button>
+91
View File
@@ -1204,6 +1204,97 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Ro
font-size: 14px;
}
.manual-markdown-note {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
margin-top: 10px;
padding: 9px 12px;
border: 1px solid #d9e7ff;
border-radius: 8px;
background: #f6faff;
color: inherit;
cursor: pointer;
text-align: left;
transition: border-color .2s ease, background .2s ease;
}
.manual-markdown-note:hover {
border-color: #91caff;
background: #eef6ff;
}
.manual-markdown-note .anticon {
flex: 0 0 auto;
color: #1677ff;
font-size: 16px;
}
.manual-markdown-note .ant-typography {
flex: 1 1 auto;
min-width: 0;
}
.manual-markdown-note > span:last-child {
flex: 0 0 auto;
color: #1677ff;
font-size: 13px;
font-weight: 600;
}
.manual-markdown-preview {
min-height: 360px;
padding: 18px 20px;
border: 1px solid #edf0f5;
border-radius: 8px;
background: #fff;
color: #1f2937;
line-height: 1.75;
}
.manual-markdown-preview h1,
.manual-markdown-preview h2,
.manual-markdown-preview h3,
.manual-markdown-preview h4 {
margin: 14px 0 8px;
}
.manual-markdown-preview p {
margin: 0 0 8px;
}
.manual-markdown-blank {
height: 10px;
}
.manual-markdown-list-item {
position: relative;
margin-bottom: 6px;
padding-left: 18px;
}
.manual-markdown-list-item::before {
position: absolute;
left: 4px;
content: "•";
color: #1677ff;
}
.manual-markdown-table-line,
.manual-markdown-source {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.manual-markdown-table-line {
overflow-x: auto;
margin: 0 0 6px;
padding: 6px 8px;
border-radius: 6px;
background: #f8fafc;
}
.manual-preview-box {
padding: 18px 20px;
border: 1px solid #edf0f5;