2255 lines
74 KiB
HTML
2255 lines
74 KiB
HTML
<!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> </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>
|