Add Cloud Tour Libo knowledge graph platform
This commit is contained in:
1 parent
9e05b09a38
commit
bae5197d62
103 files changed
+14036
-160
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user