Files
2026-06-11 16:28:00 +08:00

2255 lines
74 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Intelligent Cabin Agent Demo</title>
<style>
:root {
color-scheme: light;
--bg: #f5f7fb;
--panel: #ffffff;
--line: #dde3ee;
--text: #1f2937;
--sub: #6b7280;
--brand: #2563eb;
--user: #eff6ff;
--agent: #f3f4f6;
--accent: #eef2ff;
--success: #047857;
--warn: #b45309;
--danger: #b91c1c;
--dark: #0f172a;
}
* { box-sizing: border-box; }
html, body {
height: 100%;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
.page {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(320px, 420px);
gap: 16px;
height: 100vh;
padding: 16px;
overflow: hidden;
}
.page.debug-collapsed {
grid-template-columns: minmax(0, 1fr);
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: 0 6px 24px rgba(15, 23, 42, 0.06);
overflow: hidden;
min-height: 0;
}
.header {
padding: 18px 20px;
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-size: 18px;
font-weight: 700;
}
.sub, .hint {
color: var(--sub);
font-size: 13px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
.chat-panel {
display: grid;
grid-template-rows: auto 1fr auto;
height: calc(100vh - 32px);
min-height: 0;
}
.controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 16px 20px 8px;
}
.control {
display: flex;
flex-direction: column;
gap: 6px;
}
label {
font-size: 12px;
font-weight: 600;
color: var(--sub);
}
input, textarea, select, button {
font: inherit;
}
input, textarea, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px;
background: #fff;
color: var(--text);
}
textarea {
min-height: 82px;
resize: vertical;
}
.messages {
padding: 12px 20px 16px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
scroll-behavior: smooth;
}
.message-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.message-row.user {
align-items: flex-end;
}
.message-row.agent {
align-items: flex-start;
}
.message-row.system {
align-items: center;
}
.message-head {
padding: 0 2px;
font-size: 12px;
color: var(--sub);
}
.message-name {
font-weight: 600;
}
.message {
max-width: min(82%, 720px);
padding: 12px 14px;
border-radius: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid transparent;
}
.message.user {
background: var(--user);
border-color: #bfdbfe;
}
.message.agent {
background: var(--agent);
border-color: #e5e7eb;
}
.message.system {
max-width: min(92%, 760px);
background: #fff;
border-color: var(--line);
color: var(--sub);
font-size: 13px;
}
.message-note {
margin-top: 8px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.65);
color: #334155;
font-size: 12px;
border: 1px dashed rgba(148, 163, 184, 0.7);
}
.meta {
margin-top: 6px;
font-size: 12px;
color: var(--sub);
}
.composer {
border-top: 1px solid var(--line);
padding: 16px 20px 20px;
display: grid;
gap: 10px;
}
.composer-actions, .toolbar {
display: flex;
gap: 10px;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.btn-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 10px;
padding: 10px 14px;
cursor: pointer;
transition: opacity 0.2s ease;
}
button:hover { opacity: 0.92; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
.primary { background: var(--brand); color: #fff; }
.secondary { background: var(--accent); color: #3730a3; }
.ghost { background: #f8fafc; color: var(--text); border: 1px solid var(--line); }
.status {
font-size: 13px;
color: var(--sub);
}
.side {
display: flex;
flex-direction: column;
height: calc(100vh - 32px);
min-height: 0;
overflow: auto;
}
.page.debug-collapsed .side {
display: none;
}
.section {
padding: 16px 20px;
border-bottom: 1px solid var(--line);
}
.section h3 {
margin: 0 0 10px;
font-size: 15px;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
border: 1px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
}
.chip.active {
background: #dbeafe;
border-color: #93c5fd;
color: #1d4ed8;
}
.kv {
display: grid;
grid-template-columns: 88px 1fr;
gap: 8px 10px;
font-size: 13px;
}
.kv .k { color: var(--sub); }
.cabin-demo {
display: grid;
gap: 12px;
}
.demo-scene {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 12px;
align-items: stretch;
}
.demo-card {
border: 1px solid var(--line);
border-radius: 14px;
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
padding: 12px;
position: relative;
overflow: hidden;
}
.demo-card.pulse {
animation: demoPulse 0.85s ease;
}
@keyframes demoPulse {
0% { box-shadow: 0 0 0 rgba(37, 99, 235, 0.0); }
30% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0.12); }
100% { box-shadow: 0 0 0 rgba(37, 99, 235, 0.0); }
}
.demo-card h4 {
margin: 0 0 8px;
font-size: 13px;
}
.demo-card .hint {
font-size: 12px;
}
.car-canvas {
position: relative;
height: 168px;
border-radius: 12px;
background: linear-gradient(180deg, #dbeafe 0%, #eff6ff 38%, #f8fafc 38%, #f8fafc 100%);
overflow: hidden;
margin-top: 10px;
}
.car-body-shape {
position: absolute;
left: 20px;
right: 20px;
bottom: 22px;
height: 64px;
background: linear-gradient(180deg, #1d4ed8 0%, #1e40af 100%);
border-radius: 18px 18px 14px 14px;
}
.car-roof {
position: absolute;
left: 58px;
right: 58px;
bottom: 76px;
height: 44px;
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
border-radius: 18px 18px 8px 8px;
}
.window-frame {
position: absolute;
left: 74px;
right: 74px;
bottom: 84px;
height: 30px;
border-radius: 10px;
background: rgba(15, 23, 42, 0.15);
overflow: hidden;
border: 2px solid rgba(255, 255, 255, 0.5);
}
.window-glass {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(191, 219, 254, 0.95) 0%, rgba(125, 211, 252, 0.88) 100%);
transform: translateY(0%);
transition: transform 0.75s ease;
}
.window-glass.open {
transform: translateY(72%);
}
.wheel {
position: absolute;
bottom: 10px;
width: 26px;
height: 26px;
border-radius: 50%;
background: #0f172a;
border: 4px solid #94a3b8;
}
.wheel.left { left: 42px; }
.wheel.right { right: 42px; }
.window-status {
margin-top: 10px;
font-size: 12px;
color: var(--sub);
}
.ac-panel {
display: grid;
gap: 10px;
height: 100%;
}
.ac-unit {
margin-top: 6px;
border-radius: 14px;
background: #0f172a;
color: #e2e8f0;
min-height: 132px;
padding: 14px 12px;
display: grid;
grid-template-rows: auto auto 1fr;
align-items: start;
}
.ac-display {
font-size: 30px;
font-weight: 700;
letter-spacing: 0.02em;
}
.ac-display small {
font-size: 13px;
color: #93c5fd;
margin-left: 4px;
}
.ac-mode {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.ac-waves {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 10px;
}
.ac-wave {
width: 10px;
height: 52px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(96, 165, 250, 0.18) 0%, rgba(56, 189, 248, 0.2) 100%);
opacity: 0.28;
transform: scaleY(0.35);
transform-origin: center bottom;
transition: opacity 0.35s ease, transform 0.35s ease;
}
.ac-waves.active .ac-wave {
opacity: 1;
animation: acBreeze 1.2s ease-in-out infinite;
}
.ac-waves.active .ac-wave:nth-child(2) { animation-delay: 0.12s; }
.ac-waves.active .ac-wave:nth-child(3) { animation-delay: 0.24s; }
@keyframes acBreeze {
0%, 100% { transform: scaleY(0.45); }
50% { transform: scaleY(1); }
}
.demo-summary {
border-radius: 12px;
background: #f8fafc;
border: 1px dashed var(--line);
padding: 10px 12px;
font-size: 12px;
line-height: 1.6;
color: var(--sub);
}
.code {
margin: 0;
padding: 12px;
border-radius: 12px;
background: var(--dark);
color: #e2e8f0;
overflow: auto;
font-size: 12px;
line-height: 1.5;
min-height: 220px;
}
.sessions {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 220px;
overflow: auto;
}
.session-item {
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
cursor: pointer;
background: #fff;
}
.session-item.active {
border-color: #93c5fd;
background: #eff6ff;
}
.session-item .s-top {
font-size: 13px;
font-weight: 600;
}
.session-item .s-sub {
margin-top: 4px;
font-size: 12px;
color: var(--sub);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 240px;
overflow: auto;
}
.stage-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: #fff;
}
.stage-card.accepted {
background: #eff6ff;
border-color: #93c5fd;
}
.stage-header {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 13px;
font-weight: 600;
}
.stage-meta {
margin-top: 6px;
font-size: 12px;
color: var(--sub);
line-height: 1.5;
}
.candidate-list {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.candidate {
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--line);
background: #f8fafc;
font-size: 12px;
}
.raw-json {
margin-top: 8px;
padding: 10px 12px;
border-radius: 10px;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.5;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.planner-summary {
font-size: 12px;
color: var(--sub);
line-height: 1.6;
}
.planner-steps {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 10px;
max-height: 280px;
overflow: auto;
}
.planner-step {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: #f8fafc;
}
.planner-step-title {
font-size: 13px;
font-weight: 600;
}
.planner-step-meta {
margin-top: 6px;
font-size: 12px;
color: var(--sub);
line-height: 1.5;
}
.runtime-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.badge {
padding: 5px 10px;
border-radius: 999px;
font-size: 12px;
background: #f8fafc;
border: 1px solid var(--line);
}
.ok { color: var(--success); }
.warn { color: var(--warn); }
.error { color: var(--danger); }
.process-summary {
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
font-size: 13px;
line-height: 1.6;
color: #334155;
white-space: pre-wrap;
}
.process-timeline {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 10px;
max-height: 360px;
overflow: auto;
}
.process-step {
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: #fff;
}
.process-step.ok {
border-color: #93c5fd;
background: #eff6ff;
}
.process-step.warn {
border-color: #fcd34d;
background: #fffbeb;
}
.process-step.error {
border-color: #fca5a5;
background: #fef2f2;
}
.process-step-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
font-size: 13px;
font-weight: 700;
}
.process-step-status {
font-size: 12px;
font-weight: 600;
color: var(--sub);
white-space: nowrap;
}
.process-step-summary {
margin-top: 6px;
font-size: 13px;
line-height: 1.6;
color: #111827;
white-space: pre-wrap;
word-break: break-word;
}
.process-step-detail {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.process-pill {
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
background: #f8fafc;
border: 1px solid var(--line);
color: #475569;
}
@media (max-width: 1100px) {
body {
overflow: auto;
}
.page {
grid-template-columns: 1fr;
height: auto;
overflow: visible;
padding: 12px;
}
.chat-panel, .side {
height: auto;
}
.controls {
grid-template-columns: 1fr;
}
.message {
max-width: 92%;
}
}
</style>
</head>
<body>
<div class="page" id="page">
<section class="panel chat-panel">
<div>
<div class="header">
<div>
<div class="title">Agent 演示台</div>
<div class="sub">Bert-first 意图识别,多轮补槽续跑,右侧展示每轮请求的完整处理链路</div>
</div>
<div class="header-actions">
<div class="hint" id="serverStatus">服务状态:检测中</div>
<button class="ghost" id="toggleDebugBtn" type="button">收起调试</button>
</div>
</div>
<div class="controls">
<div class="control">
<label for="sessionId">Session ID</label>
<input id="sessionId" />
</div>
<div class="control">
<label for="userId">User ID</label>
<input id="userId" />
</div>
</div>
</div>
<div class="messages" id="messages"></div>
<div class="composer">
<textarea id="messageInput" placeholder="请输入用户指令,例如:帮我取消订单"></textarea>
<div class="composer-actions">
<div class="status" id="composerStatus">当前会自动判断是首次请求还是补槽续跑。</div>
<div class="btn-row">
<button class="ghost" id="copySummaryBtn" type="button">复制摘要</button>
<button class="ghost" id="copyJsonBtn" type="button">复制 JSON</button>
<button class="ghost" id="newSessionBtn">新建会话</button>
<button class="secondary" id="clearBtn">清空聊天</button>
<button class="primary" id="sendBtn">发送</button>
</div>
</div>
</div>
</section>
<aside class="panel side">
<div class="section">
<h3>运行配置</h3>
<div class="grid-2">
<div class="control">
<label for="matcherPipeline">Intent Route</label>
<select id="matcherPipeline">
<option value="classifier">classifier</option>
</select>
</div>
<div class="control">
<label for="classifierBackend">Classifier Backend</label>
<select id="classifierBackend">
<option value="mock">mock</option>
<option value="bert">bert</option>
<option value="joint_bert">joint_bert</option>
<option value="remote">remote</option>
</select>
</div>
<div class="control">
<label for="sessionBackend">Session Backend</label>
<select id="sessionBackend">
<option value="memory">memory</option>
<option value="redis">redis</option>
</select>
</div>
<div class="control">
<label>&nbsp;</label>
<button class="primary" id="applyRuntimeBtn">应用配置</button>
</div>
</div>
<div class="runtime-badges" id="runtimeBadges"></div>
<div class="hint" id="runtimeStatus">配置加载中</div>
</div>
<div class="section">
<h3>演示示例</h3>
<div class="chips" id="exampleChips"></div>
</div>
<div class="section">
<h3>本地会话历史</h3>
<div class="toolbar">
<div class="hint">浏览器会保存每个 session 的聊天记录和最后一次 workflow。</div>
<button class="ghost" id="refreshSessionsBtn">刷新列表</button>
</div>
<div class="sessions" id="sessionList"></div>
</div>
<div class="section">
<h3>当前状态</h3>
<div class="kv">
<div class="k">Intent</div><div id="currentIntent">-</div>
<div class="k">Matcher</div><div id="currentMatchedStage">-</div>
<div class="k">Status</div><div id="currentStatus">-</div>
<div class="k">Pending</div><div id="pendingSlots">-</div>
<div class="k">首响</div><div id="firstLatency">-</div>
<div class="k">总耗时</div><div id="totalLatency">-</div>
<div class="k">分解</div><div id="timingBreakdown">-</div>
<div class="k">Trace ID</div><div id="traceId">-</div>
</div>
</div>
<div class="section">
<h3>座舱演示</h3>
<div class="hint">根据当前执行结果,在右侧模拟车窗开合和空调状态变化,方便演示座舱控制。</div>
<div class="cabin-demo">
<div class="demo-scene">
<div class="demo-card" id="windowCard">
<h4>车窗</h4>
<div class="car-canvas">
<div class="car-roof"></div>
<div class="window-frame">
<div class="window-glass" id="windowGlass"></div>
</div>
<div class="car-body-shape"></div>
<div class="wheel left"></div>
<div class="wheel right"></div>
</div>
<div class="window-status" id="windowStatus">当前状态:已关闭</div>
</div>
<div class="demo-card" id="acCard">
<h4>空调</h4>
<div class="ac-panel">
<div class="ac-unit">
<div class="ac-display"><span id="acTempText">--</span><small id="acTempUnit">°C</small></div>
<div class="ac-mode" id="acModeText">当前状态:关闭</div>
<div class="ac-waves" id="acWaves">
<div class="ac-wave"></div>
<div class="ac-wave"></div>
<div class="ac-wave"></div>
</div>
</div>
</div>
</div>
</div>
<div class="demo-summary" id="demoStatusText">等待控制指令,右侧将展示座舱演示动画。</div>
</div>
</div>
<div class="section">
<h3>本轮流程</h3>
<div class="hint">按输入、改写、Bert、决策、规划、执行和回复的顺序展示便于直观看清楚每一步做了什么。</div>
<div class="process-summary" id="processSummary">等待一次新的请求。</div>
<div class="process-timeline" id="processTimeline"></div>
<pre class="raw-json" id="executionProcess">暂无执行过程。</pre>
</div>
<div class="section">
<h3>路由调试</h3>
<div class="hint">展示每一层 matcher 的候选意图、分数和最终命中原因。</div>
<div class="stage-list" id="routingDebugList"></div>
</div>
<div class="section" style="border-bottom: none;">
<h3>Planner 调试</h3>
<div class="hint">展示云端 planner 的拆分步骤、归一化槽位和原始 JSON。</div>
<div class="planner-summary" id="plannerSummary">当前还没有 planner 结果。</div>
<div class="planner-summary" id="clauseSummary">当前还没有子句拆解结果。</div>
<div class="planner-steps" id="clauseAnalysisList"></div>
<div class="planner-steps" id="plannerSteps"></div>
<pre class="raw-json" id="plannerRawJson">{}</pre>
</div>
<div class="section" style="border-bottom: none;">
<h3>Workflow JSON</h3>
<pre class="code" id="workflowJson">{}</pre>
</div>
</aside>
</div>
<script>
const STORAGE_INDEX_KEY = "agent-demo:sessions";
const DEBUG_VISIBILITY_KEY = "agent-demo:debug-visible";
const state = {
pendingSlots: [],
lastWorkflow: null,
lastResponse: null,
messages: [],
runtime: null,
debugVisible: true,
cabinDemo: {
windowOpen: false,
acPower: false,
temperature: 24,
lastAction: "等待控制指令,右侧将展示座舱演示动画。",
},
};
const els = {
page: document.getElementById("page"),
sessionId: document.getElementById("sessionId"),
userId: document.getElementById("userId"),
messageInput: document.getElementById("messageInput"),
messages: document.getElementById("messages"),
sendBtn: document.getElementById("sendBtn"),
clearBtn: document.getElementById("clearBtn"),
copySummaryBtn: document.getElementById("copySummaryBtn"),
copyJsonBtn: document.getElementById("copyJsonBtn"),
newSessionBtn: document.getElementById("newSessionBtn"),
refreshSessionsBtn: document.getElementById("refreshSessionsBtn"),
currentIntent: document.getElementById("currentIntent"),
currentMatchedStage: document.getElementById("currentMatchedStage"),
currentStatus: document.getElementById("currentStatus"),
pendingSlots: document.getElementById("pendingSlots"),
firstLatency: document.getElementById("firstLatency"),
totalLatency: document.getElementById("totalLatency"),
timingBreakdown: document.getElementById("timingBreakdown"),
traceId: document.getElementById("traceId"),
executionProcess: document.getElementById("executionProcess"),
processSummary: document.getElementById("processSummary"),
processTimeline: document.getElementById("processTimeline"),
windowCard: document.getElementById("windowCard"),
acCard: document.getElementById("acCard"),
windowGlass: document.getElementById("windowGlass"),
windowStatus: document.getElementById("windowStatus"),
acWaves: document.getElementById("acWaves"),
acTempText: document.getElementById("acTempText"),
acTempUnit: document.getElementById("acTempUnit"),
acModeText: document.getElementById("acModeText"),
demoStatusText: document.getElementById("demoStatusText"),
workflowJson: document.getElementById("workflowJson"),
routingDebugList: document.getElementById("routingDebugList"),
exampleChips: document.getElementById("exampleChips"),
serverStatus: document.getElementById("serverStatus"),
composerStatus: document.getElementById("composerStatus"),
matcherPipeline: document.getElementById("matcherPipeline"),
classifierBackend: document.getElementById("classifierBackend"),
sessionBackend: document.getElementById("sessionBackend"),
applyRuntimeBtn: document.getElementById("applyRuntimeBtn"),
runtimeStatus: document.getElementById("runtimeStatus"),
runtimeBadges: document.getElementById("runtimeBadges"),
sessionList: document.getElementById("sessionList"),
plannerSummary: document.getElementById("plannerSummary"),
clauseSummary: document.getElementById("clauseSummary"),
clauseAnalysisList: document.getElementById("clauseAnalysisList"),
plannerSteps: document.getElementById("plannerSteps"),
plannerRawJson: document.getElementById("plannerRawJson"),
toggleDebugBtn: document.getElementById("toggleDebugBtn"),
};
const demoExamples = [
"帮我查询订单 A123456",
"帮我取消订单",
"订单号是 A654321",
"我的订单现在什么情况 A701111",
"来点轻音乐",
"导航去公司",
"把空调调到 22 度",
"帮我转人工客服"
];
function randomId(prefix) {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function sessionStorageKey(sessionId) {
return `agent-demo:session:${sessionId}`;
}
function getSessionIndex() {
try {
return JSON.parse(localStorage.getItem(STORAGE_INDEX_KEY) || "[]");
} catch (_) {
return [];
}
}
function saveSessionIndex(index) {
localStorage.setItem(STORAGE_INDEX_KEY, JSON.stringify(index.slice(0, 20)));
}
function touchSessionIndex(sessionId, userId) {
const index = getSessionIndex().filter((item) => item.session_id !== sessionId);
const preview = [...state.messages].reverse().find((item) => item.role === "user")?.text || "暂无消息";
index.unshift({
session_id: sessionId,
user_id: userId,
preview,
updated_at: new Date().toISOString(),
});
saveSessionIndex(index);
}
function persistSession() {
const sessionId = els.sessionId.value.trim();
const userId = els.userId.value.trim();
if (!sessionId || !userId) return;
const payload = {
session_id: sessionId,
user_id: userId,
messages: state.messages.filter((item) => !item.transient),
pending_slots: state.pendingSlots,
workflow: state.lastWorkflow,
response: state.lastResponse,
};
localStorage.setItem(sessionStorageKey(sessionId), JSON.stringify(payload));
touchSessionIndex(sessionId, userId);
renderSessionList();
}
function loadSession(sessionId) {
const raw = localStorage.getItem(sessionStorageKey(sessionId));
if (!raw) {
state.messages = [];
state.pendingSlots = [];
state.lastWorkflow = null;
state.lastResponse = null;
renderMessages();
updateResponsePanel({ pending_slots: [], workflow: {}, intent: "-", status: "-", trace_id: "-" });
renderCabinDemo(null, { reset: true });
return false;
}
try {
const data = JSON.parse(raw);
els.sessionId.value = data.session_id || sessionId;
els.userId.value = data.user_id || randomId("user");
state.messages = Array.isArray(data.messages) ? data.messages : [];
state.pendingSlots = Array.isArray(data.pending_slots) ? data.pending_slots : [];
state.lastWorkflow = data.workflow || null;
state.lastResponse = data.response || null;
renderMessages();
updateResponsePanel(data.response || { pending_slots: state.pendingSlots, workflow: state.lastWorkflow || {}, intent: "-", status: "-", trace_id: "-" });
return true;
} catch (_) {
return false;
}
}
function initIds() {
els.sessionId.value = randomId("sess");
els.userId.value = randomId("user");
}
function setDebugVisible(visible) {
state.debugVisible = visible;
els.page.classList.toggle("debug-collapsed", !visible);
els.toggleDebugBtn.textContent = visible ? "收起调试" : "显示调试";
localStorage.setItem(DEBUG_VISIBILITY_KEY, visible ? "1" : "0");
}
function messageRoleName(role) {
if (role === "user") return "你";
if (role === "agent") return "助手";
return "系统";
}
function addMessage(role, text, metaText = "", detailText = "", options = {}) {
state.messages.push({
role,
text,
metaText,
detailText,
timestamp: new Date().toISOString(),
transient: Boolean(options.transient),
});
renderMessages();
persistSession();
}
function clearTransientMessages() {
const before = state.messages.length;
state.messages = state.messages.filter((item) => !item.transient);
if (state.messages.length !== before) {
renderMessages();
persistSession();
}
}
function renderMessages() {
els.messages.innerHTML = "";
for (const item of state.messages) {
const row = document.createElement("div");
row.className = `message-row ${item.role}`;
if (item.role !== "system") {
const head = document.createElement("div");
head.className = "message-head";
head.innerHTML = `<span class="message-name">${messageRoleName(item.role)}</span>`;
row.appendChild(head);
}
const div = document.createElement("div");
div.className = `message ${item.role}`;
div.textContent = item.text;
if (item.detailText) {
const note = document.createElement("div");
note.className = "message-note";
note.textContent = item.detailText;
div.appendChild(note);
}
const metaParts = [];
if (item.timestamp) {
metaParts.push(new Date(item.timestamp).toLocaleTimeString());
}
if (item.metaText) {
metaParts.push(item.metaText);
}
if (metaParts.length) {
const meta = document.createElement("div");
meta.className = "meta";
meta.textContent = metaParts.join(" | ");
div.appendChild(meta);
}
row.appendChild(div);
els.messages.appendChild(row);
}
requestAnimationFrame(() => {
els.messages.scrollTop = els.messages.scrollHeight;
});
}
function getRewriteStage(routingDebug) {
if (!routingDebug || !Array.isArray(routingDebug.stages)) {
return null;
}
return routingDebug.stages.find((stage) => stage.stage === "rewrite") || null;
}
function buildRewriteNote(routingDebug) {
const rewriteStage = getRewriteStage(routingDebug);
const meta = rewriteStage?.metadata || {};
const original = String(meta.original_text || "").trim();
const rewritten = String(meta.rewritten_text || "").trim();
if (!rewriteStage?.accepted || !original || !rewritten || original === rewritten) {
return "";
}
return `按上下文理解为:${rewritten}`;
}
function formatLatency(value) {
return typeof value === "number" && Number.isFinite(value) ? `${(value / 1000).toFixed(3)} s` : "-";
}
function formatBreakdown(breakdown) {
if (!breakdown || typeof breakdown !== "object") {
return "-";
}
const parts = Object.entries(breakdown)
.filter(([, value]) => typeof value === "number" && Number.isFinite(value))
.sort((a, b) => b[1] - a[1])
.map(([key, value]) => `${key}=${(value / 1000).toFixed(3)}s`);
return parts.length ? parts.join(" | ") : "-";
}
function defaultCabinDemoState() {
return {
windowOpen: false,
acPower: false,
temperature: 24,
lastAction: "等待控制指令,右侧将展示座舱演示动画。",
};
}
function animateDemoCard(el) {
if (!el) return;
el.classList.remove("pulse");
void el.offsetWidth;
el.classList.add("pulse");
}
function extractTemperature(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.round(value);
}
if (typeof value === "string") {
const match = value.match(/(\d{2})/);
if (match) return Number(match[1]);
}
return null;
}
function applyCabinIntent(next, intentId, slots = {}) {
let changed = false;
if (intentId === "cabin_window_open") {
changed = changed || !next.windowOpen;
next.windowOpen = true;
next.lastAction = "已演示车窗打开动画。";
} else if (intentId === "cabin_window_close") {
changed = changed || next.windowOpen;
next.windowOpen = false;
next.lastAction = "已演示车窗关闭动画。";
} else if (intentId === "cabin_ac_on") {
changed = changed || !next.acPower;
next.acPower = true;
next.lastAction = "已演示空调启动送风。";
} else if (intentId === "cabin_ac_off") {
changed = changed || next.acPower;
next.acPower = false;
next.lastAction = "已演示空调关闭。";
} else if (intentId === "cabin_set_ac") {
const nextTemp = extractTemperature(slots.temperature);
changed = changed || !next.acPower;
next.acPower = true;
if (nextTemp !== null && next.temperature !== nextTemp) {
changed = true;
next.temperature = nextTemp;
}
next.lastAction = `已演示空调调到 ${next.temperature} 度。`;
}
return changed;
}
function deriveCabinDemoState(data) {
const next = { ...state.cabinDemo };
let windowChanged = false;
let acChanged = false;
const workflowSteps = Array.isArray(data.workflow?.steps) ? data.workflow.steps : [];
const actionableSteps = workflowSteps.filter((step) => step && step.status === "completed");
if (actionableSteps.length) {
for (const step of actionableSteps) {
const changed = applyCabinIntent(next, step.intent_id, step.slots || {});
if (step.intent_id?.startsWith("cabin_window_")) windowChanged = windowChanged || changed;
if (step.intent_id?.startsWith("cabin_ac") || step.intent_id === "cabin_set_ac") acChanged = acChanged || changed;
}
} else if ((data.reply_type === "workflow_result" || data.status === "completed") && data.intent) {
const changed = applyCabinIntent(next, data.intent, data.filled_slots || {});
if (data.intent.startsWith("cabin_window_")) windowChanged = changed;
if (data.intent.startsWith("cabin_ac") || data.intent === "cabin_set_ac") acChanged = changed;
}
if (windowChanged || acChanged) {
return { next, windowChanged, acChanged };
}
return { next: state.cabinDemo, windowChanged: false, acChanged: false };
}
function renderCabinDemo(data, options = {}) {
const { reset = false } = options;
if (reset) {
state.cabinDemo = defaultCabinDemoState();
} else if (data) {
const derived = deriveCabinDemoState(data);
state.cabinDemo = derived.next;
if (derived.windowChanged) animateDemoCard(els.windowCard);
if (derived.acChanged) animateDemoCard(els.acCard);
}
const demo = state.cabinDemo;
els.windowGlass.classList.toggle("open", demo.windowOpen);
els.windowStatus.textContent = `当前状态:${demo.windowOpen ? "已打开" : "已关闭"}`;
els.acWaves.classList.toggle("active", demo.acPower);
els.acTempText.textContent = demo.acPower ? String(demo.temperature) : "--";
els.acTempUnit.textContent = demo.acPower ? "°C" : "";
els.acModeText.textContent = demo.acPower ? `当前状态:送风中,目标 ${demo.temperature}` : "当前状态:关闭";
els.demoStatusText.textContent = demo.lastAction;
}
function latestUserMessage() {
return [...state.messages].reverse().find((item) => item.role === "user")?.text || "-";
}
function buildExecutionProcessText(data) {
const routingDebug = data.routing_debug || {};
const stages = Array.isArray(routingDebug.stages) ? routingDebug.stages : [];
const plannerStage = getPlannerStage(routingDebug);
const plannerMeta = plannerStage?.metadata || {};
const clauseAnalysis = Array.isArray(plannerMeta.clause_analysis) ? plannerMeta.clause_analysis : [];
const lines = [];
lines.push(`输入: ${latestUserMessage()}`);
lines.push(`回复: ${data.reply_text || "-"}`);
lines.push(`意图: ${data.intent || "-"} | 回复类型: ${data.reply_type || "-"} | 状态: ${data.status || "-"}`);
lines.push(`首响: ${formatLatency(data.first_response_latency_ms)} | 总耗时: ${formatLatency(data.total_latency_ms)}`);
if (data.processing_breakdown && Object.keys(data.processing_breakdown).length) {
lines.push(`处理分解: ${formatBreakdown(data.processing_breakdown)}`);
}
lines.push("");
lines.push("阶段过程:");
if (!stages.length) {
lines.push("- 暂无阶段数据");
return lines.join("\n");
}
stages.forEach((stage, index) => {
const bits = [];
bits.push(`${index + 1}. ${stage.stage}`);
bits.push(stage.accepted ? "accepted" : "pass");
if (typeof stage.elapsed_ms === "number") {
bits.push(formatLatency(stage.elapsed_ms));
}
if (stage.selected_intent) {
bits.push(`intent=${stage.selected_intent}`);
}
if (stage.backend) {
bits.push(`backend=${stage.backend}`);
}
if (stage.model_name) {
bits.push(`model=${stage.model_name}`);
}
if (stage.reason) {
bits.push(stage.reason);
}
lines.push(`- ${bits.join(" | ")}`);
});
if (clauseAnalysis.length) {
lines.push("");
lines.push("子句拆解:");
clauseAnalysis.forEach((item, index) => {
const candidates = Array.isArray(item?.candidates) ? item.candidates : [];
const candidateText = candidates.length
? candidates.map((candidate) => {
const candidateScore = typeof candidate.score === "number" ? candidate.score.toFixed(3) : "-";
return `${candidate.intent_id}(${candidateScore})`;
}).join(", ")
: "-";
lines.push(
`- ${index + 1}. text=${item?.clause_text || "-"} | intent=${item?.selected_intent_id || "-"} | score=${
typeof item?.score === "number" ? item.score.toFixed(3) : "-"
} | slots=${JSON.stringify(item?.slots || {})} | candidates=${candidateText}`
);
});
}
return lines.join("\n");
}
function buildExportSummary() {
const data = state.lastResponse || {};
const runtime = state.runtime || {};
const plannerStage = getPlannerStage(data.routing_debug || {});
const plannerMeta = plannerStage?.metadata || {};
const clauseAnalysis = Array.isArray(plannerMeta.clause_analysis) ? plannerMeta.clause_analysis : [];
const normalizedSteps = Array.isArray(plannerMeta.normalized_steps) ? plannerMeta.normalized_steps : [];
return [
"## 测试结果",
`- session_id: ${els.sessionId.value.trim() || "-"}`,
`- user_id: ${els.userId.value.trim() || "-"}`,
`- pipeline: ${runtime.matcher_pipeline || "-"}`,
`- classifier: ${runtime.classifier_backend || "-"}`,
`- planner: ${runtime.planner_backend || "-"}`,
`- 最新输入: ${latestUserMessage()}`,
`- 最新回复: ${data.reply_text || "-"}`,
`- intent: ${data.intent || "-"}`,
`- status: ${data.status || "-"}`,
`- 首响: ${formatLatency(data.first_response_latency_ms)}`,
`- 总耗时: ${formatLatency(data.total_latency_ms)}`,
`- trace_id: ${data.trace_id || "-"}`,
`- multi_intent_detected: ${plannerMeta.multi_intent_detected ? "yes" : "no"}`,
`- clause_count: ${clauseAnalysis.length}`,
`- planner_steps: ${normalizedSteps.length}`,
"",
"```text",
buildExecutionProcessText(data),
"```",
].join("\n");
}
function buildExportJson() {
return JSON.stringify({
session_id: els.sessionId.value.trim(),
user_id: els.userId.value.trim(),
runtime: state.runtime,
last_response: state.lastResponse,
messages_tail: state.messages.slice(-8),
}, null, 2);
}
async function copyTextToClipboard(text, successText) {
try {
await navigator.clipboard.writeText(text);
addMessage("system", successText);
} catch (err) {
addMessage("system", `复制失败:${err.message}`);
}
}
async function readNdjsonStream(response, onEvent) {
if (!response.body) {
throw new Error("流式响应不可用");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
onEvent(JSON.parse(trimmed));
}
}
const tail = buffer.trim();
if (tail) {
onEvent(JSON.parse(tail));
}
}
function buildAgentMeta(data) {
const statusMap = {
completed: "已完成",
waiting_slot: "待补槽",
waiting_confirmation: "待确认",
clarify: "待澄清",
rejected: "已拒识",
fallback: "回退",
};
const metaParts = [];
if (statusMap[data.status]) {
metaParts.push(statusMap[data.status]);
}
if (data.reply_type === "reject") {
metaParts.push("超出当前能力范围");
} else if (data.reply_type === "clarify") {
metaParts.push("需要你再确认一下");
}
if (typeof data.first_response_latency_ms === "number") {
metaParts.push(`首响 ${formatLatency(data.first_response_latency_ms)}`);
}
if (typeof data.total_latency_ms === "number") {
metaParts.push(`总耗时 ${formatLatency(data.total_latency_ms)}`);
}
return metaParts.join(" | ");
}
function formatObjectSummary(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return "-";
}
const entries = Object.entries(value);
if (!entries.length) {
return "{}";
}
return entries.map(([key, item]) => `${key}=${typeof item === "object" ? JSON.stringify(item) : item}`).join(" | ");
}
function getLatestUserMessage() {
for (let index = state.messages.length - 1; index >= 0; index -= 1) {
const item = state.messages[index];
if (item.role === "user") {
return item.text || "";
}
}
return "";
}
function createProcessStep(step) {
const block = document.createElement("div");
block.className = `process-step ${step.tone || ""}`.trim();
const head = document.createElement("div");
head.className = "process-step-head";
head.innerHTML = `<span>${step.title}</span><span class="process-step-status">${step.status || ""}</span>`;
block.appendChild(head);
const summary = document.createElement("div");
summary.className = "process-step-summary";
summary.textContent = step.summary || "-";
block.appendChild(summary);
const details = Array.isArray(step.details) ? step.details.filter(Boolean) : [];
if (details.length) {
const detailWrap = document.createElement("div");
detailWrap.className = "process-step-detail";
for (const item of details) {
const pill = document.createElement("div");
pill.className = "process-pill";
pill.textContent = item;
detailWrap.appendChild(pill);
}
block.appendChild(detailWrap);
}
return block;
}
function formatCandidateList(candidates) {
if (!Array.isArray(candidates) || !candidates.length) {
return "无候选意图";
}
return candidates
.slice(0, 3)
.map((candidate) => {
const score = typeof candidate.score === "number" ? candidate.score.toFixed(3) : "-";
return `${candidate.intent_id}(${score})`;
})
.join(" | ");
}
function formatWorkflowExecutionSummary(workflowSteps, replyType) {
if (!Array.isArray(workflowSteps) || !workflowSteps.length) {
if (replyType === "ask_slot") {
return "当前进入待补槽阶段,执行尚未开始。";
}
if (replyType === "ask_confirmation") {
return "当前进入待确认阶段,等待用户确认后执行。";
}
return "当前没有可展示的 workflow 执行步骤。";
}
return workflowSteps
.map((step, index) => `${index + 1}. ${step.intent_id || step.action || "unknown"} -> ${step.status || "pending"}`)
.join("\n");
}
function renderProcessFlow(data) {
const routingDebug = data.routing_debug || {};
const stages = Array.isArray(routingDebug.stages) ? routingDebug.stages : [];
const rewriteStage = getRewriteStage(routingDebug);
const classifierStage = stages.find((stage) => stage.stage === "classifier") || null;
const fusionStage = stages.find((stage) => stage.stage === "fusion") || null;
const plannerStage = getPlannerStage(routingDebug);
const latestUserText = getLatestUserMessage();
const workflow = data.workflow || {};
const workflowSteps = Array.isArray(workflow.steps) ? workflow.steps : [];
const extractedSlots = routingDebug.extracted_slots || {};
const filledSlots = data.filled_slots || {};
const responseDecision = data.decision || routingDebug.decision || "-";
const steps = [];
steps.push({
title: "1. 用户输入",
status: "received",
tone: "",
summary: latestUserText || "当前没有可展示的输入文本",
details: [
data.reply_type ? `reply_type=${data.reply_type}` : "",
data.status ? `status=${data.status}` : "",
],
});
const rewriteMeta = rewriteStage?.metadata || {};
const original = String(rewriteMeta.original_text || "").trim();
const rewritten = String(rewriteMeta.rewritten_text || "").trim();
const changed = Boolean(rewriteStage?.accepted && original && rewritten && original !== rewritten);
steps.push({
title: "2. 上下文改写",
status: changed ? "rewritten" : "no rewrite",
tone: changed ? "ok" : "",
summary: changed ? `${original}\n-> ${rewritten}` : "当前没有发生上下文改写,直接使用原始输入进入 Bert。",
details: [
typeof rewriteStage?.elapsed_ms === "number" ? `latency=${formatLatency(rewriteStage.elapsed_ms)}` : "",
rewriteStage?.reason || "",
],
});
steps.push({
title: "3. Bert 意图识别",
status: classifierStage ? (classifierStage.accepted ? "accepted" : "below threshold") : "missing",
tone: classifierStage ? (classifierStage.accepted ? "ok" : "warn") : "error",
summary: classifierStage
? (classifierStage.selected_intent
? `${classifierStage.selected_intent} (${(classifierStage.score || 0).toFixed(3)})\n候选: ${formatCandidateList(classifierStage.candidates)}`
: `当前没有足够高置信的本地意图\n候选: ${formatCandidateList(classifierStage.candidates)}`)
: "当前响应里没有 classifier 阶段数据。",
details: [
classifierStage?.backend ? `backend=${classifierStage.backend}` : "",
classifierStage?.raw_label ? `raw_label=${classifierStage.raw_label}` : "",
classifierStage?.metadata?.top_margin !== undefined ? `top_margin=${classifierStage.metadata.top_margin}` : "",
typeof classifierStage?.elapsed_ms === "number" ? `latency=${formatLatency(classifierStage.elapsed_ms)}` : "",
],
});
const fusionDecision = fusionStage?.metadata?.decision || responseDecision;
steps.push({
title: "4. 本地决策",
status: fusionDecision,
tone: fusionDecision === "execute" ? "ok" : (fusionDecision === "reject" ? "error" : "warn"),
summary: fusionStage
? `${fusionStage.reason || data.decision_reason || "无决策说明"}\n候选: ${formatCandidateList(fusionStage.candidates)}`
: (data.decision_reason || "当前响应里没有 fusion 决策数据。"),
details: [
fusionStage?.selected_intent ? `selected=${fusionStage.selected_intent}` : "",
fusionStage?.metadata?.classifier_signal !== undefined ? `signal=${fusionStage.metadata.classifier_signal}` : "",
fusionStage?.metadata?.classifier_margin !== undefined ? `margin=${fusionStage.metadata.classifier_margin}` : "",
typeof fusionStage?.elapsed_ms === "number" ? `latency=${formatLatency(fusionStage.elapsed_ms)}` : "",
],
});
const normalizedSteps = Array.isArray(plannerStage?.metadata?.normalized_steps) ? plannerStage.metadata.normalized_steps : workflowSteps;
const clauseAnalysis = Array.isArray(plannerStage?.metadata?.clause_analysis) ? plannerStage.metadata.clause_analysis : [];
steps.push({
title: "5. Planner / Workflow",
status: plannerStage ? (plannerStage.accepted ? "planner used" : "planner reviewed") : "not used",
tone: plannerStage ? "ok" : "",
summary: normalizedSteps.length
? normalizedSteps.map((item, index) => `${index + 1}. ${item.intent_id || item.action || "unknown"}`).join("\n")
: "当前没有进入 planner直接按单意图链路继续处理。",
details: [
plannerStage?.backend ? `backend=${plannerStage.backend}` : "",
workflow.workflow_type ? `workflow=${workflow.workflow_type}` : "",
workflowSteps.length ? `steps=${workflowSteps.length}` : "",
clauseAnalysis.length ? `clauses=${clauseAnalysis.length}` : "",
],
});
const mergedSlots = { ...extractedSlots, ...filledSlots };
steps.push({
title: "6. 槽位与执行",
status: workflowSteps.length ? "workflow executed" : (Object.keys(mergedSlots).length ? "slots ready" : "no slots"),
tone: workflowSteps.length || Object.keys(mergedSlots).length ? "ok" : "",
summary: [
Object.keys(mergedSlots).length
? `槽位: ${formatObjectSummary(mergedSlots)}`
: "当前没有提取到可展示的槽位,或该意图无需槽位。",
formatWorkflowExecutionSummary(workflowSteps, data.reply_type),
].join("\n"),
details: [
Object.keys(extractedSlots).length ? `router_extract=${formatObjectSummary(extractedSlots)}` : "",
Object.keys(filledSlots).length ? `filled=${formatObjectSummary(filledSlots)}` : "",
data.pending_slots?.length ? `pending=${data.pending_slots.join(", ")}` : "",
],
});
steps.push({
title: "7. 最终回复",
status: data.reply_type || "-",
tone: data.reply_type === "reject" ? "error" : (data.reply_type === "clarify" ? "warn" : "ok"),
summary: data.reply_text || "无回复文本",
details: [
data.intent ? `intent=${data.intent}` : "",
typeof data.first_response_latency_ms === "number" ? `first=${formatLatency(data.first_response_latency_ms)}` : "",
typeof data.total_latency_ms === "number" ? `total=${formatLatency(data.total_latency_ms)}` : "",
],
});
els.processTimeline.innerHTML = "";
steps.forEach((step) => els.processTimeline.appendChild(createProcessStep(step)));
els.processSummary.textContent = [
`本轮输入: ${latestUserText || "-"}`,
`最终决策: ${responseDecision}`,
`最终意图: ${data.intent || routingDebug.selected_intent || "-"}`,
`响应类型: ${data.reply_type || "-"}`,
`首响 / 总耗时: ${formatLatency(data.first_response_latency_ms)} / ${formatLatency(data.total_latency_ms)}`,
].join("\n");
}
function renderRoutingDebug(routingDebug) {
els.routingDebugList.innerHTML = "";
if (!routingDebug || !Array.isArray(routingDebug.stages) || !routingDebug.stages.length) {
const empty = document.createElement("div");
empty.className = "hint";
empty.textContent = "当前还没有 matcher 调试信息,发起一次聊天请求后会显示。";
els.routingDebugList.appendChild(empty);
return;
}
for (const stage of routingDebug.stages) {
const block = document.createElement("div");
block.className = `stage-card ${stage.accepted ? "accepted" : ""}`;
const header = document.createElement("div");
header.className = "stage-header";
header.innerHTML = `
<span>${stage.stage}</span>
<span>${stage.accepted ? "accepted" : "pass"}</span>
`;
block.appendChild(header);
const meta = document.createElement("div");
const parts = [];
if (stage.selected_intent) {
parts.push(`intent=${stage.selected_intent}`);
}
if (typeof stage.score === "number" && stage.score > 0) {
parts.push(`score=${stage.score.toFixed(3)}`);
}
if (typeof stage.elapsed_ms === "number") {
parts.push(`latency=${formatLatency(stage.elapsed_ms)}`);
}
if (stage.model_name) {
parts.push(`decision=${stage.model_name}`);
}
if (stage.backend) {
parts.push(`backend=${stage.backend}`);
}
if (stage.fallback_used) {
parts.push("fallback=yes");
}
if (stage.raw_label) {
parts.push(`raw_label=${stage.raw_label}`);
}
if (stage.reason) {
parts.push(stage.reason);
}
if (stage.metadata && stage.metadata.decision) {
parts.push(`decision=${stage.metadata.decision}`);
}
if (stage.metadata && stage.metadata.grade) {
parts.push(`grade=${stage.metadata.grade}`);
}
if (stage.metadata && stage.metadata.unknown_detected) {
parts.push("unknown=yes");
}
if (stage.error_message) {
parts.push(`error=${stage.error_message}`);
}
meta.className = "stage-meta";
meta.textContent = parts.join(" | ") || "无调试说明";
block.appendChild(meta);
if (Array.isArray(stage.candidates) && stage.candidates.length) {
const candidateList = document.createElement("div");
candidateList.className = "candidate-list";
for (const candidate of stage.candidates) {
const item = document.createElement("div");
item.className = "candidate";
const score = typeof candidate.score === "number" ? candidate.score.toFixed(3) : "0.000";
const rawLabel = candidate.raw_label && candidate.raw_label !== candidate.intent_id
? ` / ${candidate.raw_label}`
: "";
item.textContent = `${candidate.intent_id}${rawLabel} (${score})`;
candidateList.appendChild(item);
}
block.appendChild(candidateList);
}
if (stage.metadata && Object.keys(stage.metadata).length) {
const raw = document.createElement("pre");
raw.className = "raw-json";
raw.textContent = JSON.stringify(stage.metadata, null, 2);
block.appendChild(raw);
}
els.routingDebugList.appendChild(block);
}
}
function getPlannerStage(routingDebug) {
if (!routingDebug || !Array.isArray(routingDebug.stages)) {
return null;
}
return routingDebug.stages.find((stage) => stage.stage === "planner") || null;
}
function renderClauseAnalysis(clauseAnalysis, multiIntentDetected) {
els.clauseAnalysisList.innerHTML = "";
if (!Array.isArray(clauseAnalysis) || !clauseAnalysis.length) {
els.clauseSummary.textContent = "当前还没有子句拆解结果。";
return;
}
const matchedCount = clauseAnalysis.filter((item) => item && item.selected_intent_id).length;
els.clauseSummary.textContent = [
`clauses=${clauseAnalysis.length}`,
`matched=${matchedCount}`,
`multi_intent=${multiIntentDetected ? "yes" : "no"}`,
].join(" | ");
clauseAnalysis.forEach((item, index) => {
const block = document.createElement("div");
block.className = "planner-step";
const title = document.createElement("div");
title.className = "planner-step-title";
const selected = item?.selected_intent_id || "未命中";
const score = typeof item?.score === "number" ? item.score.toFixed(3) : "-";
title.textContent = `子句 #${index + 1} | ${selected} | score=${score}`;
block.appendChild(title);
const stepMeta = document.createElement("div");
stepMeta.className = "planner-step-meta";
const parts = [];
if (item?.clause_text) {
parts.push(`text=${item.clause_text}`);
}
if (item?.reason) {
parts.push(`reason=${item.reason}`);
}
if (item?.slots && Object.keys(item.slots).length) {
parts.push(`slots=${JSON.stringify(item.slots)}`);
}
const candidates = Array.isArray(item?.candidates) ? item.candidates : [];
if (candidates.length) {
parts.push(`candidates=${candidates.map((candidate) => {
const candidateScore = typeof candidate.score === "number" ? candidate.score.toFixed(3) : "-";
return `${candidate.intent_id}(${candidateScore})`;
}).join(", ")}`);
}
stepMeta.textContent = parts.join(" | ") || "无子句调试信息";
block.appendChild(stepMeta);
els.clauseAnalysisList.appendChild(block);
});
}
function renderPlannerDebug(routingDebug, workflow) {
const plannerStage = getPlannerStage(routingDebug);
els.clauseAnalysisList.innerHTML = "";
els.clauseSummary.textContent = "当前还没有子句拆解结果。";
els.plannerSteps.innerHTML = "";
els.plannerRawJson.textContent = "{}";
if (!plannerStage) {
els.plannerSummary.textContent = "当前请求没有触发 planner多命令或复杂请求时这里会展示云端拆分结果。";
return;
}
const meta = plannerStage.metadata || {};
renderClauseAnalysis(meta.clause_analysis, Boolean(meta.multi_intent_detected));
const workflowType = meta.workflow_type || workflow?.workflow_type || "-";
const reason = plannerStage.reason || "无说明";
els.plannerSummary.textContent = [
`accepted=${plannerStage.accepted ? "yes" : "no"}`,
`backend=${plannerStage.backend || "-"}`,
`model=${plannerStage.model_name || "-"}`,
`workflow=${workflowType}`,
`latency=${formatLatency(plannerStage.elapsed_ms)}`,
reason,
].join(" | ");
const normalizedSteps = Array.isArray(meta.normalized_steps) ? meta.normalized_steps : [];
const stepItems = normalizedSteps.length
? normalizedSteps
: (Array.isArray(plannerStage.candidates) ? plannerStage.candidates.map((item) => ({
intent_id: item.intent_id,
normalized_slots: item.metadata?.slots || {},
reason: item.reason || "",
})) : []);
if (!stepItems.length) {
const empty = document.createElement("div");
empty.className = "hint";
empty.textContent = "planner 已触发,但当前没有可展示的 step。";
els.plannerSteps.appendChild(empty);
}
stepItems.forEach((item, index) => {
const block = document.createElement("div");
block.className = "planner-step";
const title = document.createElement("div");
title.className = "planner-step-title";
title.textContent = `#${index + 1} ${item.intent_id || "unknown"}`;
block.appendChild(title);
const stepMeta = document.createElement("div");
stepMeta.className = "planner-step-meta";
const parts = [];
if (item.clause_text) {
parts.push(`clause=${item.clause_text}`);
}
if (item.reason) {
parts.push(`reason=${item.reason}`);
}
if (Array.isArray(item.depends_on) && item.depends_on.length) {
parts.push(`depends_on=${JSON.stringify(item.depends_on)}`);
}
if (item.condition && Object.keys(item.condition).length) {
parts.push(`condition=${JSON.stringify(item.condition)}`);
}
if (item.requires_confirmation) {
parts.push("confirm=yes");
}
const slots = item.normalized_slots || item.slots || {};
if (Object.keys(slots).length) {
parts.push(`slots=${JSON.stringify(slots, null, 0)}`);
} else {
parts.push("slots={}");
}
if (item.cloud_slots && Object.keys(item.cloud_slots).length) {
parts.push(`cloud=${JSON.stringify(item.cloud_slots, null, 0)}`);
}
if (item.clause_slots && Object.keys(item.clause_slots).length) {
parts.push(`clause_extract=${JSON.stringify(item.clause_slots, null, 0)}`);
}
stepMeta.textContent = parts.join(" | ");
block.appendChild(stepMeta);
els.plannerSteps.appendChild(block);
});
const rawJson = meta.parsed_plan || meta.raw_response || workflow?.meta?.planner_debug?.parsed_plan || {};
if (typeof rawJson === "string") {
try {
els.plannerRawJson.textContent = JSON.stringify(JSON.parse(rawJson), null, 2);
} catch (_) {
els.plannerRawJson.textContent = rawJson;
}
} else {
els.plannerRawJson.textContent = JSON.stringify(rawJson, null, 2);
}
}
function updateResponsePanel(data) {
state.pendingSlots = Array.isArray(data.pending_slots) ? data.pending_slots : [];
state.lastWorkflow = data.workflow || null;
state.lastResponse = data;
const routingDebug = data.routing_debug || {};
els.currentIntent.textContent = data.intent || "-";
const currentStage = routingDebug.matched_stage || "-";
const currentDecision = routingDebug.decision ? ` / ${routingDebug.decision}` : "";
els.currentMatchedStage.textContent = `${currentStage}${currentDecision}`;
els.currentStatus.textContent = data.status || "-";
els.pendingSlots.textContent = state.pendingSlots.length ? state.pendingSlots.join(", ") : "-";
els.firstLatency.textContent = formatLatency(data.first_response_latency_ms);
els.totalLatency.textContent = formatLatency(data.total_latency_ms);
els.timingBreakdown.textContent = formatBreakdown(data.processing_breakdown);
els.traceId.textContent = data.trace_id || "-";
els.executionProcess.textContent = buildExecutionProcessText(data);
renderProcessFlow(data);
els.workflowJson.textContent = JSON.stringify(data.workflow || {}, null, 2);
renderCabinDemo(data);
renderRoutingDebug(routingDebug);
renderPlannerDebug(routingDebug, data.workflow || {});
if (state.pendingSlots.length) {
els.composerStatus.textContent = `等待补槽: ${state.pendingSlots.join(", ")},下一条消息会走 fill-slots。`;
} else {
els.composerStatus.textContent = "当前会自动判断是首次请求还是补槽续跑。";
}
persistSession();
}
function renderRuntimeBadges(runtime) {
els.runtimeBadges.innerHTML = "";
const badges = [
`intent_route=bert-first`,
`pipeline=${runtime.matcher_pipeline}`,
`classifier=${runtime.classifier_backend}`,
`session=${runtime.session_backend}`,
`slot=${runtime.slot_extractor_backend}`,
`planner=${runtime.planner_backend}`,
`planner_model=${runtime.planner_model_name || "-"}`,
];
for (const item of badges) {
const badge = document.createElement("div");
badge.className = "badge";
badge.textContent = item;
els.runtimeBadges.appendChild(badge);
}
}
async function fetchRuntimeConfig() {
const res = await fetch("/api/v1/demo/runtime");
const runtime = await res.json();
state.runtime = runtime;
els.matcherPipeline.value = "classifier";
els.classifierBackend.value = runtime.classifier_backend;
els.sessionBackend.value = runtime.session_backend;
renderRuntimeBadges(runtime);
els.runtimeStatus.textContent = "当前页面和后端运行配置已同步。";
}
async function applyRuntimeConfig() {
const payload = {
matcher_pipeline: "classifier",
classifier_backend: els.classifierBackend.value,
session_backend: els.sessionBackend.value,
};
els.applyRuntimeBtn.disabled = true;
els.runtimeStatus.textContent = "正在应用配置...";
try {
const res = await fetch("/api/v1/demo/runtime", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "配置切换失败");
}
state.runtime = data;
renderRuntimeBadges(data);
els.runtimeStatus.textContent = "配置已应用,后续请求将使用新的运行时设置。";
addMessage("system", `演示配置已切换:${data.matcher_pipeline} / ${data.classifier_backend} / ${data.session_backend}`);
} catch (err) {
els.runtimeStatus.textContent = `配置切换失败:${err.message}`;
} finally {
els.applyRuntimeBtn.disabled = false;
}
}
async function sendMessage() {
const text = els.messageInput.value.trim();
if (!text) return;
const isFillSlots = state.pendingSlots.length > 0;
addMessage("user", text, isFillSlots ? "继续当前任务" : "");
els.messageInput.value = "";
const endpoint = isFillSlots ? "/api/v1/agent/fill-slots" : "/api/v1/agent/chat";
const streamEndpoint = "/api/v1/agent/chat-stream";
const payload = isFillSlots
? {
session_id: els.sessionId.value.trim(),
user_id: els.userId.value.trim(),
input_text: text,
}
: {
session_id: els.sessionId.value.trim(),
user_id: els.userId.value.trim(),
channel: "demo-web",
input_text: text,
input_type: "text",
};
els.sendBtn.disabled = true;
try {
if (!isFillSlots) {
const streamRes = await fetch(streamEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!streamRes.ok) {
throw new Error(`流式请求失败: ${streamRes.status}`);
}
let finalData = null;
await readNdjsonStream(streamRes, (event) => {
if (!event || typeof event !== "object") return;
if (event.type === "ack") {
clearTransientMessages();
addMessage("agent", event.reply_text || "好的,正在处理中,请稍等一下。", "处理中", "", { transient: true });
return;
}
if (event.type === "final") {
finalData = event.data || null;
return;
}
if (event.type === "error") {
throw new Error(event.message || "流式处理失败");
}
});
if (!finalData) {
throw new Error("流式响应未返回最终结果");
}
clearTransientMessages();
addMessage(
"agent",
finalData.reply_text || "无返回文本",
buildAgentMeta(finalData),
buildRewriteNote(finalData.routing_debug),
);
updateResponsePanel(finalData);
return;
}
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "请求失败");
}
addMessage(
"agent",
data.reply_text || "无返回文本",
buildAgentMeta(data),
buildRewriteNote(data.routing_debug),
);
updateResponsePanel(data);
} catch (err) {
addMessage("system", `请求失败:${err.message}`);
} finally {
els.sendBtn.disabled = false;
els.messageInput.focus();
}
}
function clearChat() {
state.messages = [];
state.pendingSlots = [];
state.lastWorkflow = null;
state.lastResponse = null;
persistSession();
renderMessages();
updateResponsePanel({ pending_slots: [], workflow: {}, intent: "-", status: "-", trace_id: "-" });
renderCabinDemo(null, { reset: true });
addMessage("system", "聊天记录已清空,当前 session 保持不变。");
}
function newSession() {
initIds();
state.messages = [];
state.pendingSlots = [];
state.lastWorkflow = null;
state.lastResponse = null;
renderMessages();
updateResponsePanel({ pending_slots: [], workflow: {}, intent: "-", status: "-", trace_id: "-" });
renderCabinDemo(null, { reset: true });
addMessage("system", "已创建新的演示会话。");
persistSession();
}
function renderExamples() {
els.exampleChips.innerHTML = "";
for (const text of demoExamples) {
const chip = document.createElement("button");
chip.className = "chip";
chip.textContent = text;
chip.addEventListener("click", () => {
els.messageInput.value = text;
els.messageInput.focus();
});
els.exampleChips.appendChild(chip);
}
}
function renderSessionList() {
const index = getSessionIndex();
const currentSessionId = els.sessionId.value.trim();
els.sessionList.innerHTML = "";
if (!index.length) {
const empty = document.createElement("div");
empty.className = "hint";
empty.textContent = "还没有本地历史 session发几条消息后会出现在这里。";
els.sessionList.appendChild(empty);
return;
}
for (const item of index) {
const block = document.createElement("div");
block.className = `session-item ${item.session_id === currentSessionId ? "active" : ""}`;
block.innerHTML = `
<div class="s-top">${item.session_id}</div>
<div class="s-sub">${item.preview || "暂无消息"}</div>
<div class="s-sub">${item.user_id} · ${new Date(item.updated_at).toLocaleString()}</div>
`;
block.addEventListener("click", () => {
if (loadSession(item.session_id)) {
renderSessionList();
addMessage("system", `已恢复本地会话 ${item.session_id}`);
}
});
els.sessionList.appendChild(block);
}
}
async function checkHealth() {
try {
const res = await fetch("/health");
const data = await res.json();
els.serverStatus.textContent = `服务状态:${data.status} / ${data.env}`;
} catch (_) {
els.serverStatus.textContent = "服务状态:连接失败";
}
}
function bindEvents() {
els.sendBtn.addEventListener("click", sendMessage);
els.clearBtn.addEventListener("click", clearChat);
els.copySummaryBtn.addEventListener("click", () => copyTextToClipboard(buildExportSummary(), "已复制测试摘要,可直接粘贴给我。"));
els.copyJsonBtn.addEventListener("click", () => copyTextToClipboard(buildExportJson(), "已复制测试 JSON可直接粘贴给我。"));
els.newSessionBtn.addEventListener("click", newSession);
els.refreshSessionsBtn.addEventListener("click", renderSessionList);
els.applyRuntimeBtn.addEventListener("click", applyRuntimeConfig);
els.toggleDebugBtn.addEventListener("click", () => setDebugVisible(!state.debugVisible));
els.sessionId.addEventListener("change", () => {
if (!loadSession(els.sessionId.value.trim())) {
state.messages = [];
renderMessages();
updateResponsePanel({ pending_slots: [], workflow: {}, intent: "-", status: "-", trace_id: "-" });
renderCabinDemo(null, { reset: true });
persistSession();
}
renderSessionList();
});
els.userId.addEventListener("change", persistSession);
els.messageInput.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
sendMessage();
}
});
}
async function init() {
const storedDebugVisible = localStorage.getItem(DEBUG_VISIBILITY_KEY);
setDebugVisible(storedDebugVisible === null ? window.innerWidth > 1280 : storedDebugVisible === "1");
initIds();
renderExamples();
bindEvents();
await checkHealth();
await fetchRuntimeConfig();
addMessage("system", "演示页面已就绪。输入一条用户指令开始体验。");
updateResponsePanel({ pending_slots: [], workflow: {}, intent: "-", status: "-", trace_id: "-" });
renderCabinDemo(null, { reset: true });
renderSessionList();
}
init();
</script>
</body>
</html>