Add customer service API deployment support

This commit is contained in:
2026-06-10 10:48:26 +08:00
parent 0594fc9f8c
commit e589073311
8 changed files with 777 additions and 62 deletions

View File

@@ -8,7 +8,7 @@ import {
ExpandOutlined, HeartOutlined, PhoneOutlined, SearchOutlined, SendOutlined,
ShareAltOutlined, ShrinkOutlined,
} from "@ant-design/icons";
import { plazaAmapConfig, plazaUserQuery } from "../../api";
import { displayGraphName, getProjectContext, plazaAmapConfig, plazaUserQuery, travelAssistantQuery } from "../../api";
const { Text } = Typography;
@@ -69,6 +69,7 @@ type QueryResult = {
};
type QueryPayload = {
mode?: "spatial" | "travel_customer_service";
question: string;
graph_name: string;
user_location: { lng: number; lat: number };
@@ -76,6 +77,9 @@ type QueryPayload = {
answer: string;
results: QueryResult[];
trace: Record<string, any>;
plans?: Array<Record<string, any>>;
follow_up_questions?: string[];
risk_notes?: string[];
};
const samples = [
@@ -86,6 +90,14 @@ const samples = [
"附近有没有公交地铁站?",
];
const travelSamples = [
"黄小西三日游多少钱?",
"2天1晚想去黄果树有哪些线路顺便看附近酒店和可选车辆价格",
"黄果树附近有哪些酒店餐饮,价格和余位能直接承诺吗?",
"客户临时多加2人原来5人用车还能坐吗",
"小七孔有哪些必付和可选费用,老人小孩有没有需要注意的?",
];
const formatDistance = (m: number) => {
if (m >= 1000) return `${(m / 1000).toFixed(1)}km`;
return `${Math.round(m)}m`;
@@ -121,6 +133,7 @@ export default function UserExperiencePanel() {
const [fullscreen, setFullscreen] = useState(false);
const [photoIndex, setPhotoIndex] = useState(0);
const [failedPhotos, setFailedPhotos] = useState<string[]>([]);
const [projectContext, setProjectContextState] = useState(() => getProjectContext());
const mapRef = useRef<HTMLDivElement | null>(null);
const mapInstance = useRef<any>(null);
@@ -129,6 +142,25 @@ export default function UserExperiencePanel() {
const results = payload?.results || [];
const intent = payload?.intent;
const graphKey = String(projectContext.graphName || "").toLowerCase();
const isTravelCustomerService = graphKey.includes("baixinghui") || String(projectContext.graphName || "").includes("百姓惠");
const isTravelPayload = payload?.mode === "travel_customer_service";
useEffect(() => {
const updateContext = (event: Event) => {
const detail = (event as CustomEvent).detail;
setProjectContextState(detail || getProjectContext());
};
window.addEventListener("znkg-project-context", updateContext);
return () => window.removeEventListener("znkg-project-context", updateContext);
}, []);
useEffect(() => {
const nextSamples = isTravelCustomerService ? travelSamples : samples;
setQuestion((current) => ([...samples, ...travelSamples].includes(current) ? nextSamples[0] : current));
setPayload(null);
setActiveId("");
}, [isTravelCustomerService]);
useEffect(() => {
let disposed = false;
@@ -305,6 +337,40 @@ export default function UserExperiencePanel() {
setQuestion(text);
setLoading(true);
try {
if (isTravelCustomerService) {
const { data } = await travelAssistantQuery({
question: text,
graph_name: projectContext.graphName,
use_llm: true,
});
const trace = data?.trace || {};
const warnings = Array.isArray(trace.quality_checks)
? trace.quality_checks
.filter((item: any) => item?.status === "warn" || item?.status === "fail")
.map((item: any) => item?.detail || item?.label)
.filter(Boolean)
: [];
setPayload({
mode: "travel_customer_service",
question: text,
graph_name: data?.graph_name || projectContext.graphName,
user_location: { lng, lat },
intent: {
radius_m: radius,
category: trace.response_mode_label || "百姓惠客服",
keywords: data?.intent?.destinations || [],
user_need: text,
},
answer: data?.copy_text || "当前知识图谱没有返回可直接回复的话术,请补充客户出行人数、天数、团期和必去景点后再试。",
results: [],
trace,
plans: data?.plans || [],
follow_up_questions: data?.follow_up_questions || [],
risk_notes: warnings,
});
setActiveId("");
return;
}
const { data } = await plazaUserQuery({
question: text,
lng,
@@ -330,35 +396,50 @@ export default function UserExperiencePanel() {
onChange={(e) => setQuestion(e.target.value)}
onSearch={() => runQuery()}
loading={loading}
placeholder="搜索地点、公交、地铁、美食..."
placeholder={isTravelCustomerService ? "输入客户原话,例如:黄小西三日游多少钱?" : "搜索地点、公交、地铁、美食..."}
/>
<div className="kg-user-controls">
<div className="kg-user-control-title">
<Tag color="blue"></Tag>
<Button size="small" icon={<AimOutlined />} onClick={() => { setLng(106.702501); setLat(26.55806); }}>
</Button>
</div>
<div className="kg-user-control-grid">
<label>
<span></span>
<InputNumber value={lng} precision={6} onChange={(v) => setLng(Number(v || lng))} />
</label>
<label>
<span></span>
<InputNumber value={lat} precision={6} onChange={(v) => setLat(Number(v || lat))} />
</label>
<label>
<span></span>
<InputNumber min={100} max={10000} step={100} value={radius} onChange={(v) => setRadius(Number(v || 1000))} />
</label>
</div>
{isTravelCustomerService ? (
<div className="kg-user-travel-mode">
<Tag color="green"></Tag>
<Text type="secondary">{displayGraphName(projectContext.graphName)}</Text>
</div>
) : (
<>
<div className="kg-user-control-title">
<Tag color="blue"></Tag>
<Button size="small" icon={<AimOutlined />} onClick={() => { setLng(106.702501); setLat(26.55806); }}>
</Button>
</div>
<div className="kg-user-control-grid">
<label>
<span></span>
<InputNumber value={lng} precision={6} onChange={(v) => setLng(Number(v || lng))} />
</label>
<label>
<span></span>
<InputNumber value={lat} precision={6} onChange={(v) => setLat(Number(v || lat))} />
</label>
<label>
<span></span>
<InputNumber min={100} max={10000} step={100} value={radius} onChange={(v) => setRadius(Number(v || 1000))} />
</label>
</div>
</>
)}
</div>
<div className="kg-user-result-head">
<b><CompassOutlined /> </b>
{payload ? <span>{results.length} · {intent?.category || "综合"} · {intent?.radius_m || radius}m</span> : null}
{payload ? (
<span>
{isTravelPayload
? `${displayGraphName(payload.graph_name)} · ${intent?.category || "客服问答"} · 方案 ${(payload.plans || []).length}`
: `${results.length} 个结果 · ${intent?.category || "综合"} · ${intent?.radius_m || radius}m`}
</span>
) : null}
</div>
<div className="kg-user-results">
@@ -370,7 +451,41 @@ export default function UserExperiencePanel() {
<>
<div className="kg-user-answer">{payload.answer}</div>
{results.length === 0 ? (
<Empty description="当前半径内没有匹配结果" style={{ marginTop: 70 }} />
isTravelPayload ? (
<div className="kg-user-travel-summary">
{(payload.plans || []).slice(0, 4).map((plan, idx) => (
<div className="kg-user-travel-plan" key={`${plan.plan_name || idx}`}>
<Space size={6} wrap>
<Tag color="blue">{idx + 1}</Tag>
<b>{plan.plan_name || "未命名线路"}</b>
{plan.duration_days && <Tag>{plan.duration_days}</Tag>}
{plan.fit_score && <Tag color="purple"> {plan.fit_score}</Tag>}
</Space>
<p>{plan.route_summary || plan.quote_summary || plan.variant_summary || "图谱已命中该线路,更多细节请继续追问。"}</p>
</div>
))}
{!!payload.follow_up_questions?.length && (
<div className="kg-user-travel-followups">
<Text strong></Text>
<Space wrap>
{payload.follow_up_questions.slice(0, 4).map((item) => (
<Tag color="gold" key={item} onClick={() => runQuery(item)}>{item}</Tag>
))}
</Space>
</div>
)}
{!!payload.risk_notes?.length && (
<Alert
type="warning"
showIcon
message="客服承诺边界"
description={payload.risk_notes.slice(0, 3).join("")}
/>
)}
</div>
) : (
<Empty description="当前半径内没有匹配结果" style={{ marginTop: 70 }} />
)
) : results.map((r, idx) => {
const photo = firstPhoto(r);
return (
@@ -587,6 +702,12 @@ export default function UserExperiencePanel() {
.kg-user-control-grid .ant-input-number {
width: 100%;
}
.kg-user-travel-mode {
display: flex;
flex-direction: column;
gap: 8px;
line-height: 1.6;
}
.kg-user-result-head {
display: flex;
align-items: center;
@@ -614,6 +735,32 @@ export default function UserExperiencePanel() {
font-size: 13px;
line-height: 1.65;
}
.kg-user-travel-summary {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
background: #fff;
}
.kg-user-travel-plan {
padding: 12px;
border: 1px solid #edf1f7;
border-radius: 8px;
background: #fbfdff;
}
.kg-user-travel-plan p {
margin: 8px 0 0;
color: #4e5969;
line-height: 1.65;
}
.kg-user-travel-followups {
display: flex;
flex-direction: column;
gap: 8px;
}
.kg-user-travel-followups .ant-tag {
cursor: pointer;
}
.kg-user-result-card {
display: grid;
grid-template-columns: 1fr 96px;