test/test_web/index.html

4947 lines
163 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Qwen3-ASR 会议识别测试</title>
<style>
:root {
--bg: #f5f7fa;
--panel: #ffffff;
--panel-soft: #f8fafc;
--line: #d8e0ea;
--line-strong: #c5d0dd;
--text: #132033;
--muted: #637083;
--brand: #0f766e;
--brand-strong: #0b5f59;
--brand-soft: #e6f5f3;
--ok: #067647;
--warn: #b54708;
--danger: #b42318;
--shadow: 0 18px 46px rgba(19, 32, 51, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--text);
font-family: "PingFang SC", "Noto Sans SC", "Segoe UI", "Microsoft YaHei", sans-serif;
background:
linear-gradient(180deg, rgba(15, 118, 110, 0.08), transparent 220px),
var(--bg);
}
button, input, textarea, select {
font: inherit;
}
button {
border: 0;
}
.page {
max-width: 1280px;
margin: 0 auto;
padding: 28px 18px 42px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 20px;
}
.eyebrow {
color: var(--brand-strong);
font-size: 13px;
font-weight: 800;
margin-bottom: 8px;
}
h1 {
margin: 0;
font-size: 32px;
line-height: 1.15;
letter-spacing: 0;
}
h2 {
margin: 0;
font-size: 18px;
letter-spacing: 0;
}
h3 {
margin: 0;
font-size: 15px;
letter-spacing: 0;
}
p {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.7;
}
.status {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 5px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel);
color: var(--muted);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.status.ok { color: var(--ok); background: #ecfdf3; border-color: #abefc6; }
.status.warn { color: var(--warn); background: #fffaeb; border-color: #fedf89; }
.status.err { color: var(--danger); background: #fef3f2; border-color: #fecdca; }
.layout {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(420px, 0.95fr);
gap: 18px;
align-items: start;
}
.stack {
display: grid;
gap: 16px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 18px;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.steps {
display: grid;
gap: 12px;
}
.step {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
}
.step-no {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
background: var(--brand);
color: #fff;
font-weight: 900;
font-size: 14px;
}
.field {
min-width: 0;
}
label {
display: block;
margin-bottom: 7px;
color: var(--muted);
font-size: 13px;
font-weight: 800;
}
input[type="text"],
input[type="number"],
input[type="file"],
select,
textarea {
width: 100%;
min-width: 0;
padding: 11px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--text);
outline: none;
}
input:focus,
select:focus,
textarea:focus {
border-color: rgba(15, 118, 110, 0.55);
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
}
input[type="file"] {
background: #fff;
}
textarea {
min-height: 150px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.55;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.btn {
min-height: 42px;
padding: 10px 15px;
border-radius: 8px;
cursor: pointer;
font-weight: 900;
background: var(--brand);
color: #fff;
transition: background 0.15s ease, transform 0.15s ease, opacity 0.15s ease;
}
.btn:hover { background: var(--brand-strong); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.btn.secondary {
background: #e7eef6;
color: #203047;
}
.btn.secondary:hover {
background: #dce6f0;
}
.btn.danger {
background: var(--danger);
}
.btn.primary-wide {
width: 100%;
min-height: 52px;
font-size: 16px;
}
.switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--muted);
font-size: 13px;
font-weight: 800;
}
.source-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 12px 0;
}
.tab-btn {
min-height: 38px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--muted);
cursor: pointer;
font-weight: 800;
}
.tab-btn.active {
color: var(--brand-strong);
background: var(--brand-soft);
border-color: rgba(15, 118, 110, 0.35);
}
.workspace-tabs {
display: inline-grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 18px;
padding: 6px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
}
.workspace-tab {
min-height: 42px;
padding: 10px 16px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted);
font-weight: 900;
cursor: pointer;
}
.workspace-tab.active {
background: var(--brand);
color: #fff;
box-shadow: 0 10px 24px rgba(15, 118, 110, 0.18);
}
.workspace-panel {
display: none;
}
.workspace-panel.active {
display: grid;
gap: 16px;
}
.source-panel {
display: none;
}
.source-panel.active {
display: grid;
gap: 12px;
}
.hint {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.pill {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
background: var(--brand-soft);
color: var(--brand-strong);
font-size: 12px;
font-weight: 900;
}
.progress-card {
padding: 16px;
border-radius: 8px;
background: var(--panel-soft);
border: 1px solid var(--line);
}
.progress-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.progress-title {
font-size: 14px;
font-weight: 900;
}
.progress-percent {
color: var(--brand-strong);
font-size: 13px;
font-weight: 900;
}
.progress-track {
width: 100%;
height: 9px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.progress-fill {
width: 0;
height: 100%;
border-radius: 999px;
background: var(--brand);
transition: width 0.25s ease;
}
.upload-progress {
display: grid;
gap: 7px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.upload-progress[hidden] {
display: none;
}
.upload-progress .progress-track {
height: 7px;
}
.upload-progress .hint {
font-weight: 800;
}
.progress-note {
margin-top: 10px;
color: var(--text);
font-size: 14px;
font-weight: 800;
}
.progress-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin-top: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.metric {
padding: 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--panel-soft);
}
.metric strong {
display: block;
font-size: 20px;
margin-bottom: 4px;
}
.metric span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.result {
display: grid;
gap: 10px;
max-height: 620px;
overflow: auto;
padding-right: 4px;
}
.empty-state {
padding: 42px 18px;
border: 1px dashed var(--line-strong);
border-radius: 8px;
background: var(--panel-soft);
text-align: center;
color: var(--muted);
font-size: 14px;
}
.segment {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
padding: 12px;
}
.segment-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 900;
}
.segment p {
color: var(--text);
font-size: 14px;
}
.live-grid {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
gap: 14px;
align-items: start;
}
.live-card {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
padding: 14px;
}
.live-card h3 {
margin-bottom: 6px;
}
.live-transcript {
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
padding: 6px 6px 4px;
min-height: 0;
}
.bubble-row {
display: flex;
margin-bottom: 2px;
}
.bubble-row.speaker-left {
justify-content: flex-start;
}
.bubble-row.speaker-right {
justify-content: flex-start;
}
.bubble-row.speaker-unknown .bubble-body {
opacity: 0.9;
}
.bubble-wrapper {
max-width: min(82%, 720px);
}
.bubble-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 5px;
font-size: 11px;
color: #8a93a5;
}
.speaker-badge {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 999px;
flex: 0 0 auto;
}
.bubble-body {
padding: 11px 13px;
border-radius: 14px;
font-size: 14px;
line-height: 1.65;
color: var(--wx-text);
word-break: break-word;
background: #edf3ff;
border-top-left-radius: 4px;
}
.speaker-right .bubble-body {
background: #eef8ef;
border-top-left-radius: 4px;
border-top-right-radius: 14px;
}
.bubble-body.interim {
background: #f5f7fa;
color: #8d97ab;
font-style: italic;
}
.pending-text {
color: #9aa3b5;
font-style: italic;
opacity: 0.78;
transition: opacity 0.25s ease;
}
.pending-text::before {
content: " ";
}
.bubble-time {
font-size: 10px;
color: #a0a8b8;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.speaker-color-0 { background-color: #4a7dff; }
.speaker-color-1 { background-color: #52c41a; }
.speaker-color-2 { background-color: #faad14; }
.speaker-color-3 { background-color: #ff4d4f; }
.speaker-color-4 { background-color: #9254de; }
.speaker-color-5 { background-color: #13c2c2; }
.live-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.live-stat {
padding: 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: #fff;
}
.live-stat strong {
display: block;
font-size: 20px;
margin-bottom: 4px;
}
.live-stat span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.session-list {
display: grid;
gap: 10px;
max-height: 280px;
overflow: auto;
padding-right: 4px;
}
.session-item {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 12px;
}
.session-item strong {
display: block;
margin-bottom: 6px;
font-size: 13px;
}
.session-item p {
color: var(--text);
font-size: 13px;
}
.session-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-bottom: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
details {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
details > summary {
cursor: pointer;
padding: 13px 14px;
color: var(--text);
font-weight: 900;
list-style: none;
}
details > summary::-webkit-details-marker { display: none; }
.details-body {
border-top: 1px solid var(--line);
padding: 14px;
display: grid;
gap: 12px;
background: var(--panel-soft);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th, td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 10px 8px;
vertical-align: top;
}
th {
color: var(--muted);
font-size: 12px;
background: var(--panel-soft);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.muted { color: var(--muted); }
.hidden { display: none !important; }
audio {
width: 100%;
margin-top: 8px;
}
@media (max-width: 1020px) {
.layout {
grid-template-columns: 1fr;
}
.live-grid {
grid-template-columns: 1fr;
}
.topbar {
align-items: stretch;
display: grid;
}
}
@media (max-width: 720px) {
.page { padding: 18px 12px 30px; }
h1 { font-size: 26px; }
.grid-2, .grid-3, .summary-grid, .source-tabs, .live-stats {
grid-template-columns: 1fr;
}
.step {
grid-template-columns: 1fr;
}
}
</style>
<style>
:root {
--wx-bg: #f0f2f5;
--wx-panel: #ffffff;
--wx-soft: #f7f8fa;
--wx-line: #e5e7eb;
--wx-text: #1a1a2e;
--wx-muted: #667085;
--wx-brand: #4a7dff;
--wx-brand-strong: #3a6ae8;
--wx-brand-soft: #eef4ff;
--wx-danger: #ff4d4f;
--wx-success: #52c41a;
--wx-shadow: 0 8px 26px rgba(15, 23, 42, 0.08);
}
body {
background: radial-gradient(circle at top left, rgba(74, 125, 255, 0.12), transparent 24%), var(--wx-bg);
color: var(--wx-text);
min-height: 100vh;
}
.page {
max-width: 100%;
padding: 20px;
}
.topbar {
align-items: center;
margin-bottom: 18px;
padding: 18px 22px;
border: 1px solid rgba(74, 125, 255, 0.12);
border-radius: 18px;
background: linear-gradient(135deg, rgba(74, 125, 255, 0.12), rgba(255, 255, 255, 0.96));
box-shadow: var(--wx-shadow);
}
.eyebrow {
color: var(--wx-brand);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.topbar h1 {
font-size: 28px;
}
.topbar p {
color: var(--wx-muted);
max-width: 680px;
}
.layout {
grid-template-columns: 360px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.sidebar-shell,
.content-shell {
min-width: 0;
display: grid;
gap: 16px;
}
.sidebar-shell {
position: sticky;
top: 20px;
}
.panel,
.live-card,
.progress-card,
details {
border: 1px solid var(--wx-line);
border-radius: 16px;
background: var(--wx-panel);
box-shadow: var(--wx-shadow);
}
.panel {
padding: 18px;
}
.section-head h2,
.live-card h3 {
color: var(--wx-text);
}
.section-head p,
.hint,
p {
color: var(--wx-muted);
}
.workspace-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
padding: 6px;
border: 1px solid var(--wx-line);
border-radius: 14px;
background: var(--wx-panel);
box-shadow: var(--wx-shadow);
}
.workspace-tab {
min-height: 44px;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
}
.workspace-tab.active {
background: var(--wx-brand);
box-shadow: 0 8px 18px rgba(74, 125, 255, 0.22);
}
.workspace-panel.active {
display: block;
}
.live-grid {
display: block;
}
.live-card {
padding: 18px;
background: var(--wx-panel);
}
.live-card + .live-card {
margin-top: 14px;
}
.menu-stack {
display: grid;
gap: 14px;
}
.menu-block {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid #e7eaf0;
border-radius: 14px;
background: var(--wx-soft);
}
.menu-block h3 {
font-size: 14px;
margin: 0;
}
.menu-subtitle {
color: var(--wx-muted);
font-size: 12px;
line-height: 1.6;
}
.mini-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.mini-grid .field,
.mini-grid .switch {
min-width: 0;
}
.menu-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.menu-inline .pill {
background: #fff;
border: 1px solid #dbe4ff;
}
.compact-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.compact-actions .btn:last-child {
grid-column: 1 / -1;
}
.compact-details {
border: 1px solid #e7eaf0;
border-radius: 14px;
background: #fff;
box-shadow: none;
overflow: hidden;
}
.compact-details > summary {
padding: 12px 14px;
font-size: 13px;
font-weight: 700;
color: var(--wx-text);
}
.compact-details .details-body {
background: var(--wx-soft);
padding: 14px;
}
.compact-note {
font-size: 12px;
color: var(--wx-muted);
line-height: 1.6;
}
.row,
.actions {
gap: 10px;
}
.btn {
min-height: 40px;
border-radius: 10px;
font-weight: 700;
}
.btn,
.btn.primary-wide,
.workspace-tab.active {
background: var(--wx-brand);
color: #fff;
}
.btn:hover,
.btn.primary-wide:hover {
background: var(--wx-brand-strong);
}
.btn.secondary {
background: #eef2ff;
color: #2440a8;
}
.btn.secondary:hover {
background: #dfe7ff;
}
.btn.danger {
background: var(--wx-danger);
}
.field label,
.switch {
color: var(--wx-muted);
font-weight: 600;
}
input[type="text"],
input[type="number"],
input[type="file"],
select,
textarea {
border: 1px solid #d7dce5;
border-radius: 10px;
background: #fff;
color: var(--wx-text);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus,
select:focus,
textarea:focus {
border-color: rgba(74, 125, 255, 0.55);
box-shadow: 0 0 0 3px rgba(74, 125, 255, 0.14);
}
.switch {
justify-content: space-between;
padding: 10px 12px;
border: 1px solid #e4e7ec;
border-radius: 10px;
background: var(--wx-soft);
}
.pill {
background: var(--wx-brand-soft);
color: var(--wx-brand);
border-radius: 999px;
font-weight: 700;
}
.status {
border-radius: 999px;
border: 1px solid #d9e2ff;
background: #fff;
color: var(--wx-muted);
font-weight: 700;
}
.status.ok {
color: #067647;
background: #ecfdf3;
border-color: #abefc6;
}
.status.warn {
color: #b54708;
background: #fffaeb;
border-color: #fedf89;
}
.status.err {
color: #b42318;
background: #fef3f2;
border-color: #fecdca;
}
.progress-card,
.metric,
.segment,
.live-line,
.session-item,
.upload-progress {
background: var(--wx-soft);
border-color: #e6e8ec;
border-radius: 12px;
}
.progress-fill {
background: linear-gradient(90deg, #4a7dff, #78a3ff);
}
.content-shell {
height: calc(100vh - 132px);
min-height: 680px;
overflow: hidden;
}
#workspaceLiveAside.workspace-panel.active {
display: grid;
grid-template-rows: minmax(0, 1fr) 300px;
gap: 16px;
min-height: 0;
}
#workspaceLiveAside > .panel:first-child {
display: flex;
flex-direction: column;
min-height: 0;
}
#workspaceOfflineAside.workspace-panel.active {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 16px;
min-height: 0;
overflow: hidden;
}
#workspaceSummary.workspace-panel.active {
display: grid;
gap: 16px;
}
#workspaceSummaryAside.workspace-panel.active {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 16px;
height: 100%;
min-height: 0;
overflow: hidden;
}
#workspaceSummaryAside .live-log-card {
height: auto;
min-height: 0;
max-height: 220px;
}
#workspaceSummaryAside .summary-request-preview .details-body {
max-height: 190px;
overflow: hidden;
}
#workspaceSummaryAside .summary-request-preview .prompt-preview {
height: 132px;
min-height: 132px;
}
.summary-result-card {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.summary-mode-grid,
.summary-source-grid {
display: grid;
gap: 10px;
}
.summary-mode-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.summary-source-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.summary-mode,
.summary-source-btn {
border: 1px solid #e4e7ec;
border-radius: 10px;
background: #fff;
color: var(--wx-text);
cursor: pointer;
text-align: left;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.summary-mode {
min-height: 74px;
padding: 12px;
}
.summary-mode strong {
display: block;
margin-bottom: 4px;
font-size: 14px;
}
.summary-mode span {
color: var(--wx-muted);
font-size: 12px;
line-height: 1.45;
}
.summary-source-btn {
min-height: 38px;
padding: 8px 10px;
color: var(--wx-muted);
font-size: 13px;
font-weight: 700;
text-align: center;
}
.summary-mode.active,
.summary-source-btn.active {
border-color: rgba(74, 125, 255, 0.48);
background: var(--wx-brand-soft);
box-shadow: 0 0 0 3px rgba(74, 125, 255, 0.12);
}
.summary-output {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
.summary-result-shell {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
border: 1px solid #e7ebf1;
border-radius: 10px;
background: #fff;
}
.summary-result-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid #edf1f5;
background: #f7f8fa;
flex-shrink: 0;
}
.summary-view-tabs {
display: inline-grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
padding: 4px;
border: 1px solid #e4e7ec;
border-radius: 8px;
background: #fff;
}
.summary-view-tab {
min-height: 32px;
padding: 6px 12px;
border-radius: 6px;
background: transparent;
color: var(--wx-muted);
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
.summary-view-tab.active {
background: var(--wx-brand);
color: #fff;
}
.summary-result-body {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
.summary-thinking-panel {
display: flex;
flex-direction: column;
max-height: 150px;
min-height: 86px;
border-bottom: 1px solid #edf1f5;
background: #fbfcff;
flex-shrink: 0;
}
.summary-thinking-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 9px 12px;
color: var(--wx-muted);
font-size: 12px;
font-weight: 700;
border-bottom: 1px solid #edf1f5;
flex-shrink: 0;
}
#summaryThinkingOutput {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px 12px;
color: #475467;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.65;
white-space: pre-wrap;
}
.summary-main-body {
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
#summaryMarkdownEditor {
display: none;
width: 100%;
height: 100%;
min-height: 100%;
border: 0;
border-radius: 0;
resize: none;
overflow-y: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.65;
}
#summaryMarkdownPreview {
height: 100%;
min-height: 100%;
max-height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
}
#workspaceSummaryAside #summaryMarkdownPreview,
#workspaceSummaryAside #summaryMarkdownEditor {
height: 100%;
max-height: 100%;
overflow-y: auto;
}
.summary-result-shell.editing #summaryMarkdownPreview {
display: none;
}
.summary-result-shell.editing #summaryMarkdownEditor {
display: block;
}
.summary-doc {
padding: 16px;
color: var(--wx-text);
font-size: 14px;
line-height: 1.75;
}
.summary-doc h3 {
margin: 0 0 8px;
font-size: 17px;
}
.summary-doc h4 {
margin: 14px 0 6px;
font-size: 14px;
}
.summary-doc ul,
.summary-doc ol {
margin: 6px 0 0 20px;
padding: 0;
}
.summary-doc li {
margin-bottom: 5px;
}
.summary-doc p {
margin: 6px 0;
color: var(--wx-text);
line-height: 1.75;
}
.summary-warning {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #fedf89;
border-radius: 8px;
background: #fffaeb;
color: #93370d;
font-size: 13px;
line-height: 1.6;
}
.prompt-preview {
min-height: 0;
height: 100%;
background: #0f172a;
color: #dbeafe;
border-color: #1f2937;
}
.result,
.session-list {
max-height: none;
overflow-y: auto;
}
#liveRawOutput,
#rawOutput {
min-height: 0;
height: 100%;
background: #111827;
color: #dbeafe;
border-color: #1f2937;
}
.content-shell .panel,
.content-shell .live-card {
overflow: hidden;
}
@media (max-width: 1080px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar-shell {
position: static;
}
.content-shell {
height: auto;
min-height: 0;
}
#workspaceLiveAside.workspace-panel.active,
#workspaceOfflineAside.workspace-panel.active,
#workspaceSummaryAside.workspace-panel.active {
grid-template-rows: auto;
}
}
@media (max-width: 720px) {
.mini-grid,
.compact-actions,
.summary-mode-grid,
.summary-source-grid {
grid-template-columns: 1fr;
}
}
.topbar {
margin-bottom: 14px;
padding: 14px 18px;
border-radius: 10px;
background: #fff;
border: 1px solid #e7ebf1;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.topbar .eyebrow {
margin-bottom: 6px;
letter-spacing: 0.04em;
}
.topbar h1 {
font-size: 24px;
}
.topbar p {
margin-top: 4px;
max-width: none;
font-size: 13px;
}
.layout {
grid-template-columns: 340px minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.sidebar-shell {
gap: 12px;
}
.content-shell {
gap: 14px;
height: calc(100vh - 108px);
min-height: 700px;
}
.workspace-tabs {
padding: 4px;
border-radius: 8px;
box-shadow: none;
}
.workspace-tab {
min-height: 40px;
border-radius: 6px;
font-size: 13px;
font-weight: 700;
}
#workspaceLive > .panel {
padding: 16px;
border-radius: 10px;
border-color: #e7ebf1;
background: #f9fafb;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
#workspaceLive .section-head {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #e9edf3;
}
#workspaceLive .section-head h2 {
font-size: 18px;
margin-bottom: 2px;
}
#workspaceLive .section-head p {
font-size: 12px;
color: #8a94a6;
}
#workspaceLive .menu-stack {
gap: 12px;
}
#workspaceLive .menu-block {
gap: 10px;
padding: 12px;
border-radius: 10px;
border: 1px solid #e7ebf1;
background: #fff;
}
#workspaceLive .menu-block h3 {
font-size: 14px;
font-weight: 700;
}
#workspaceLive .menu-subtitle,
#workspaceLive .compact-note {
font-size: 12px;
line-height: 1.55;
color: #8a94a6;
}
#workspaceLive .compact-details {
border-radius: 10px;
border-color: #e7ebf1;
box-shadow: none;
}
#workspaceLive .compact-details > summary {
padding: 12px;
font-size: 13px;
}
#workspaceLive .compact-details .details-body {
padding: 12px;
}
#workspaceLive input[type="text"],
#workspaceLive input[type="number"],
#workspaceLive textarea {
padding: 9px 10px;
border-radius: 8px;
font-size: 13px;
}
#workspaceLive .switch {
min-height: 40px;
padding: 9px 10px;
border-radius: 8px;
background: #f7f8fa;
}
#workspaceLive .compact-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#workspaceLiveAside.workspace-panel.active {
display: flex;
flex-direction: column;
gap: 14px;
height: 100%;
min-height: 0;
}
.live-result-card,
.live-log-card {
border-radius: 10px;
border-color: #e7ebf1;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
background: #fff;
}
.live-result-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 16px 18px;
}
.live-log-card {
height: 260px;
min-height: 260px;
display: flex;
flex-direction: column;
padding: 16px 18px;
}
.live-result-head,
.live-log-head {
margin-bottom: 10px;
}
.live-output-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 14px;
margin-bottom: 10px;
padding: 8px 10px;
border-radius: 8px;
background: #f7f8fa;
border: 1px solid #edf1f5;
font-size: 12px;
flex-shrink: 0;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 6px;
color: #667085;
}
.meta-chip strong {
color: #1a1a2e;
font-size: 12px;
}
.live-transcript-shell {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
#liveTranscript {
flex: 1;
min-height: 0;
padding: 4px 4px 2px;
border-radius: 8px;
background: #fff;
}
#liveRawOutput {
flex: 1;
min-height: 0;
margin-top: 2px;
border-radius: 8px;
background: #0f172a;
color: #dbeafe;
}
.empty-state {
display: grid;
place-items: center;
min-height: 100%;
padding: 40px 18px;
text-align: center;
color: #98a2b3;
font-size: 14px;
line-height: 1.7;
}
.live-transcript {
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
scrollbar-width: thin;
}
.bubble-row {
display: flex;
margin-bottom: 2px;
}
/* Match Asr-demo: alternate speaker lanes and keep unresolved speech neutral. */
.bubble-row.speaker-left { justify-content: flex-start; }
.bubble-row.speaker-right { justify-content: flex-end; }
.speaker-right .bubble-header { justify-content: flex-end; }
.speaker-right .bubble-body {
border-top-left-radius: 12px;
border-top-right-radius: 4px;
text-align: right;
}
.bubble-row.speaker-unknown .bubble-body {
background: #f0f0f0;
color: #999;
font-style: italic;
border-radius: 12px;
border-top-left-radius: 4px;
}
.speaker-badge.speaker-color-unknown { background-color: #ccc; }
.bubble-wrapper {
max-width: min(78%, 760px);
}
.bubble-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
color: #98a2b3;
font-size: 11px;
}
.speaker-name {
font-weight: 600;
color: #667085;
}
.bubble-body {
padding: 10px 13px;
border-radius: 12px;
line-height: 1.7;
font-size: 14px;
background: #edf3ff;
color: #1a1a2e;
border-top-left-radius: 4px;
}
.bubble-row.speaker-1 .bubble-body { background: #eef9ef; }
.bubble-row.speaker-2 .bubble-body { background: #fff7e6; }
.bubble-row.speaker-3 .bubble-body { background: #fff1f0; }
.bubble-row.speaker-4 .bubble-body { background: #f9f0ff; }
.bubble-row.speaker-5 .bubble-body { background: #e6fffb; }
.bubble-body.interim {
background: #f6f7fb;
color: #98a2b3;
font-style: italic;
}
.pending-text {
color: #98a2b3;
font-style: italic;
opacity: 0.9;
}
.pending-text::before {
content: " ";
}
.bubble-time {
font-size: 10px;
color: #b1b8c6;
}
@media (max-width: 1080px) {
.layout {
grid-template-columns: 1fr;
}
.content-shell {
height: auto;
min-height: 0;
}
.live-log-card {
height: 280px;
min-height: 280px;
}
}
:root {
--tw-bg: #f4f7ff;
--tw-sidebar: #eaf1ff;
--tw-card: #ffffff;
--tw-card-soft: #f1f4ff;
--tw-line: #dfe6fb;
--tw-text: #25264b;
--tw-muted: #74799b;
--tw-primary: #6256ea;
--tw-primary-2: #7569ff;
--tw-cyan: #59c8df;
--tw-shadow: 0 18px 48px rgba(56, 62, 110, 0.12);
}
body {
background:
radial-gradient(circle at 78% 18%, rgba(217, 232, 255, 0.92), transparent 28%),
radial-gradient(circle at 82% 44%, rgba(124, 128, 255, 0.16), transparent 22%),
linear-gradient(135deg, #f8fbff 0%, var(--tw-bg) 58%, #ffffff 100%);
color: var(--tw-text);
}
.page {
max-width: 100%;
min-height: 100vh;
padding: 22px 26px 28px;
}
.topbar {
min-height: 82px;
margin: 0 0 18px;
padding: 18px 22px;
border: 1px solid rgba(219, 226, 250, 0.92);
border-radius: 18px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 14px 34px rgba(83, 88, 145, 0.08);
backdrop-filter: blur(8px);
}
.topbar .eyebrow {
color: var(--tw-primary);
font-size: 13px;
font-weight: 900;
letter-spacing: 0;
text-transform: none;
}
.topbar h1 {
color: var(--tw-text);
font-size: 28px;
font-weight: 900;
}
.topbar p {
color: var(--tw-muted);
}
.topbar .actions {
align-items: center;
}
.layout {
display: grid;
grid-template-columns: minmax(360px, 420px) minmax(0, 1fr);
gap: 18px;
align-items: stretch;
min-height: calc(100vh - 146px);
}
.sidebar-shell {
position: sticky;
top: 18px;
align-self: start;
max-height: calc(100vh - 44px);
padding: 0;
gap: 14px;
background: transparent;
border-right: 0;
overflow-y: auto;
z-index: auto;
scrollbar-width: thin;
}
.workspace-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 6px;
border: 1px solid rgba(219, 226, 250, 0.92);
border-radius: 14px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 12px 28px rgba(83, 88, 145, 0.08);
}
.workspace-tab {
min-height: 44px;
padding: 10px 12px;
border-radius: 10px;
background: transparent;
color: #474969;
text-align: center;
font-size: 14px;
font-weight: 800;
}
.workspace-tab.active {
background: linear-gradient(135deg, var(--tw-primary), var(--tw-primary-2));
color: #fff;
box-shadow: 0 12px 24px rgba(98, 86, 234, 0.26);
}
.sidebar-shell .panel,
.sidebar-shell details {
border: 1px solid rgba(210, 218, 249, 0.86);
border-radius: 14px;
box-shadow: 0 12px 30px rgba(83, 88, 145, 0.08);
background: rgba(255, 255, 255, 0.88);
}
.content-shell {
height: calc(100vh - 146px);
min-height: 720px;
padding: 0;
gap: 16px;
}
.panel,
.live-card,
.progress-card,
details {
border-color: rgba(219, 226, 250, 0.92);
border-radius: 14px;
background: rgba(255, 255, 255, 0.88);
box-shadow: var(--tw-shadow);
}
.menu-block,
.step,
.metric,
.segment,
.session-item,
.progress-card,
.upload-progress {
border-color: rgba(222, 229, 252, 0.95);
background: rgba(247, 249, 255, 0.9);
border-radius: 12px;
}
.pill,
.summary-mode.active,
.summary-source-btn.active {
background: #eef0ff;
color: var(--tw-primary);
border-color: rgba(98, 86, 234, 0.25);
}
.btn,
.btn.primary-wide,
.workspace-tab.active {
background: linear-gradient(135deg, var(--tw-primary), var(--tw-primary-2));
color: #fff;
}
.btn:hover,
.btn.primary-wide:hover {
background: linear-gradient(135deg, #5247dc, #675cff);
}
.btn.secondary {
background: #eef1ff;
color: var(--tw-primary);
}
input[type="text"],
input[type="number"],
input[type="file"],
select,
textarea {
border-color: #dfe5f6;
background: rgba(255, 255, 255, 0.96);
color: var(--tw-text);
}
input:focus,
select:focus,
textarea:focus {
border-color: rgba(98, 86, 234, 0.55);
box-shadow: 0 0 0 3px rgba(98, 86, 234, 0.12);
}
#workspaceSummaryAside.workspace-panel.active {
grid-template-rows: minmax(0, 1fr) auto;
height: 100%;
}
.summary-result-card {
padding: 22px 24px;
min-height: 0;
height: 100%;
}
.summary-result-shell {
border-radius: 12px;
border-color: #e0e7fb;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5);
}
.summary-thinking-panel {
max-height: 120px;
min-height: 76px;
background: linear-gradient(180deg, #f8faff, #f3f6ff);
}
.summary-main-body {
min-height: 0;
}
#summaryMarkdownPreview,
#summaryMarkdownEditor,
#summaryThinkingOutput,
.prompt-preview {
scrollbar-width: thin;
}
#summaryMarkdownPreview {
padding: 24px 28px;
background: #fff;
}
#summaryMarkdownEditor {
padding: 24px 28px;
background: #fff;
}
#workspaceSummaryAside .live-log-card {
padding: 14px 18px;
height: auto;
min-height: 0;
max-height: 220px;
}
#workspaceSummary .panel {
padding: 16px;
}
#workspaceSummary .section-head {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(224, 231, 251, 0.9);
}
#workspaceSummary .menu-stack {
gap: 12px;
}
#workspaceSummary .menu-block {
padding: 13px;
}
#workspaceSummary textarea {
min-height: 112px;
}
#workspaceSummary #summaryTranscript {
min-height: 180px;
}
.summary-result-card .section-head {
margin-bottom: 10px;
flex-shrink: 0;
}
.summary-result-card .live-output-meta {
flex-shrink: 0;
}
@media (max-width: 1080px) {
.topbar {
padding: 18px;
}
.layout {
grid-template-columns: 1fr;
min-height: 0;
}
.sidebar-shell {
position: static;
max-height: none;
}
.content-shell {
height: auto;
min-height: 0;
padding: 14px;
}
}
</style>
</head>
<body>
<div class="page">
<header class="topbar">
<div>
<div class="eyebrow">Qwen3-ASR Meeting Console</div>
<h1>会议语音识别测试</h1>
<p>左边配置,右边直接看会议气泡流和原始日志。</p>
</div>
<div class="actions">
<span id="healthStatus" class="status">服务未检查</span>
<button class="btn secondary" id="checkHealthBtn">检查服务</button>
</div>
</header>
<div class="layout">
<main class="stack sidebar-shell">
<div class="workspace-tabs">
<button class="workspace-tab active" data-workspace="live">实时会议</button>
<button class="workspace-tab" data-workspace="offline">离线会议</button>
<button class="workspace-tab" data-workspace="summary">会议总结</button>
</div>
<div id="workspaceLive" class="workspace-panel active">
<section class="panel">
<div class="section-head">
<div>
<h2>识别配置</h2>
<p>保持像 demo 一样的单配置面板,常用参数直接可改。</p>
</div>
<span class="pill">实时模式</span>
</div>
<div class="menu-stack">
<div class="menu-block">
<h3>基础设置</h3>
<div class="mini-grid">
<div class="field">
<label for="liveWsPath">WebSocket 路径</label>
<input id="liveWsPath" type="text" value="/ws/v1/asr">
</div>
<div class="field">
<label for="liveSessionId">会话 ID(session_id)</label>
<input id="liveSessionId" type="text" placeholder="留空则自动生成,可用于断线重连测试">
</div>
<div class="field">
<label for="liveLanguage">识别语言(language)</label>
<input id="liveLanguage" type="text" placeholder="留空自动识别,例如 zh / en">
</div>
<label class="switch">数字归一化 <input id="liveEnableItn" type="checkbox" checked></label>
<label class="switch">复用上次会话 <input id="liveReuseSessionId" type="checkbox" checked></label>
</div>
<div class="menu-inline">
<span id="liveProtocolBadge" class="pill">Qwen 协议</span>
<span id="liveProtocolNote" class="menu-subtitle">实时参数在左侧,结果和日志固定在右侧。</span>
</div>
</div>
<div class="menu-block">
<h3>识别选项</h3>
<div id="liveQwenParams">
<div class="mini-grid">
<div class="field">
<label for="liveSilenceDurationMs">静音收段时长(silence_duration_ms)</label>
<input id="liveSilenceDurationMs" type="number" min="200" step="100" value="800">
</div>
<div class="field">
<label for="liveMinPartialSec">最短 partial 时长(min_partial_sec)</label>
<input id="liveMinPartialSec" type="number" min="0.25" step="0.05" value="0.3">
</div>
<div class="field">
<label for="livePreRollMs">句首预读时长(pre_roll_ms)</label>
<input id="livePreRollMs" type="number" min="0" step="20" value="240">
</div>
<div class="field">
<label for="liveMaxSentenceCount">单段最多句数(max_sentence_count)</label>
<input id="liveMaxSentenceCount" type="number" min="1" step="1" value="8">
</div>
<label class="switch">说话人分离 <input id="liveEnableSpeaker" type="checkbox" checked></label>
<label class="switch">匹配声纹库 <input id="liveMatchSpeakerRegistry" type="checkbox" checked></label>
<div class="field">
<label for="liveSpeakerThreshold">声纹匹配阈值(speaker_threshold)</label>
<input id="liveSpeakerThreshold" type="number" min="0" max="1" step="0.01" value="0.6">
</div>
</div>
<p class="compact-note">这个 demo 的实时气泡是“整段覆盖式 partial”,不是逐字追加,所以看起来会一截一截长出来。默认与后端保持一致:`silence_duration_ms=800`。</p>
</div>
</div>
<details class="compact-details">
<summary>高级参数</summary>
<div class="details-body">
<div class="field">
<label for="liveContext">上下文提示(context)</label>
<textarea id="liveContext" placeholder="可填会议主题、术语、项目名,帮助实时测试更贴近实际场景。"></textarea>
</div>
<div class="mini-grid" style="margin-top: 10px;">
<div class="field">
<label for="liveUnfixedTokenNum">回滚 token 数(unfixed_token_num)</label>
<input id="liveUnfixedTokenNum" type="number" min="1" step="1" value="3">
</div>
<div class="field">
<label for="livePartialHoldbackChars">partial 保留尾字(partial_holdback_chars)</label>
<input id="livePartialHoldbackChars" type="number" min="0" step="1" value="2">
</div>
<div class="field">
<label for="liveForceStableSegmentSec">超时软提交秒数(force_stable_segment_sec)</label>
<input id="liveForceStableSegmentSec" type="number" min="0" step="0.5" value="6">
</div>
<div class="field">
<label for="liveForceStableMinChars">软提交最少字数(force_stable_min_chars)</label>
<input id="liveForceStableMinChars" type="number" min="8" step="1" value="24">
</div>
<div class="field">
<label for="liveMaxSegmentSec">单段最长秒数(max_segment_sec)</label>
<input id="liveMaxSegmentSec" type="number" min="3" step="1" value="9">
</div>
</div>
<div class="mini-grid" style="margin-top: 10px;">
<label class="switch">原生流式 partial <input id="liveEnableNativePartialStream" type="checkbox" checked></label>
<label class="switch">实时 VAD 拆段 <input id="liveEnableRealtimeVadSplit" type="checkbox" checked></label>
<label class="switch">长段重转写 <input id="liveEnableRealtimeLongform" type="checkbox"></label>
</div>
</div>
</details>
<div class="menu-block">
<div class="menu-inline">
<h3>开始识别</h3>
<span id="liveMicState" class="pill">麦克风空闲</span>
</div>
<div class="compact-actions">
<button class="btn" id="liveStartBtn">开始实时会议</button>
<button class="btn danger" id="liveStopBtn" disabled>停止实时会议</button>
<button class="btn secondary" id="liveSimulateDisconnectBtn" disabled>模拟断线</button>
<button class="btn secondary" id="liveClearBtn">清空实时结果</button>
</div>
<div class="menu-subtitle">开始后右侧会持续刷新中间结果、最终分段和原始日志。要测断线恢复,保持同一个 `session_id` 再次点击“开始实时会议”即可。</div>
</div>
</div>
</section>
</div>
<div id="workspaceOffline" class="workspace-panel">
<section class="panel">
<div class="section-head">
<div>
<h2>开始识别</h2>
<p>普通测试只需要完成下面三步。</p>
</div>
<span id="uploadState" class="pill">等待音频</span>
</div>
<div class="steps">
<div class="step">
<div class="step-no">1</div>
<div>
<h3>选择音频来源</h3>
<p>上传测试文件,或填写可访问的音频/视频 URL、服务端本地路径。</p>
<div class="source-tabs">
<button class="tab-btn active" data-source="upload">上传文件</button>
<button class="tab-btn" data-source="url">URL / 本地路径</button>
</div>
<div id="sourceUpload" class="source-panel active">
<div class="field">
<label for="audioFile">选择音频或视频文件</label>
<input id="audioFile" type="file" accept="audio/*,video/*">
</div>
<div class="actions">
<button class="btn secondary" id="uploadBtn">上传文件</button>
<span id="uploadHint" class="hint">也可以直接点“开始识别”,系统会自动上传。</span>
</div>
<div class="actions" style="margin-top: 10px;">
<button class="btn secondary" id="meetingRecordStartBtn">开始录音</button>
<button class="btn secondary" id="meetingRecordStopBtn" disabled>停止录音</button>
<span id="meetingRecordStatus" class="hint">可用麦克风录一段会议测试音频。</span>
</div>
<audio id="meetingRecordPreview" controls hidden style="width: 100%; margin-top: 10px;"></audio>
<div class="upload-progress" id="meetingUploadProgress" hidden>
<div class="progress-track">
<div class="progress-fill" id="meetingUploadBar"></div>
</div>
<div class="hint" id="meetingUploadText">等待上传</div>
</div>
</div>
<div id="sourceUrl" class="source-panel">
<div class="field">
<label for="fileUrl">audio_address</label>
<input id="fileUrl" type="text" placeholder="https://example.com/audio.wav 或 /data/audio.wav">
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-no">2</div>
<div>
<h3>确认识别选项</h3>
<div class="grid-2" style="margin-top: 12px;">
<label class="switch">说话人分离 <input id="enableSpeaker" type="checkbox" checked></label>
<label class="switch">匹配声纹库 <input id="matchSpeakerRegistry" type="checkbox" checked></label>
<label class="switch">文字去重 <input id="enableTextCleanup" type="checkbox" checked></label>
<label class="switch">字词时间戳 <input id="wordTimestamps" type="checkbox"></label>
</div>
<details style="margin-top: 12px;">
<summary>高级参数</summary>
<div class="details-body">
<div class="grid-3">
<div class="field">
<label for="speakerThreshold">声纹阈值</label>
<input id="speakerThreshold" type="number" min="0" max="1" step="0.01" value="0.6">
</div>
<div class="field">
<label for="sampleRate">采样率</label>
<input id="sampleRate" type="number" value="16000">
</div>
<div class="field">
<label for="hotwords">热词</label>
<textarea id="hotwords" placeholder="一行一个,格式参考老项目:&#10;通义千问 2.0&#10;ModelScope 1.5"></textarea>
</div>
</div>
<div class="grid-2">
<div class="field">
<label for="fileId">上传测试 file_id</label>
<input id="fileId" type="text" placeholder="仅用于上传调试,会议接口不发送">
</div>
<div class="field">
<label for="taskId">task_id</label>
<input id="taskId" class="mono" type="text" placeholder="创建任务后自动填入">
</div>
</div>
</div>
</details>
</div>
</div>
<div class="step">
<div class="step-no">3</div>
<div>
<h3>启动任务</h3>
<p>创建任务后会自动轮询进度,完成后展示分段结果。</p>
<div class="actions" style="margin-top: 12px;">
<button class="btn primary-wide" id="createTaskBtn">开始识别</button>
<button class="btn secondary" id="pollTaskBtn">查询当前任务</button>
<button class="btn secondary" id="stopPollBtn">停止轮询</button>
</div>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="section-head">
<div>
<h2>声纹管理</h2>
<p>声纹样本和会议录音分开录,避免误用。</p>
</div>
<button class="btn secondary" id="listSpeakersBtn">刷新列表</button>
</div>
<div class="grid-2">
<div class="field">
<label for="speakerName">姓名</label>
<input id="speakerName" type="text" placeholder="张三">
</div>
<div class="field">
<label for="speakerUserId">user_id</label>
<input id="speakerUserId" type="text" placeholder="u001">
</div>
</div>
<div class="step" style="margin-top: 12px;">
<div class="step-no">声</div>
<div>
<h3>录制 12 秒声纹样本</h3>
<p>请让要注册或识别的人单独说话,倒计时结束后会自动停止并上传。</p>
<div class="actions" style="margin-top: 12px;">
<button class="btn secondary" id="speakerRecordStartBtn">开始录制声纹</button>
<button class="btn danger" id="speakerRecordStopBtn" disabled>停止录制</button>
<span id="speakerRecordCountdown" class="pill">12 秒</span>
</div>
<div id="speakerRecordStatus" class="hint" style="margin-top: 8px;">还没有声纹样本。</div>
<div class="upload-progress" id="speakerUploadProgress" hidden style="margin-top: 10px;">
<div class="progress-track">
<div class="progress-fill" id="speakerUploadBar"></div>
</div>
<div class="hint" id="speakerUploadText">等待上传</div>
</div>
<audio id="speakerRecordPreview" controls hidden></audio>
<input id="speakerFileId" type="text" hidden>
</div>
</div>
<div class="actions" style="margin-top: 12px;">
<button class="btn" id="registerSpeakerBtn">注册声纹</button>
<button class="btn secondary" id="identifySpeakerBtn">识别声纹</button>
</div>
<div id="speakerList" style="margin-top: 14px;"></div>
</section>
<details>
<summary>连接与原始响应</summary>
<div class="details-body">
<div class="grid-2">
<div class="field">
<label for="apiBase">接口地址</label>
<input id="apiBase" type="text" value="">
</div>
<div class="field">
<label for="apiToken">X-NLS-Token</label>
<input id="apiToken" type="text" placeholder="未配置 API_KEY 可留空">
</div>
</div>
<div class="row">
<h3>原始 JSON</h3>
<button class="btn secondary" id="copyJsonBtn">复制 JSON</button>
</div>
<textarea id="rawOutput" readonly>{}</textarea>
</div>
</details>
</div>
<div id="workspaceSummary" class="workspace-panel">
<section class="panel">
<div class="section-head">
<div>
<h2>会议总结测试</h2>
<p>用于在线会议和离线会议结束后,对 ASR 内容做三级总结验证。</p>
</div>
<span id="summaryReadyBadge" class="pill">真实模型</span>
</div>
<div class="menu-stack">
<div class="menu-block">
<h3>总结模型</h3>
<div class="mini-grid">
<div class="field">
<label for="summaryModelEndpoint">总结模型地址</label>
<input id="summaryModelEndpoint" type="text" value="http://10.100.53.199:9527/v1/chat/completions">
</div>
<div class="field">
<label for="summaryModelName">模型名称</label>
<input id="summaryModelName" type="text" value="gemma-4-26B">
</div>
<div class="field">
<label for="summaryApiKey">api-key</label>
<input id="summaryApiKey" type="text" value="unis123">
</div>
<label class="switch">流式输出 <input id="summaryStream" type="checkbox" checked></label>
</div>
<div class="compact-note">点击“调用模型总结”会直接请求 OpenAI-compatible 接口;请求 JSON 会同步显示在右侧。</div>
</div>
<div class="menu-block">
<h3>会议元数据</h3>
<div class="mini-grid">
<div class="field">
<label for="summaryMeetingTitle">会议标题</label>
<input id="summaryMeetingTitle" type="text" value="AI会议产品技术方案讨论会">
</div>
<div class="field">
<label for="summaryMeetingDate">会议日期</label>
<input id="summaryMeetingDate" type="text" placeholder="例如 2026-06-08">
</div>
<div class="field">
<label for="summaryMeetingType">会议来源</label>
<select id="summaryMeetingType">
<option value="在线会议">在线会议</option>
<option value="离线会议">离线会议</option>
</select>
</div>
<div class="field">
<label for="summaryOutputLanguage">输出语言</label>
<select id="summaryOutputLanguage">
<option value="中文">中文</option>
<option value="English">English</option>
</select>
</div>
</div>
<div class="live-output-meta" style="margin-top: 10px;">
<span class="meta-chip">会议时长 <strong id="summaryDurationText">等待完整会议内容</strong></span>
<span class="meta-chip">参会人 / 说话人 <strong id="summaryAttendeesText">等待完整会议内容</strong></span>
</div>
<input id="summaryDuration" type="text" hidden>
<input id="summaryAttendees" type="text" hidden>
<div class="field">
<label for="summaryFocus">特别关注点</label>
<textarea id="summaryFocus" placeholder="例如:重点关注决策项、行动项、风险、还原与概括的关系。"></textarea>
</div>
</div>
<div class="menu-block">
<h3>总结级别</h3>
<div class="summary-mode-grid">
<button class="summary-mode active" data-summary-level="standard">
<strong>标准</strong>
<span>10-20%,结论优先,适合团队负责人阅读。</span>
</button>
<button class="summary-mode" data-summary-level="detailed">
<strong>详细</strong>
<span>30-50%,保留论证链条和争议过程。</span>
</button>
<button class="summary-mode" data-summary-level="concise">
<strong>简洁</strong>
<span>3-5%,只保留核心结论和 TOP 行动项。</span>
</button>
</div>
</div>
<div class="menu-block">
<h3>Markdown 总结模板</h3>
<div class="mini-grid">
<div class="field">
<label for="summaryTemplateSelect">模板列表</label>
<select id="summaryTemplateSelect"></select>
</div>
<div class="field">
<label for="summaryTemplateName">模板名称</label>
<input id="summaryTemplateName" type="text" placeholder="例如 周会纪要 / 项目复盘">
</div>
</div>
<div class="field">
<label for="summaryTemplatePrompt">Markdown 模板内容</label>
<textarea id="summaryTemplatePrompt" placeholder="用 Markdown 写输出结构,例如:&#10;## 会议概述&#10;## 关键议题&#10;## 行动项"></textarea>
</div>
<div class="compact-actions">
<button class="btn secondary" id="summaryNewTemplateBtn">新建模板</button>
<button class="btn" id="summarySaveTemplateBtn">保存模板</button>
<button class="btn danger" id="summaryDeleteTemplateBtn">删除模板</button>
</div>
<div class="menu-subtitle" id="summaryTemplateHint">选择模板后会作为严格 Markdown 输出结构传给总结模型。</div>
</div>
<div class="menu-block">
<h3>完整会议内容来源</h3>
<div class="summary-source-grid">
<button class="summary-source-btn active" data-summary-source="manual">手动粘贴</button>
<button class="summary-source-btn" data-summary-source="live">实时结果</button>
<button class="summary-source-btn" data-summary-source="offline">离线结果</button>
</div>
<div class="field">
<label for="summaryTranscript">完整会议 ASR 内容</label>
<textarea id="summaryTranscript" placeholder="这里应放完整会议 ASR 内容。实时/离线会议结束后会自动导入,也可以手动粘贴完整会议文本。"></textarea>
</div>
<div class="compact-actions">
<button class="btn secondary" id="summaryBuildPromptBtn">生成提示词</button>
<button class="btn" id="summaryRunBtn">调用模型总结</button>
<button class="btn secondary" id="summaryCopyPayloadBtn">复制请求 JSON</button>
</div>
<div class="menu-subtitle" id="summarySourceHint">手动粘贴模式,可直接填入任意 ASR 转写文本。</div>
</div>
</div>
</section>
</div>
</main>
<aside class="stack content-shell">
<div id="workspaceLiveAside" class="workspace-panel active">
<section class="panel live-result-card">
<div class="section-head live-result-head">
<h2>实时输出</h2>
<span id="liveStatus" class="status">未连接</span>
</div>
<div class="live-output-meta">
<span class="meta-chip">已确认分段 <strong id="liveSegmentCount">0</strong></span>
<span class="meta-chip">累计分块 <strong id="liveChunkCount">0</strong></span>
<span class="meta-chip">会话时长 <strong id="liveDuration">00:00</strong></span>
<span class="meta-chip">麦克风状态 <strong id="liveMicStateSummary">空闲</strong></span>
</div>
<div class="live-transcript-shell">
<div id="liveTranscript" class="live-transcript">
<div class="empty-state">点击“开始实时会议”后,这里会像 demo 一样滚动显示气泡和实时变化。</div>
</div>
</div>
</section>
<section class="panel live-log-card">
<div class="section-head live-log-head">
<h2>原始日志</h2>
<button class="btn secondary" id="copyLiveJsonBtn">复制实时 JSON</button>
</div>
<textarea id="liveRawOutput" readonly>[]</textarea>
</section>
<section class="panel" hidden>
<div class="section-head">
<h2>会议列表</h2>
<button class="btn secondary" id="liveClearSessionsBtn">清空列表</button>
</div>
<p style="margin-bottom: 12px;">每次实时会议结束后都会沉淀一条测试记录,方便对比不同参数下的识别表现。</p>
<div id="liveSessionList" class="session-list">
<div class="empty-state">还没有实时会议记录。</div>
</div>
</section>
</div>
<div id="workspaceOfflineAside" class="workspace-panel">
<section class="panel">
<div class="section-head">
<h2>任务状态</h2>
<span id="taskStatus" class="status">未创建任务</span>
</div>
<div class="progress-card" id="taskProgressCard">
<div class="progress-head">
<span class="progress-title">识别处理进度</span>
<span class="progress-percent" id="taskProgressPercent">0%</span>
</div>
<div class="progress-track">
<div class="progress-fill" id="taskProgressBar"></div>
</div>
<div class="progress-note" id="taskProgressNote">选择音频后点击开始识别。</div>
<div class="progress-meta">
<span id="taskElapsed">耗时 00:00</span>
<span id="taskEta">预计剩余 --</span>
</div>
</div>
</section>
<section class="panel">
<div class="section-head">
<h2>识别结果</h2>
<span id="resultBadge" class="status">暂无结果</span>
</div>
<div class="summary-grid">
<div class="metric"><strong id="metricDuration">--</strong><span>音频时长</span></div>
<div class="metric"><strong id="metricSegments">--</strong><span>分段数量</span></div>
<div class="metric"><strong id="metricSpeakers">--</strong><span>说话人数</span></div>
</div>
<div id="summary" class="hint">结果会显示在这里。</div>
<div id="segments" class="result" style="margin-top: 12px;">
<div class="empty-state">还没有识别结果</div>
</div>
</section>
</div>
<div id="workspaceSummaryAside" class="workspace-panel">
<section class="panel summary-result-card">
<div class="section-head">
<div>
<h2>总结结果</h2>
<p>真实模型输出支持 Markdown 预览和编辑,内容区域可独立滚动。</p>
</div>
<span id="summaryStatus" class="status">等待内容</span>
</div>
<div class="live-output-meta">
<span class="meta-chip">总结级别 <strong id="summaryLevelMeta">标准</strong></span>
<span class="meta-chip">ASR 字数 <strong id="summaryCharsMeta">0</strong></span>
<span class="meta-chip">总结耗时 <strong id="summaryElapsedMeta">00:00</strong></span>
<span class="meta-chip">首字耗时 <strong id="summaryFirstTokenMeta">--</strong></span>
<span class="meta-chip">输出字数 <strong id="summaryOutputCharsMeta">0</strong></span>
<span class="meta-chip">模型地址 <strong id="summaryEndpointMeta">未填写</strong></span>
</div>
<div id="summaryOutput" class="summary-output">
<div class="summary-result-shell" id="summaryResultShell">
<div class="summary-result-toolbar">
<div class="summary-view-tabs">
<button class="summary-view-tab active" data-summary-view="preview">Markdown 预览</button>
<button class="summary-view-tab" data-summary-view="edit">编辑</button>
</div>
<div class="actions">
<button class="btn secondary" id="summaryCopyMarkdownBtn">复制 Markdown</button>
<button class="btn secondary" id="summaryCopyPromptBtn">复制提示词</button>
</div>
</div>
<div class="summary-result-body">
<div class="summary-thinking-panel">
<div class="summary-thinking-head">
<span>思考过程</span>
<span id="summaryThinkingBadge">等待流式输出</span>
</div>
<div id="summaryThinkingOutput">模型返回 reasoning_content / reasoning / &lt;think&gt; 时会显示在这里。</div>
</div>
<div class="summary-main-body">
<div id="summaryMarkdownPreview" class="summary-doc">
<div class="empty-state">选择来源或粘贴 ASR 文本后,点击“调用模型总结”。</div>
</div>
<textarea id="summaryMarkdownEditor" placeholder="模型生成的 Markdown 会显示在这里,也可以手动编辑。"></textarea>
</div>
</div>
</div>
</div>
</section>
<details class="panel live-log-card summary-request-preview">
<summary>请求预览</summary>
<div class="details-body">
<div class="row">
<h3>Prompt / Payload</h3>
</div>
<textarea id="summaryPromptPreview" class="prompt-preview" readonly>{}</textarea>
</div>
</details>
</div>
</aside>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const state = {
pollTimer: null,
elapsedTimer: null,
lastJson: null,
taskStartedAt: null,
taskEndedAt: null,
activeSource: "upload",
activeWorkspace: "live",
summaryLevel: "standard",
summarySource: "manual",
summaryPayload: null,
summaryMarkdown: "",
summaryThinking: "",
summaryThinkingOpen: false,
summaryRunning: false,
summaryStartedAt: null,
summaryEndedAt: null,
summaryFirstTokenAt: null,
summaryTimer: null,
summaryFinishReason: "",
summaryTemplates: [],
activeSummaryTemplateId: "",
liveSocket: null,
liveAudioContext: null,
liveMediaStream: null,
liveProcessor: null,
livePcmPending: new Int16Array(0),
liveTargetSampleRate: 16000,
liveStartedAt: null,
liveTaskId: "",
liveSessionId: "",
liveTranscript: [],
liveMessages: [],
liveChunkCount: 0,
liveSpeakerOrderMap: {},
liveSpeakerOrderCounter: 0,
liveSessions: [],
liveSessionSaved: false,
liveLastSummaryHydratedAt: null,
meetingRecorder: null,
meetingRecordChunks: [],
};
$("apiBase").value = window.location.origin;
const SUMMARY_TEMPLATE_STORAGE_KEY = "qwen-asr-summary-templates";
const defaultSummaryTemplates = [
{
id: "standard-meeting",
name: "标准会议纪要",
prompt: "## 会议概述\n\n## 关键议题\n\n## 决策清单\n| 决策 | 责任方 | 原文依据 |\n|---|---|---|\n\n## 行动项汇总\n| 任务 | 负责人 | 截止日期 | 优先级 | 原文依据 |\n|---|---|---|---|---|\n\n## 待确认事项\n| 事项 | 缺失信息 | 原文依据 |\n|---|---|---|\n\n## 风险提示\n| 风险 | 影响 | 应对动作 | 原文依据 |\n|---|---|---|---|\n\n> 行动项缺失负责人或截止日期时标注【待确认】;ASR 未提及的信息标注【未提及】。",
},
{
id: "product-review",
name: "产品评审",
prompt: "## 产品目标\n\n## 用户痛点\n\n## 方案取舍\n| 方案 | 结论 | 原文依据 |\n|---|---|---|\n\n## 关键争议\n\n## 优先级与排期\n| 项目 | 优先级 | 排期 | 原文依据 |\n|---|---|---|---|\n\n## 风险与依赖\n\n## 下一步行动\n| 动作 | 负责人 | 截止日期 | 原文依据 |\n|---|---|---|---|",
},
{
id: "executive-brief",
name: "高管简报",
prompt: "## 核心结论\n\n## 关键决策\n| 决策 | 影响 | 原文依据 |\n|---|---|---|\n\n## TOP 行动项\n| 动作 | 负责人 | 截止日期 | 原文依据 |\n|---|---|---|---|\n\n## 风险\n\n## 需要拍板的问题\n\n> 输出必须简洁,弱化讨论过程;没有原文依据的信息标注【未提及】。",
},
];
function apiBase() {
return $("apiBase").value.replace(/\/+$/, "");
}
function headers(json = true) {
const result = {};
const token = $("apiToken").value.trim();
if (token) result["X-NLS-Token"] = token;
if (json) result["Content-Type"] = "application/json";
return result;
}
function setStatus(el, text, type = "") {
el.textContent = text;
el.className = `status ${type}`.trim();
}
function setUploadState(text, type = "") {
const el = $("uploadState");
el.textContent = text;
el.style.background = type === "ok" ? "#ecfdf3" : "var(--brand-soft)";
el.style.color = type === "ok" ? "var(--ok)" : "var(--brand-strong)";
}
function showRaw(payload) {
state.lastJson = payload;
$("rawOutput").value = JSON.stringify(payload, null, 2);
}
function showLiveRaw(payload) {
state.liveMessages.push(payload);
if (state.liveMessages.length > 80) {
state.liveMessages = state.liveMessages.slice(-80);
}
$("liveRawOutput").value = JSON.stringify(state.liveMessages, null, 2);
}
function formatDuration(totalSeconds) {
const safeSeconds = Math.max(0, Math.floor(Number(totalSeconds) || 0));
const hours = Math.floor(safeSeconds / 3600);
const minutes = Math.floor((safeSeconds % 3600) / 60);
const seconds = safeSeconds % 60;
const mm = String(minutes).padStart(2, "0");
const ss = String(seconds).padStart(2, "0");
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
}
function formatBytes(bytes) {
const value = Number(bytes) || 0;
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function setLocalProgress(stage, percent, message) {
const safePercent = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
setStatus($("taskStatus"), `${stage} ${safePercent}%`, "warn");
$("taskProgressPercent").textContent = `${safePercent}%`;
$("taskProgressBar").style.width = `${safePercent}%`;
$("taskProgressNote").textContent = message;
$("taskEta").textContent = "预计剩余 --";
}
function updateElapsedDisplay() {
if (!state.taskStartedAt) {
$("taskElapsed").textContent = "耗时 00:00";
return;
}
const endTime = state.taskEndedAt || Date.now();
$("taskElapsed").textContent = `耗时 ${formatDuration((endTime - state.taskStartedAt) / 1000)}`;
}
function startElapsedTimer() {
stopElapsedTimer(false);
updateElapsedDisplay();
state.elapsedTimer = setInterval(updateElapsedDisplay, 1000);
}
function stopElapsedTimer(markEnded = true) {
if (markEnded && state.taskStartedAt && !state.taskEndedAt) {
state.taskEndedAt = Date.now();
}
if (state.elapsedTimer) clearInterval(state.elapsedTimer);
state.elapsedTimer = null;
updateElapsedDisplay();
}
function wsBase() {
const base = apiBase();
if (!base) throw new Error("请先填写接口地址。");
if (base.startsWith("https://")) return `wss://${base.slice("https://".length)}`;
if (base.startsWith("http://")) return `ws://${base.slice("http://".length)}`;
if (base.startsWith("ws://") || base.startsWith("wss://")) return base;
return `${window.location.protocol === "https:" ? "wss" : "ws"}://${base.replace(/^\/+/, "")}`;
}
async function requestJson(path, options = {}) {
const response = await fetch(`${apiBase()}${path}`, options);
const text = await response.text();
let payload;
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = { text };
}
if (!response.ok) {
const message = payload?.detail?.message || payload?.detail || payload?.message || response.statusText;
throw new Error(typeof message === "string" ? message : JSON.stringify(message));
}
return payload;
}
function setUploadProgress(kind, percent, text, visible = true) {
const safePercent = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
const prefix = kind === "speaker" ? "speakerUpload" : "meetingUpload";
const progress = $(`${prefix}Progress`);
const bar = $(`${prefix}Bar`);
const label = $(`${prefix}Text`);
progress.hidden = !visible;
bar.style.width = `${safePercent}%`;
label.textContent = text || `上传中 ${safePercent}%`;
}
function uploadFormData(path, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${apiBase()}${path}`);
const token = $("apiToken").value.trim();
if (token) xhr.setRequestHeader("X-NLS-Token", token);
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) {
onProgress?.(0, "正在上传...");
return;
}
const percent = Math.round((event.loaded / event.total) * 100);
onProgress?.(percent, `上传中 ${percent}%`);
};
xhr.onload = () => {
let payload = {};
try {
payload = xhr.responseText ? JSON.parse(xhr.responseText) : {};
} catch {
payload = { text: xhr.responseText };
}
if (xhr.status >= 200 && xhr.status < 300) {
onProgress?.(100, "上传完成");
resolve(payload);
return;
}
const message = payload?.detail?.message || payload?.detail || payload?.message || xhr.statusText;
reject(new Error(typeof message === "string" ? message : JSON.stringify(message)));
};
xhr.onerror = () => reject(new Error("上传失败,请检查服务是否可访问。"));
xhr.onabort = () => reject(new Error("上传已取消。"));
xhr.send(formData);
});
}
function setSource(source) {
state.activeSource = source;
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.source === source);
});
$("sourceUpload").classList.toggle("active", source === "upload");
$("sourceUrl").classList.toggle("active", source === "url");
}
function setWorkspace(workspace) {
state.activeWorkspace = workspace;
document.querySelectorAll(".workspace-tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.workspace === workspace);
});
$("workspaceLive").classList.toggle("active", workspace === "live");
$("workspaceOffline").classList.toggle("active", workspace === "offline");
$("workspaceSummary").classList.toggle("active", workspace === "summary");
$("workspaceLiveAside").classList.toggle("active", workspace === "live");
$("workspaceOfflineAside").classList.toggle("active", workspace === "offline");
$("workspaceSummaryAside").classList.toggle("active", workspace === "summary");
}
const summaryLevelConfig = {
detailed: {
label: "详细",
ratio: "原文的30-50%",
instruction: "详细级总结:覆盖每个重要议题,保留观点、论据、交锋过程、情绪倾向、关键原文引用,并完整列出决策、行动项、待确认事项和风险。",
},
standard: {
label: "标准",
ratio: "原文的10-20%",
instruction: "标准级总结:只保留重要议题,结论优先,合并相似观点,突出决策与可执行行动项,兼顾信息密度与可读性。",
},
concise: {
label: "简洁",
ratio: "原文的3-5%",
instruction: "简洁级总结:只保留核心结论、关键决策、TOP 5 行动项和最重要风险,删除论据和讨论过程,适合快速浏览。",
},
};
const meetingSummarySystemPrompt = `你是一位“证据约束型”会议纪要抽取助手。你的任务不是创作文章,而是从 ASR 原文中抽取可核验信息,并严格填入用户提供的 Markdown 模板。
稳定性硬规则:
1. 只使用 ASR 原文和会议元数据中的信息,不使用常识、背景知识或猜测补全。
2. 数字、时间、金额、比例、指标、版本号、人名、项目名、截止日期必须按原文保留;不要四舍五入、不要改写单位、不要合并成模糊表达。
3. 区分【事实】【观点】【结论】【决策】【行动项】【风险】【待确认】;没有明确表达为决策的内容,不得写成“已决策”。
4. 每条结论、决策、行动项、风险都必须能在 ASR 原文中找到依据;模板含“原文依据”字段时,填写不超过 30 字的原文短句或时间/说话人线索。
5. 负责人、截止日期、优先级、状态未明确出现时,必须填写【待确认】;ASR 完全未提及时填写【未提及】。
6. ASR 有冲突或前后口径不一致时,不要替用户判断真相,写为“存在冲突:A / B”,并标注【待确认】。
7. 可以清理口头语、重复词和明显 ASR 噪音,但不得删除关键数字、明确判断、责任信息和动作信息。
8. 严格遵循用户提供的 Markdown 模板:只能按模板中的标题、栏目、表格和字段组织输出;不得新增、删除、改名或重排模板中的一级/二级标题。
9. 最终只输出 Markdown 正文,不输出 JSON,不输出分析过程,不输出“根据会议内容”等模板外套话。`;
function setSummaryLevel(level) {
state.summaryLevel = level in summaryLevelConfig ? level : "standard";
document.querySelectorAll(".summary-mode").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.summaryLevel === state.summaryLevel);
});
$("summaryLevelMeta").textContent = summaryLevelConfig[state.summaryLevel].label;
buildSummaryPromptPreview();
}
function setSummarySource(source) {
state.summarySource = source;
document.querySelectorAll(".summary-source-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.summarySource === source);
});
if (source === "live") {
$("summaryTranscript").value = collectLiveTranscriptText();
$("summaryMeetingType").value = "在线会议";
$("summarySourceHint").textContent = "已从实时会议结束后的完整 ASR 内容导入。";
} else if (source === "offline") {
$("summaryTranscript").value = collectOfflineTranscriptText();
$("summaryMeetingType").value = "离线会议";
$("summarySourceHint").textContent = "已从离线识别完成后的完整 ASR 分段导入。";
} else {
deriveSummaryMetaFromTranscript(true);
$("summarySourceHint").textContent = "手动粘贴模式,请填入完整会议 ASR 内容。";
}
updateSummaryMeta();
buildSummaryPromptPreview();
}
function collectLiveTranscriptText() {
return state.liveTranscript
.filter((item) => !item.partial && item.text)
.map((item) => {
const speaker = item.speaker || item.label || "未知发言人";
return `${speaker}:${item.text}`;
})
.join("\n");
}
function collectOfflineTranscriptText() {
const segments = state.lastJson?.data?.result?.segments || state.lastJson?.result?.segments || [];
if (!Array.isArray(segments) || !segments.length) return "";
return segments.map((seg, index) => {
const speaker = seg.speaker_name || seg.speaker_id || `Segment ${index + 1}`;
const start = seg.start === undefined ? "" : `[${seg.start}s-${seg.end ?? ""}s] `;
return `${start}${speaker}:${seg.text || ""}`;
}).join("\n");
}
function uniqueTextItems(items) {
return [...new Set(
(items || [])
.map((item) => String(item || "").trim())
.filter(Boolean),
)];
}
function deriveSummaryMetaFromTranscript(force = false) {
const transcript = $("summaryTranscript").value.trim();
if (!transcript) {
if (force) {
$("summaryDuration").value = "";
$("summaryAttendees").value = "";
}
return;
}
const speakerMatches = [...transcript.matchAll(/(?:^|\n|\]\s*)([^::\n]{1,24})[::]/g)]
.map((match) => match[1].replace(/^\[[^\]]+\]\s*/, "").trim())
.filter((name) => name && !/^\d+(?:\.\d+)?s?$/.test(name) && !/^Segment\s+\d+/i.test(name));
const speakers = uniqueTextItems(speakerMatches).slice(0, 12);
const timeMatches = [...transcript.matchAll(/\[(\d+(?:\.\d+)?)s?\s*[-–]\s*(\d+(?:\.\d+)?)s?\]/g)];
const maxEnd = timeMatches.reduce((max, match) => Math.max(max, Number(match[2]) || 0), 0);
if (force || !$("summaryAttendees").value.trim()) {
$("summaryAttendees").value = speakers.length ? speakers.join("、") : "";
}
if ((force || !$("summaryDuration").value.trim()) && maxEnd > 0) {
$("summaryDuration").value = formatDuration(maxEnd);
}
}
function hydrateSummaryFromMeeting(options) {
const {
source,
title,
date,
duration,
attendees,
transcript,
focus,
statusText,
} = options;
const cleanTranscript = (transcript || "").trim();
if (!cleanTranscript) return;
state.summarySource = source;
document.querySelectorAll(".summary-source-btn").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.summarySource === source);
});
$("summaryMeetingType").value = source === "live" ? "在线会议" : "离线会议";
$("summaryMeetingTitle").value = title || (source === "live" ? "实时会议总结" : "离线会议总结");
$("summaryMeetingDate").value = date || new Date().toLocaleDateString("zh-CN");
$("summaryDuration").value = duration || "";
$("summaryAttendees").value = attendees || "";
$("summaryTranscript").value = cleanTranscript;
if (focus) $("summaryFocus").value = focus;
$("summarySourceHint").textContent = statusText || "会议结束后已自动带入 ASR 内容和会议参数。";
setStatus($("summaryStatus"), "参数已自动带入", "ok");
buildSummaryPromptPreview();
}
function hydrateSummaryFromLiveMeeting(status = "completed", fullText = "") {
const transcript = fullText.trim() || collectLiveTranscriptText();
if (!transcript) return;
const startedAt = state.liveStartedAt ? new Date(state.liveStartedAt) : new Date();
const speakers = uniqueTextItems(
state.liveTranscript
.filter((item) => !item.partial)
.map((item) => item.speaker || "")
.filter((speaker) => speaker && !speaker.includes("未确认")),
);
const duration = state.liveStartedAt
? formatDuration((Date.now() - state.liveStartedAt) / 1000)
: $("liveDuration").textContent;
hydrateSummaryFromMeeting({
source: "live",
title: `实时会议 ${startedAt.toLocaleString("zh-CN", { hour12: false })}`,
date: startedAt.toLocaleDateString("zh-CN"),
duration,
attendees: speakers.length ? speakers.join("、") : `${$("liveSegmentCount").textContent || 0} 段,未确认说话人`,
transcript,
focus: "重点关注实时会议结束后的关键结论、行动项、待确认事项和风险。",
statusText: `实时会议已结束,已自动带入 ${transcript.length} 字 ASR 内容。`,
});
state.liveLastSummaryHydratedAt = Date.now();
}
function hydrateSummaryFromOfflineResult(result) {
const segments = result?.segments || [];
if (!segments.length) return;
const transcript = segments.map((seg, index) => {
const speaker = seg.speaker_name || seg.speaker_id || `Segment ${index + 1}`;
const start = seg.start === undefined ? "" : `[${seg.start}s-${seg.end ?? ""}s] `;
return `${start}${speaker}:${seg.text || ""}`;
}).join("\n");
const speakers = uniqueTextItems([
...(result.speakers || []),
...segments.map((seg) => seg.speaker_name || seg.speaker_id || ""),
]);
const durationSeconds = Number(result.audio_duration_seconds || 0);
hydrateSummaryFromMeeting({
source: "offline",
title: $("fileUrl").value.trim()
? `离线会议 ${$("fileUrl").value.trim().split("/").pop()}`
: "离线会议总结",
date: new Date().toLocaleDateString("zh-CN"),
duration: durationSeconds ? formatDuration(durationSeconds) : "",
attendees: speakers.length ? speakers.join("、") : `${result.speaker_count || 0} 人 / ${segments.length} 段`,
transcript,
focus: "重点关注离线会议识别后的完整纪要、决策清单、行动项和风险提示。",
statusText: `离线识别已完成,已自动带入 ${segments.length} 段 ASR 内容。`,
});
}
function updateSummaryMeta() {
if (state.summarySource === "manual") deriveSummaryMetaFromTranscript(false);
const transcript = $("summaryTranscript").value.trim();
$("summaryCharsMeta").textContent = String(transcript.length);
$("summaryEndpointMeta").textContent = $("summaryModelEndpoint").value.trim() || "未填写";
$("summaryReadyBadge").textContent = transcript ? "可总结" : "等待 ASR";
$("summaryDurationText").textContent = $("summaryDuration").value.trim() || "等待完整会议内容";
$("summaryAttendeesText").textContent = $("summaryAttendees").value.trim() || "等待完整会议内容";
}
function buildSummaryUserPrompt() {
const level = summaryLevelConfig[state.summaryLevel];
const title = $("summaryMeetingTitle").value.trim() || "未命名会议";
const date = $("summaryMeetingDate").value.trim() || "未填写";
const duration = $("summaryDuration").value.trim() || "未填写";
const attendees = $("summaryAttendees").value.trim() || "未知";
const language = $("summaryOutputLanguage").value;
const focus = $("summaryFocus").value.trim() || "无";
const templateName = $("summaryTemplateName").value.trim() || "未选择";
const templatePrompt = $("summaryTemplatePrompt").value.trim() || "无";
const transcript = $("summaryTranscript").value.trim() || "[请在此处填入 ASR 转写内容]";
return `# 【任务指令】
请对以下${$("summaryMeetingType").value} ASR 内容进行证据约束型会议总结,并严格填入指定 Markdown 模板。
# 【会议元数据】
- 会议标题:${title}
- 会议日期:${date}
- 会议时长:${duration}
- 参会人数 / 说话人:${attendees}
- 总结语言:${language}
- 总结级别:${level.label}
- 期望压缩比例:${level.ratio}
# 【级别专用指令】
${level.instruction}
注意:总结级别只影响信息详略,不得覆盖“关键数字、结论、动作、责任信息和明确判断必须保留”的规则。
# 【Markdown 总结模板】
- 模板名称:${templateName}
- 严格按以下 Markdown 模板组织输出,标题、栏目、表格和字段名称必须保持一致。
- 不要自行新增、改名或扩充模板外的一级/二级栏目;模板中没有的结构不要输出。
- 模板字段在 ASR 中没有对应信息时,填写【未提及】或【待确认】,不要编造。
- 如模板包含表格,只填写模板已有列;不要新增列,不要删除列。
- 如模板包含“原文依据”字段,填写能支撑该项的原文短句、时间戳或说话人线索,不超过 30 字。
\`\`\`markdown
${templatePrompt}
\`\`\`
# 【稳定抽取步骤】
1. 先在内部识别 ASR 中的关键数字、结论、明确判断、决策、动作、责任人、截止日期、风险和待确认事项。
2. 再把已识别信息映射到 Markdown 模板对应栏目。
3. 合并重复事项,但不要合并不同责任人、不同截止日期或不同数字口径。
4. 对无法确认的信息使用【待确认】;对 ASR 没有提到的信息使用【未提及】。
5. 最终只输出填充后的 Markdown 模板正文,不输出本步骤说明。
# 【会议转写内容】
\`\`\`
${transcript}
\`\`\`
# 【输出要求】
- 格式:Markdown
- 语言:${language}
- 特别关注点:${focus}
- 必须严格遵循 Markdown 总结模板,不要自行扩充模板外栏目
- 必须基于 ASR 原文,不要使用常识、经验或模型想象补充内容
- 必须保留会议中的关键数字、结论、动作、责任信息和明确判断
- 同一份 ASR 和同一模板下,应尽量保持同样的排序、栏目和措辞
- 必须输出 Markdown 正文,不要输出 JSON`;
}
function loadSummaryTemplates() {
try {
const raw = localStorage.getItem(SUMMARY_TEMPLATE_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
state.summaryTemplates = Array.isArray(parsed) && parsed.length ? parsed : [...defaultSummaryTemplates];
} catch {
state.summaryTemplates = [...defaultSummaryTemplates];
}
state.activeSummaryTemplateId = state.summaryTemplates[0]?.id || "";
}
function saveSummaryTemplates() {
localStorage.setItem(SUMMARY_TEMPLATE_STORAGE_KEY, JSON.stringify(state.summaryTemplates));
}
function renderSummaryTemplates() {
const select = $("summaryTemplateSelect");
select.innerHTML = state.summaryTemplates.map((template) => `
<option value="${escapeHtml(template.id)}">${escapeHtml(template.name)}</option>
`).join("");
if (state.activeSummaryTemplateId) select.value = state.activeSummaryTemplateId;
applySelectedSummaryTemplate(false);
}
function applySelectedSummaryTemplate(updatePrompt = true) {
const id = $("summaryTemplateSelect").value || state.activeSummaryTemplateId;
const template = state.summaryTemplates.find((item) => item.id === id) || state.summaryTemplates[0];
if (!template) return;
state.activeSummaryTemplateId = template.id;
$("summaryTemplateSelect").value = template.id;
$("summaryTemplateName").value = template.name;
$("summaryTemplatePrompt").value = template.prompt;
$("summaryTemplateHint").textContent = `已套用模板:${template.name}`;
if (updatePrompt) buildSummaryPromptPreview();
}
function createSummaryTemplate() {
const id = `template-${Date.now()}`;
const template = {
id,
name: "新的 Markdown 模板",
prompt: "## 会议概述\n\n## 关键议题\n\n## 决策清单\n\n## 行动项\n\n## 风险提示",
};
state.summaryTemplates.push(template);
state.activeSummaryTemplateId = id;
saveSummaryTemplates();
renderSummaryTemplates();
$("summaryTemplateName").focus();
buildSummaryPromptPreview();
}
function saveCurrentSummaryTemplate() {
const name = $("summaryTemplateName").value.trim();
if (!name) {
$("summaryTemplateHint").textContent = "请先填写模板名称。";
return;
}
const prompt = $("summaryTemplatePrompt").value.trim();
const id = $("summaryTemplateSelect").value || state.activeSummaryTemplateId || `template-${Date.now()}`;
const existing = state.summaryTemplates.find((item) => item.id === id);
if (existing) {
existing.name = name;
existing.prompt = prompt;
} else {
state.summaryTemplates.push({ id, name, prompt });
}
state.activeSummaryTemplateId = id;
saveSummaryTemplates();
renderSummaryTemplates();
$("summaryTemplateHint").textContent = `已保存模板:${name}`;
buildSummaryPromptPreview();
}
function deleteCurrentSummaryTemplate() {
const id = $("summaryTemplateSelect").value;
if (!id) return;
if (state.summaryTemplates.length <= 1) {
$("summaryTemplateHint").textContent = "至少保留一个模板。";
return;
}
state.summaryTemplates = state.summaryTemplates.filter((item) => item.id !== id);
state.activeSummaryTemplateId = state.summaryTemplates[0]?.id || "";
saveSummaryTemplates();
renderSummaryTemplates();
$("summaryTemplateHint").textContent = "模板已删除。";
buildSummaryPromptPreview();
}
function buildSummaryPayload() {
const apiKey = $("summaryApiKey").value.trim();
const authHeaders = apiKey
? {
Authorization: `Bearer ${apiKey}`,
"api-key": apiKey,
}
: {};
const payload = {
endpoint: $("summaryModelEndpoint").value.trim(),
model: $("summaryModelName").value.trim(),
headers: authHeaders,
body: {
model: $("summaryModelName").value.trim() || "{{summary_model_name}}",
messages: [
{ role: "system", content: meetingSummarySystemPrompt },
{ role: "user", content: buildSummaryUserPrompt() },
],
temperature: 0,
max_tokens: 8192,
stream: $("summaryStream").checked,
},
};
state.summaryPayload = payload;
return payload;
}
function buildSummaryPromptPreview() {
updateSummaryMeta();
const payload = buildSummaryPayload();
$("summaryPromptPreview").value = JSON.stringify(payload, null, 2);
return payload;
}
function updateSummaryElapsedDisplay() {
if (!state.summaryStartedAt) {
$("summaryElapsedMeta").textContent = "00:00";
$("summaryFirstTokenMeta").textContent = "--";
return;
}
const endTime = state.summaryEndedAt || Date.now();
$("summaryElapsedMeta").textContent = formatDuration((endTime - state.summaryStartedAt) / 1000);
$("summaryFirstTokenMeta").textContent = state.summaryFirstTokenAt
? formatDuration((state.summaryFirstTokenAt - state.summaryStartedAt) / 1000)
: "--";
}
function startSummaryTimer() {
stopSummaryTimer(false);
state.summaryStartedAt = Date.now();
state.summaryEndedAt = null;
state.summaryFirstTokenAt = null;
updateSummaryElapsedDisplay();
state.summaryTimer = setInterval(updateSummaryElapsedDisplay, 500);
}
function stopSummaryTimer(markEnded = true) {
if (markEnded && state.summaryStartedAt && !state.summaryEndedAt) {
state.summaryEndedAt = Date.now();
}
if (state.summaryTimer) clearInterval(state.summaryTimer);
state.summaryTimer = null;
updateSummaryElapsedDisplay();
}
function markSummaryFirstToken() {
if (state.summaryStartedAt && !state.summaryFirstTokenAt) {
state.summaryFirstTokenAt = Date.now();
updateSummaryElapsedDisplay();
}
}
function setSummaryMarkdown(markdown) {
state.summaryMarkdown = markdown || "";
$("summaryMarkdownEditor").value = state.summaryMarkdown;
$("summaryMarkdownPreview").innerHTML = state.summaryMarkdown
? renderMarkdown(state.summaryMarkdown)
: `<div class="empty-state">选择来源或粘贴 ASR 文本后,点击“调用模型总结”。</div>`;
$("summaryOutputCharsMeta").textContent = String(state.summaryMarkdown.trim().length);
}
function setSummaryThinking(text, badge = "") {
state.summaryThinking = text || "";
$("summaryThinkingOutput").textContent = state.summaryThinking || "模型返回 reasoning_content / reasoning / <think> 时会显示在这里。";
$("summaryThinkingBadge").textContent = badge || (state.summaryThinking ? "接收中" : "等待流式输出");
$("summaryThinkingOutput").scrollTop = $("summaryThinkingOutput").scrollHeight;
}
function splitThinkBlock(markdown) {
const raw = String(markdown || "");
const match = raw.match(/<think>([\s\S]*?)(?:<\/think>|$)/i);
if (!match) return { thinking: "", content: raw };
const thinking = match[1].trim();
const content = raw.replace(/<think>[\s\S]*?(?:<\/think>|$)/i, "").trim();
return { thinking, content };
}
function setSummaryView(view) {
const editing = view === "edit";
$("summaryResultShell").classList.toggle("editing", editing);
document.querySelectorAll(".summary-view-tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.summaryView === view);
});
if (!editing) {
setSummaryMarkdown($("summaryMarkdownEditor").value);
}
}
function renderMarkdown(markdown) {
const lines = String(markdown || "").split(/\r?\n/);
const html = [];
let listType = "";
let inTable = false;
const closeList = () => {
if (listType) {
html.push(`</${listType}>`);
listType = "";
}
};
const closeTable = () => {
if (inTable) {
html.push("</tbody></table>");
inTable = false;
}
};
const inline = (value) => escapeHtml(value)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`([^`]+)`/g, "<code>$1</code>");
lines.forEach((rawLine) => {
const line = rawLine.trim();
if (!line) {
closeList();
closeTable();
return;
}
if (/^\|(.+)\|$/.test(line)) {
closeList();
const cells = line.slice(1, -1).split("|").map((cell) => inline(cell.trim()));
if (cells.every((cell) => /^:?-{2,}:?$/.test(cell))) return;
if (!inTable) {
html.push("<table><tbody>");
inTable = true;
}
html.push(`<tr>${cells.map((cell) => `<td>${cell}</td>`).join("")}</tr>`);
return;
}
closeTable();
const heading = line.match(/^(#{1,4})\s+(.+)$/);
if (heading) {
closeList();
const level = Math.min(4, heading[1].length + 2);
html.push(`<h${level}>${inline(heading[2])}</h${level}>`);
return;
}
const unordered = line.match(/^[-*]\s+(.+)$/);
if (unordered) {
if (listType !== "ul") {
closeList();
html.push("<ul>");
listType = "ul";
}
html.push(`<li>${inline(unordered[1])}</li>`);
return;
}
const ordered = line.match(/^\d+[.、]\s+(.+)$/);
if (ordered) {
if (listType !== "ol") {
closeList();
html.push("<ol>");
listType = "ol";
}
html.push(`<li>${inline(ordered[1])}</li>`);
return;
}
closeList();
html.push(`<p>${inline(line)}</p>`);
});
closeList();
closeTable();
return html.join("");
}
function extractSummaryContent(payload) {
if (typeof payload === "string") return payload;
const choices = Array.isArray(payload?.choices) ? payload.choices : [];
const fromChoices = choices
.map((choice) => extractSummaryContentDelta({ choices: [choice] }))
.filter(Boolean)
.join("");
return fromChoices
|| extractContentValue(payload?.output_text)
|| extractContentValue(payload?.text)
|| extractContentValue(payload?.data?.text)
|| extractContentValue(payload?.content)
|| JSON.stringify(payload, null, 2);
}
function extractSummaryContentDelta(payload) {
if (!payload || typeof payload === "string") return "";
const choice = payload?.choices?.[0];
return extractContentValue(choice?.delta?.content)
|| extractContentValue(choice?.delta?.text)
|| extractContentValue(choice?.message?.content)
|| extractContentValue(choice?.message?.text)
|| extractContentValue(choice?.text)
|| extractContentValue(payload?.delta?.content)
|| extractContentValue(payload?.delta?.text)
|| extractContentValue(payload?.message?.content)
|| extractContentValue(payload?.message?.text)
|| extractContentValue(payload?.content)
|| extractContentValue(payload?.text)
|| extractContentValue(payload?.data?.content)
|| extractContentValue(payload?.data?.text)
|| "";
}
function extractContentValue(value) {
if (!value) return "";
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === "string") return item;
return item?.text || item?.content || item?.value || "";
}).join("");
}
if (typeof value === "object") {
return value.text || value.content || value.value || "";
}
return String(value);
}
function extractSummaryReasoning(payload) {
if (!payload || typeof payload === "string") return "";
const choice = payload?.choices?.[0];
return choice?.delta?.reasoning_content
|| choice?.delta?.reasoning
|| choice?.delta?.reasoningContent
|| choice?.message?.reasoning_content
|| choice?.message?.reasoning
|| choice?.message?.reasoningContent
|| payload?.delta?.reasoning_content
|| payload?.delta?.reasoning
|| payload?.message?.reasoning_content
|| payload?.message?.reasoning
|| payload?.reasoning_content
|| payload?.reasoning
|| "";
}
function extractSummaryFinishReason(payload) {
if (!payload || typeof payload === "string") return "";
const choice = payload?.choices?.[0];
return choice?.finish_reason || choice?.finishReason || payload?.finish_reason || payload?.finishReason || "";
}
function appendStreamingSummary(contentDelta, reasoningDelta) {
if (reasoningDelta) {
markSummaryFirstToken();
setSummaryThinking(`${state.summaryThinking}${reasoningDelta}`, "流式接收中");
}
if (contentDelta) {
markSummaryFirstToken();
let delta = String(contentDelta);
if (state.summaryThinkingOpen) {
const endIndex = delta.toLowerCase().indexOf("</think>");
if (endIndex === -1) {
setSummaryThinking(`${state.summaryThinking}${delta}`, "流式接收中");
return;
}
setSummaryThinking(`${state.summaryThinking}${delta.slice(0, endIndex)}`, "流式接收中");
state.summaryThinkingOpen = false;
delta = delta.slice(endIndex + "</think>".length);
}
const startIndex = delta.toLowerCase().indexOf("<think>");
if (startIndex !== -1) {
const before = delta.slice(0, startIndex);
if (before) setSummaryMarkdown(`${state.summaryMarkdown}${before}`);
const afterStart = delta.slice(startIndex + "<think>".length);
const endIndex = afterStart.toLowerCase().indexOf("</think>");
if (endIndex === -1) {
state.summaryThinkingOpen = true;
setSummaryThinking(`${state.summaryThinking}${afterStart}`, "流式接收中");
return;
}
setSummaryThinking(`${state.summaryThinking}${afterStart.slice(0, endIndex)}`, "流式接收中");
delta = afterStart.slice(endIndex + "</think>".length);
}
if (delta) setSummaryMarkdown(`${state.summaryMarkdown}${delta}`);
$("summaryMarkdownPreview").scrollTop = $("summaryMarkdownPreview").scrollHeight;
}
}
function handleSummaryStreamPayload(data) {
const cleanData = String(data || "").trim();
if (!cleanData || cleanData === "[DONE]") return false;
try {
const payload = JSON.parse(cleanData);
const finishReason = extractSummaryFinishReason(payload);
if (finishReason) state.summaryFinishReason = finishReason;
appendStreamingSummary(extractSummaryContentDelta(payload), extractSummaryReasoning(payload));
return true;
} catch {
appendStreamingSummary(cleanData, "");
return true;
}
}
function flushSummaryStreamBuffer(buffer) {
const rest = String(buffer || "").trim();
if (!rest) return;
if (rest.startsWith("data:")) {
const dataBlocks = rest.split(/\r?\n\s*data:/).map((item, index) => {
return index === 0 ? item.replace(/^data:\s*/, "") : item;
});
dataBlocks.forEach((item) => handleSummaryStreamPayload(item));
return;
}
handleSummaryStreamPayload(rest);
}
async function readSummaryStream(response) {
const contentType = response.headers.get("content-type") || "";
if (contentType && !/event-stream|stream|text\/plain/i.test(contentType)) {
const text = await response.text();
let payload;
try {
payload = text ? JSON.parse(text) : {};
} catch {
appendStreamingSummary(text, "");
return;
}
const reasoning = extractSummaryReasoning(payload);
const content = extractSummaryContent(payload);
state.summaryFinishReason = extractSummaryFinishReason(payload);
appendStreamingSummary(content, reasoning);
return;
}
const reader = response.body?.getReader?.();
if (!reader) {
const text = await response.text();
appendStreamingSummary(text, "");
return;
}
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(/\r?\n/);
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
handleSummaryStreamPayload(data);
}
}
buffer += decoder.decode();
flushSummaryStreamBuffer(buffer);
}
async function runMeetingSummary() {
const transcript = $("summaryTranscript").value.trim();
if (!transcript) {
setStatus($("summaryStatus"), "缺少 ASR 内容", "err");
setSummaryMarkdown("");
buildSummaryPromptPreview();
return;
}
const payload = buildSummaryPromptPreview();
if (!payload.endpoint) {
setStatus($("summaryStatus"), "缺少模型地址", "err");
return;
}
state.summaryRunning = true;
state.summaryThinkingOpen = false;
state.summaryFinishReason = "";
startSummaryTimer();
$("summaryRunBtn").disabled = true;
setStatus($("summaryStatus"), "模型总结中", "warn");
setSummaryView("preview");
setSummaryThinking("", "连接模型中");
setSummaryMarkdown("");
try {
const response = await fetch(payload.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
...payload.headers,
},
body: JSON.stringify(payload.body),
});
if (!response.ok) {
const text = await response.text();
let resultPayload;
try {
resultPayload = text ? JSON.parse(text) : {};
} catch {
resultPayload = text;
}
const message = resultPayload?.error?.message
|| resultPayload?.message
|| response.statusText
|| "模型调用失败";
throw new Error(typeof message === "string" ? message : JSON.stringify(message));
}
if (payload.body.stream) {
await readSummaryStream(response);
} else {
const text = await response.text();
let resultPayload;
try {
resultPayload = text ? JSON.parse(text) : {};
} catch {
resultPayload = text;
}
const reasoning = extractSummaryReasoning(resultPayload).trim();
state.summaryFinishReason = extractSummaryFinishReason(resultPayload);
const markdown = extractSummaryContent(resultPayload).trim();
const split = splitThinkBlock(markdown);
setSummaryThinking(reasoning || split.thinking, reasoning || split.thinking ? "已完成" : "未返回思考");
setSummaryMarkdown(split.content || markdown || "模型返回为空。");
}
if (!state.summaryMarkdown.trim()) setSummaryMarkdown("模型返回为空。");
stopSummaryTimer(true);
$("summaryThinkingBadge").textContent = state.summaryThinking ? "已完成" : "未返回思考";
if (state.summaryFinishReason === "length") {
setStatus($("summaryStatus"), `模型总结完成,可能被长度截断,耗时 ${$("summaryElapsedMeta").textContent}`, "warn");
} else {
setStatus($("summaryStatus"), `模型总结完成,耗时 ${$("summaryElapsedMeta").textContent}`, "ok");
}
} catch (error) {
stopSummaryTimer(true);
setStatus($("summaryStatus"), error.message || "模型调用失败", "err");
setSummaryThinking(state.summaryThinking, state.summaryThinking ? "中断" : "无思考输出");
setSummaryMarkdown(`## 模型调用失败\n\n${error.message || String(error)}\n\n请检查模型地址、api-key、跨域 CORS 或服务状态。`);
} finally {
state.summaryRunning = false;
$("summaryRunBtn").disabled = false;
}
}
async function uploadSelectedFileIfNeeded() {
if ($("fileUrl").value.trim()) return $("fileUrl").value.trim();
const file = $("audioFile").files[0];
if (!file) return "";
await uploadFile();
return $("fileUrl").value.trim();
}
async function sourcePayload(autoUpload = true) {
const audioAddress = $("fileUrl").value.trim();
if (audioAddress) return { audio_address: audioAddress };
const uploadedAddress = autoUpload ? await uploadSelectedFileIfNeeded() : $("fileUrl").value.trim();
if (uploadedAddress) return { audio_address: uploadedAddress };
throw new Error("请填写音频/视频 URL、服务端本地路径,或先上传测试文件。");
}
function parseHotwords(raw) {
const text = (raw || "").trim();
if (!text) return [];
const parseEntry = (entry) => {
const value = entry.trim();
if (!value) return null;
const match = value.match(/^(.+?)\s+([+-]?\d+(?:\.\d+)?)$/);
if (!match) return { hotword: value, weight: 1.0 };
return { hotword: match[1].trim(), weight: Number(match[2]) };
};
const chunked = text.split(/[\n,,;;]+/).map(item => item.trim()).filter(Boolean);
if (chunked.length > 1) return chunked.map(parseEntry).filter(Boolean);
const tokens = text.split(/\s+/).filter(Boolean);
const items = [];
for (let index = 0; index < tokens.length; index += 2) {
const hotword = tokens[index];
const weightToken = tokens[index + 1];
const weight = Number(weightToken);
if (Number.isFinite(weight)) {
items.push({ hotword, weight });
} else {
items.push({ hotword, weight: 1.0 });
if (weightToken) index -= 1;
}
}
return items;
}
function createClientMessageId() {
if (window.crypto?.randomUUID) {
return window.crypto.randomUUID().replace(/-/g, "").slice(0, 32);
}
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`.slice(0, 32);
}
function downsampleAudio(input, inputSampleRate, targetSampleRate = 16000) {
if (!input?.length) return new Float32Array(0);
if (!inputSampleRate || inputSampleRate === targetSampleRate) {
return new Float32Array(input);
}
const ratio = inputSampleRate / targetSampleRate;
const outputLength = Math.max(1, Math.round(input.length / ratio));
const output = new Float32Array(outputLength);
let outputIndex = 0;
let inputIndex = 0;
while (outputIndex < outputLength) {
const nextInputIndex = Math.min(
input.length,
Math.round((outputIndex + 1) * ratio),
);
let sum = 0;
let count = 0;
for (let i = inputIndex; i < nextInputIndex; i += 1) {
sum += input[i];
count += 1;
}
output[outputIndex] = count > 0 ? sum / count : input[inputIndex] || 0;
outputIndex += 1;
inputIndex = nextInputIndex;
}
return output;
}
function float32ToInt16(input) {
const pcm = new Int16Array(input.length);
for (let i = 0; i < input.length; i += 1) {
const sample = Math.max(-1, Math.min(1, input[i]));
pcm[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
}
return pcm;
}
function mergeInt16Arrays(left, right) {
if (!left?.length) return right;
if (!right?.length) return left;
const merged = new Int16Array(left.length + right.length);
merged.set(left, 0);
merged.set(right, left.length);
return merged;
}
function flushLivePcm(force = false) {
if (!state.liveSocket || state.liveSocket.readyState !== WebSocket.OPEN) return;
const frameSamples = Math.round(state.liveTargetSampleRate * 0.2);
while (state.livePcmPending.length >= frameSamples) {
const frame = state.livePcmPending.slice(0, frameSamples);
state.liveSocket.send(frame.buffer);
state.livePcmPending = state.livePcmPending.slice(frameSamples);
}
if (force && state.livePcmPending.length > 0) {
state.liveSocket.send(state.livePcmPending.buffer);
state.livePcmPending = new Int16Array(0);
}
}
function syncLiveProtocolOptions() {
$("liveProtocolBadge").textContent = "Tencent-like 协议";
$("liveProtocolNote").textContent =
"当前返回结构对齐腾讯实时 speaker demo,主消费 voice_id/start/sentences/end/error。";
$("liveQwenParams").hidden = false;
}
function buildQwenRealtimeStartPayload(sampleRate) {
const manualSessionId = $("liveSessionId").value.trim();
const sessionId = manualSessionId
|| ($("liveReuseSessionId").checked ? state.liveSessionId : "")
|| createClientMessageId().slice(0, 16);
$("liveSessionId").value = sessionId;
state.liveSessionId = sessionId;
return {
type: "start",
payload: {
format: "pcm",
sample_rate: sampleRate,
session_id: sessionId,
language: $("liveLanguage").value.trim() || null,
context: $("liveContext").value.trim(),
enable_inverse_text_normalization: $("liveEnableItn").checked,
unfixed_token_num: Number($("liveUnfixedTokenNum").value || 3),
silence_duration_ms: Number($("liveSilenceDurationMs").value || 800),
min_partial_sec: Number($("liveMinPartialSec").value || 0.3),
pre_roll_ms: Number($("livePreRollMs").value || 240),
max_sentence_count: Number($("liveMaxSentenceCount").value || 8),
partial_holdback_chars: Number($("livePartialHoldbackChars").value || 2),
enable_native_partial_stream: $("liveEnableNativePartialStream").checked,
enable_speaker: $("liveEnableSpeaker").checked,
match_speaker_registry: $("liveMatchSpeakerRegistry").checked,
speaker_threshold: Number($("liveSpeakerThreshold").value || 0.6),
enable_realtime_longform: $("liveEnableRealtimeLongform").checked,
enable_realtime_vad_split: $("liveEnableRealtimeVadSplit").checked,
force_stable_segment_sec: Number($("liveForceStableSegmentSec").value || 6),
force_stable_min_chars: Number($("liveForceStableMinChars").value || 24),
max_segment_sec: Number($("liveMaxSegmentSec").value || 12),
},
};
}
function formatLiveSpeaker(meta) {
if (!meta) return "";
const numericSpeakerId = Number(meta.speaker_id);
const name = String(meta.speaker_name || "").trim();
if (name) return name;
if (Number.isFinite(numericSpeakerId) && numericSpeakerId >= 0) {
return `说话人 ${numericSpeakerId}`;
}
return typeof meta.speaker_id === "string" ? meta.speaker_id.trim() : "";
}
function buildLiveSpeakerKey(meta, fallbackSegmentIndex = null) {
if (!meta) {
return fallbackSegmentIndex === null ? "unknown" : `segment-${fallbackSegmentIndex}`;
}
if (meta.user_id) return `user:${String(meta.user_id).trim()}`;
if (meta.registry_speaker_id) return `registry:${String(meta.registry_speaker_id).trim()}`;
const numericSpeakerId = Number(meta.speaker_id);
if (Number.isFinite(numericSpeakerId) && numericSpeakerId >= 0) {
return `speaker:${numericSpeakerId}`;
}
if (typeof meta.speaker_id === "string" && meta.speaker_id.trim()) {
return `speaker:${meta.speaker_id.trim()}`;
}
if (meta.speaker_name) return `name:${String(meta.speaker_name).trim()}`;
return fallbackSegmentIndex === null ? "unknown" : `segment-${fallbackSegmentIndex}`;
}
function formatLiveTimeRange(startMs, endMs) {
const formatTime = (value) => {
const totalSeconds = Math.max(0, Math.floor(Number(value || 0) / 1000));
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
const seconds = String(totalSeconds % 60).padStart(2, "0");
return `${minutes}:${seconds}`;
};
return `${formatTime(startMs)} - ${formatTime(endMs)}`;
}
function getLiveSpeakerOrder(key) {
const safeKey = String(key ?? "unknown").trim() || "unknown";
if (!(safeKey in state.liveSpeakerOrderMap)) {
state.liveSpeakerOrderMap[safeKey] = state.liveSpeakerOrderCounter % 6;
state.liveSpeakerOrderCounter += 1;
}
return state.liveSpeakerOrderMap[safeKey];
}
function handleQwenLiveMessage(payload) {
if (payload.type === "voice_id") {
state.liveTaskId = String(payload.voice_id || state.liveTaskId || "");
state.liveSessionId = String(payload.session_id || state.liveSessionId || state.liveTaskId || "");
if (state.liveSessionId) $("liveSessionId").value = state.liveSessionId;
return true;
}
if (payload.type === "start") {
setStatus($("liveStatus"), "实时会议中", "ok");
return true;
}
if (payload.type === "sentences") {
const sentences = Array.isArray(payload.sentences) ? payload.sentences : [];
sentences.forEach((sentence) => {
const segmentIndex = Number(sentence?.sentence_id ?? 0);
const isPartial = Number(sentence?.sentence_type ?? 0) === 0;
const speakerLabel = formatLiveSpeaker(sentence);
const speakerKey = buildLiveSpeakerKey(sentence, segmentIndex);
const speakerPending = Number(sentence?.speaker_id) < 0 && !speakerLabel;
addLiveEntry(
`${isPartial ? "进行中" : "已确认"} Segment ${segmentIndex + 1}${speakerLabel ? ` · ${speakerLabel}` : ""}`,
(sentence?.sentence || "").trim(),
isPartial,
speakerLabel,
speakerPending,
segmentIndex,
speakerKey,
Number(sentence?.start_time ?? 0),
Number(sentence?.end_time ?? 0),
);
});
const confirmedCount = state.liveTranscript.filter((item) => !item.partial).length;
$("liveSegmentCount").textContent = String(confirmedCount);
if (payload.result && Number.isFinite(Number(payload.result.index))) {
state.liveChunkCount = Math.max(state.liveChunkCount, Number(payload.result.index) + 1);
$("liveChunkCount").textContent = String(state.liveChunkCount);
}
updateLiveDuration();
return true;
}
if (payload.type === "end") {
const sentences = Array.isArray(payload.sentences) ? payload.sentences : [];
const summaryText = payload.result?.voice_text_str || "";
if (sentences.length) {
syncLiveTranscriptFromPayload(
sentences.map((sentence) => ({
index: Number(sentence?.sentence_id ?? 0),
text: sentence?.sentence || "",
speaker_id: sentence?.speaker_id,
speaker_name: sentence?.speaker_name,
start_time: sentence?.start_time,
end_time: sentence?.end_time,
})),
"",
sentences.length ? Number(sentences[sentences.length - 1]?.sentence_id ?? 0) : 0,
);
}
hydrateSummaryFromLiveMeeting("completed", summaryText);
finalizeLiveSession("completed", summaryText);
setStatus($("liveStatus"), "已结束", "ok");
stopRealtimeMeeting({ sendStop: false }).catch(() => {});
return true;
}
if (payload.type === "error") {
setStatus($("liveStatus"), payload.message || "实时识别出错", "err");
return true;
}
return false;
}
function syncLiveTranscriptFromPayload(sentences, partialText = "", currentSegmentIndex = 0) {
const confirmed = Array.isArray(sentences) ? sentences : [];
state.liveTranscript = confirmed.map((segment, idx) => {
const segmentIndex = Number(segment?.index ?? idx);
const speakerLabel = formatLiveSpeaker(segment);
const speakerKey = buildLiveSpeakerKey(segment, segmentIndex);
const speakerPending = Number(segment?.speaker_id) < 0 && !speakerLabel;
return {
label: `已确认 Segment ${segmentIndex + 1}${speakerLabel ? ` · ${speakerLabel}` : ""}`,
text: (segment?.text || "").trim(),
partial: false,
speaker: speakerLabel,
speakerPending,
segmentIndex,
speakerKey,
startTimeMs: Number(segment?.start_time ?? 0),
endTimeMs: Number(segment?.end_time ?? 0),
};
}).filter((item) => item.text);
$("liveSegmentCount").textContent = String(confirmed.length);
const safePartial = (partialText || "").trim();
if (safePartial) {
state.liveTranscript.push({
label: `进行中 Segment ${currentSegmentIndex + 1}`,
text: safePartial,
partial: true,
speaker: "",
speakerPending: false,
segmentIndex: currentSegmentIndex,
speakerKey: `segment-${currentSegmentIndex}`,
});
}
renderLiveTranscript();
}
function renderLiveTranscript() {
const items = state.liveTranscript;
if (!items.length) {
$("liveTranscript").innerHTML = `<div class="empty-state">点击“开始实时会议”后,这里会像 demo 一样滚动显示气泡和实时变化。</div>`;
return;
}
const rendered = items.map((item) => {
const speaker = (item.speaker || "").trim();
const speakerPending = item.speakerPending || !speaker;
const timeRange = formatLiveTimeRange(item.startTimeMs, item.endTimeMs);
const bodyText = `${escapeHtml(item.text || "")}${item.partial ? " ..." : ""}`;
if (speakerPending) {
return `
<article class="bubble-row speaker-left speaker-unknown">
<div class="bubble-wrapper">
<div class="bubble-header">
<span class="speaker-badge speaker-color-unknown"></span>
<span class="speaker-name">说话人不确定</span>
<span class="bubble-time">${escapeHtml(timeRange)}</span>
</div>
<div class="bubble-body ${item.partial ? "interim" : ""}">${bodyText}</div>
</div>
</article>
`;
}
const order = getLiveSpeakerOrder(item.speakerKey || speaker);
const side = order % 2 === 0 ? "speaker-left" : "speaker-right";
const badge = `speaker-color-${order}`;
return `
<article class="bubble-row ${side} speaker-${order}">
<div class="bubble-wrapper">
<div class="bubble-header">
<span class="speaker-badge ${badge}"></span>
<span class="speaker-name">${escapeHtml(speaker)}</span>
<span class="bubble-time">${escapeHtml(timeRange)}</span>
</div>
<div class="bubble-body ${item.partial ? "interim" : ""}">${bodyText}</div>
</div>
</article>
`;
});
$("liveTranscript").innerHTML = rendered.join("");
$("liveTranscript").scrollTop = $("liveTranscript").scrollHeight;
}
function renderLiveSessions() {
if (!state.liveSessions.length) {
$("liveSessionList").innerHTML = `<div class="empty-state">还没有实时会议记录。</div>`;
return;
}
$("liveSessionList").innerHTML = state.liveSessions.map((item, index) => `
<article class="session-item">
<strong>${escapeHtml(item.title || `会议 ${index + 1}`)}</strong>
<div class="session-meta">
<span>${escapeHtml(item.time || "")}</span>
<span>时长 ${escapeHtml(item.duration || "00:00")}</span>
<span>${escapeHtml(item.status || "completed")}</span>
<span>${escapeHtml(String(item.segments || 0))} 段</span>
</div>
<p>${escapeHtml(item.preview || "")}</p>
</article>
`).join("");
}
function clearLiveTranscript() {
state.liveTranscript = [];
state.liveMessages = [];
state.liveChunkCount = 0;
state.liveSpeakerOrderMap = {};
state.liveSpeakerOrderCounter = 0;
state.liveSessionSaved = false;
state.liveLastSummaryHydratedAt = null;
$("liveSegmentCount").textContent = "0";
$("liveChunkCount").textContent = "0";
$("liveDuration").textContent = "00:00";
$("liveRawOutput").value = "[]";
renderLiveTranscript();
}
function updateLiveDuration() {
if (!state.liveStartedAt) {
$("liveDuration").textContent = "00:00";
return;
}
$("liveDuration").textContent = formatDuration((Date.now() - state.liveStartedAt) / 1000);
}
function setLiveRunning(running) {
$("liveStartBtn").disabled = running;
$("liveStopBtn").disabled = !running;
$("liveSimulateDisconnectBtn").disabled = !running;
$("liveMicState").textContent = running ? "麦克风采集中" : "麦克风空闲";
$("liveMicStateSummary").textContent = running ? "采集中" : "空闲";
}
async function simulateLiveDisconnect() {
if (!state.liveSocket) throw new Error("当前没有可断开的实时会议连接。");
setStatus($("liveStatus"), "模拟断线中", "warn");
await stopRealtimeMeeting({ sendStop: false });
}
function addLiveEntry(
label,
text,
partial = false,
speaker = "",
speakerPending = false,
segmentIndex = null,
speakerKey = "",
startTimeMs = 0,
endTimeMs = 0,
) {
const safeText = (text || "").trim();
if (!safeText) return;
if (partial && state.liveTranscript.length) {
const last = state.liveTranscript[state.liveTranscript.length - 1];
if (last.partial && (segmentIndex === null || last.segmentIndex === segmentIndex)) {
last.label = label;
last.text = safeText;
last.speaker = speaker;
last.speakerPending = speakerPending;
last.segmentIndex = segmentIndex;
last.speakerKey = speakerKey || last.speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`);
last.startTimeMs = startTimeMs;
last.endTimeMs = endTimeMs;
renderLiveTranscript();
return;
}
}
if (!partial && state.liveTranscript.length) {
const last = state.liveTranscript[state.liveTranscript.length - 1];
if (last.partial) state.liveTranscript.pop();
}
if (!partial && segmentIndex !== null) {
const existing = state.liveTranscript.find(
(item) => !item.partial && item.segmentIndex === segmentIndex,
);
if (existing) {
existing.label = label;
existing.text = safeText;
existing.speaker = speaker;
existing.speakerPending = speakerPending;
existing.speakerKey = speakerKey || existing.speakerKey || `segment-${segmentIndex}`;
existing.startTimeMs = startTimeMs;
existing.endTimeMs = endTimeMs;
renderLiveTranscript();
return;
}
}
state.liveTranscript.push({
label,
text: safeText,
partial,
speaker,
speakerPending,
segmentIndex,
speakerKey: speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`),
startTimeMs,
endTimeMs,
});
renderLiveTranscript();
}
function clearTrailingPartialEntry() {
if (!state.liveTranscript.length) return;
const last = state.liveTranscript[state.liveTranscript.length - 1];
if (!last.partial) return;
state.liveTranscript.pop();
renderLiveTranscript();
}
function finalizeLiveSession(status = "completed", fullText = "") {
if (state.liveSessionSaved || !state.liveStartedAt) return;
const transcriptText = fullText.trim() || state.liveTranscript
.filter((item) => !item.partial)
.map((item) => item.text)
.join(" ");
const cleanText = transcriptText.trim();
if (!cleanText) return;
const startedAt = new Date(state.liveStartedAt);
state.liveSessions.unshift({
title: `实时会议 ${state.liveSessions.length + 1}`,
time: startedAt.toLocaleString("zh-CN", { hour12: false }),
duration: formatDuration((Date.now() - state.liveStartedAt) / 1000),
status,
segments: Number($("liveSegmentCount").textContent || 0),
preview: cleanText.length > 160 ? `${cleanText.slice(0, 160)}...` : cleanText,
});
if (state.liveSessions.length > 12) {
state.liveSessions = state.liveSessions.slice(0, 12);
}
state.liveSessionSaved = true;
renderLiveSessions();
}
async function stopRealtimeMeeting(options = {}) {
const { sendStop = true } = options;
const socket = state.liveSocket;
state.liveSocket = null;
if (socket && sendStop && socket.readyState === WebSocket.OPEN) {
flushLivePcm(true);
hydrateSummaryFromLiveMeeting("stopped");
finalizeLiveSession("stopped");
socket.send(JSON.stringify({ type: "stop" }));
} else if (socket && socket.readyState < WebSocket.CLOSING) {
socket.close();
}
if (state.liveProcessor) {
state.liveProcessor.disconnect();
state.liveProcessor.onaudioprocess = null;
state.liveProcessor = null;
}
if (state.liveMediaStream) {
state.liveMediaStream.getTracks().forEach((track) => track.stop());
state.liveMediaStream = null;
}
if (state.liveAudioContext) {
try {
await state.liveAudioContext.close();
} catch {}
state.liveAudioContext = null;
}
setLiveRunning(false);
state.liveTaskId = "";
state.livePcmPending = new Int16Array(0);
if (!$("liveReuseSessionId").checked) {
state.liveSessionId = "";
$("liveSessionId").value = "";
}
updateLiveDuration();
}
async function startRealtimeMeeting() {
if (state.liveSocket) throw new Error("实时会议已经在进行中。");
clearLiveTranscript();
setStatus($("liveStatus"), "连接中", "warn");
setLiveRunning(true);
state.liveStartedAt = Date.now();
updateLiveDuration();
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(2048, 1, 1);
const silentGain = audioContext.createGain();
silentGain.gain.value = 0;
const wsUrl = `${wsBase()}${$("liveWsPath").value.trim() || "/ws/v1/asr"}`;
const socket = new WebSocket(wsUrl);
const liveTaskId = createClientMessageId().slice(0, 16);
state.liveMediaStream = stream;
state.liveAudioContext = audioContext;
state.liveProcessor = processor;
state.liveSocket = socket;
state.liveTaskId = liveTaskId;
socket.addEventListener("open", () => {
state.livePcmPending = new Int16Array(0);
socket.send(JSON.stringify(buildQwenRealtimeStartPayload(state.liveTargetSampleRate)));
});
socket.addEventListener("message", (event) => {
let payload = {};
try {
payload = JSON.parse(event.data);
} catch {
payload = { text: String(event.data || "") };
}
showLiveRaw(payload);
handleQwenLiveMessage(payload);
});
socket.addEventListener("close", () => {
if (state.liveSocket === socket) {
state.liveSocket = null;
}
hydrateSummaryFromLiveMeeting("closed");
finalizeLiveSession("closed");
if ($("liveStatus").textContent === "实时会议中" || $("liveStatus").textContent === "连接中") {
setStatus($("liveStatus"), "连接已关闭", "warn");
}
stopRealtimeMeeting({ sendStop: false }).catch(() => {});
});
socket.addEventListener("error", () => {
setStatus($("liveStatus"), "连接失败", "err");
});
processor.onaudioprocess = (event) => {
if (!state.liveSocket || state.liveSocket.readyState !== WebSocket.OPEN) return;
const channelData = event.inputBuffer.getChannelData(0);
const downsampled = downsampleAudio(
channelData,
audioContext.sampleRate,
state.liveTargetSampleRate,
);
const pcm = float32ToInt16(downsampled);
state.livePcmPending = mergeInt16Arrays(state.livePcmPending, pcm);
flushLivePcm(false);
updateLiveDuration();
};
source.connect(processor);
processor.connect(silentGain);
silentGain.connect(audioContext.destination);
} catch (error) {
await stopRealtimeMeeting({ sendStop: false });
state.liveStartedAt = null;
setStatus($("liveStatus"), error.message || "启动失败", "err");
throw error;
}
}
function speakerPayload() {
const fileId = $("speakerFileId").value.trim();
if (!fileId) throw new Error("请先录制 12 秒声纹样本。");
return { file_id: fileId };
}
function syncSpeakerOptions() {
const enabled = $("enableSpeaker").checked;
$("matchSpeakerRegistry").disabled = !enabled;
}
function renderTask(payload) {
showRaw(payload);
const data = payload.data || payload;
const status = data.status || "unknown";
const stage = data.stage || "unknown";
const pct = data.percentage ?? 0;
const progressMessage = data.message || status;
setStatus($("taskStatus"), `${stage} ${pct}%`, status === "completed" ? "ok" : status === "failed" ? "err" : "warn");
$("taskProgressPercent").textContent = `${pct}%`;
$("taskProgressBar").style.width = `${pct}%`;
$("taskProgressNote").textContent = progressMessage;
$("taskEta").textContent = data.eta_seconds === undefined || data.eta_seconds === null
? "预计剩余 --"
: `预计剩余 ${formatDuration(data.eta_seconds)}`;
if (data.task_id) $("taskId").value = data.task_id;
if (data.result) renderResult(data.result);
if (status === "completed" || status === "failed") {
stopPolling();
stopElapsedTimer(true);
}
}
function renderResult(result) {
const segments = result.segments || [];
const speakers = result.speakers || [];
$("metricDuration").textContent = `${result.audio_duration_seconds || 0}s`;
$("metricSegments").textContent = String(segments.length);
$("metricSpeakers").textContent = String(result.speaker_count || 0);
$("summary").textContent = speakers.length ? `识别到:${speakers.join("、")}` : "没有识别到说话人信息。";
setStatus($("resultBadge"), "已完成", "ok");
if (!segments.length) {
$("segments").innerHTML = `<div class="empty-state">没有分段结果</div>`;
return;
}
$("segments").innerHTML = segments.map((seg, index) => `
<article class="segment">
<div class="segment-head">
<span>#${index + 1}</span>
<span>${seg.start ?? 0}s - ${seg.end ?? 0}s</span>
<span>${escapeHtml(seg.speaker_name || seg.speaker_id || "未标记")}</span>
${seg.user_id ? `<span class="mono">${escapeHtml(seg.user_id)}</span>` : ""}
</div>
<p>${escapeHtml(seg.text || "")}</p>
</article>
`).join("");
hydrateSummaryFromOfflineResult(result);
}
function renderSpeakers(payload) {
showRaw(payload);
const items = payload?.data?.items || [];
if (!items.length) {
$("speakerList").innerHTML = `<div class="empty-state">暂无声纹人员</div>`;
return;
}
$("speakerList").innerHTML = `
<table>
<thead><tr><th>ID</th><th>姓名</th><th>user_id</th><th></th></tr></thead>
<tbody>
${items.map((item) => `
<tr>
<td class="mono">${escapeHtml(item.id || "")}</td>
<td>${escapeHtml(item.name || "")}</td>
<td class="mono">${escapeHtml(item.user_id || "")}</td>
<td><button class="btn danger" style="min-height: 32px; padding: 6px 9px;" onclick="deleteSpeaker('${item.id}')">删除</button></td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[char]));
}
async function uploadFile() {
const file = $("audioFile").files[0];
if (!file) throw new Error("请选择音频文件。");
setUploadState("上传中");
$("uploadBtn").disabled = true;
setUploadProgress("meeting", 0, "准备上传");
const body = new FormData();
body.append("file", file);
try {
const payload = await uploadFormData("/api/v1/files", body, (percent, text) => {
setUploadProgress("meeting", percent, text);
});
$("fileId").value = payload.data.file_id;
if (payload.data.file_path) $("fileUrl").value = payload.data.file_path;
setUploadState("音频已就绪", "ok");
setUploadProgress("meeting", 100, "上传完成,已填入本地 audio_address。");
showRaw(payload);
return payload.data.file_path || "";
} catch (error) {
setUploadProgress("meeting", 0, error.message || "上传失败");
throw error;
} finally {
$("uploadBtn").disabled = false;
}
}
function preferredAudioMimeType() {
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/mp4",
"audio/ogg;codecs=opus",
];
if (!window.MediaRecorder) return "";
return candidates.find(type => MediaRecorder.isTypeSupported(type)) || "";
}
async function uploadMeetingRecording(blob) {
if (!blob || blob.size === 0) throw new Error("录音为空,请重新录制。");
const extension = blob.type.includes("mp4") ? "m4a" : blob.type.includes("ogg") ? "ogg" : "webm";
const formData = new FormData();
formData.append("file", blob, `meeting-record-${Date.now()}.${extension}`);
setUploadState("录音上传中");
setUploadProgress("meeting", 0, "准备上传录音");
const payload = await uploadFormData("/api/v1/files", formData, (percent, text) => {
setUploadProgress("meeting", percent, text);
});
$("fileId").value = payload.data.file_id;
if (payload.data.file_path) $("fileUrl").value = payload.data.file_path;
setUploadState("录音已就绪", "ok");
setUploadProgress("meeting", 100, "录音上传完成,已填入本地 audio_address。");
showRaw(payload);
return payload.data.file_path || "";
}
async function startMeetingRecord() {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error("当前浏览器不支持麦克风录音,或页面不是 HTTPS/localhost。");
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = preferredAudioMimeType();
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
state.meetingRecorder = recorder;
state.meetingRecordChunks = [];
recorder.ondataavailable = event => {
if (event.data && event.data.size > 0) state.meetingRecordChunks.push(event.data);
};
recorder.onstop = async () => {
const tracks = recorder.stream?.getTracks?.() || [];
tracks.forEach(track => track.stop());
$("meetingRecordStartBtn").disabled = false;
$("meetingRecordStopBtn").disabled = true;
const blob = new Blob(state.meetingRecordChunks, { type: recorder.mimeType || "audio/webm" });
$("meetingRecordPreview").src = URL.createObjectURL(blob);
$("meetingRecordPreview").hidden = false;
$("meetingRecordStatus").textContent = `录音完成,大小 ${formatBytes(blob.size)},正在上传...`;
try {
await uploadMeetingRecording(blob);
$("meetingRecordStatus").textContent = "录音已上传,可以直接开始离线会议识别。";
} catch (error) {
$("meetingRecordStatus").textContent = "录音上传失败。";
showError(error);
} finally {
state.meetingRecorder = null;
state.meetingRecordChunks = [];
}
};
recorder.start();
$("audioFile").value = "";
$("fileId").value = "";
$("fileUrl").value = "";
$("meetingRecordPreview").hidden = true;
$("meetingRecordStartBtn").disabled = true;
$("meetingRecordStopBtn").disabled = false;
$("meetingRecordStatus").textContent = "正在录音,停止后会自动上传并填入 audio_address。";
setUploadState("录音中");
}
function stopMeetingRecord() {
const recorder = state.meetingRecorder;
if (recorder && recorder.state !== "inactive") {
recorder.stop();
$("meetingRecordStatus").textContent = "正在停止录音...";
}
}
let speakerRecorder = null;
let speakerChunks = [];
let speakerCountdownTimer = null;
let speakerRemainingSeconds = 12;
function setSpeakerCountdown(seconds) {
$("speakerRecordCountdown").textContent = `${seconds} 秒`;
}
function clearSpeakerCountdown() {
if (speakerCountdownTimer) clearInterval(speakerCountdownTimer);
speakerCountdownTimer = null;
}
async function startSpeakerRecord() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
speakerRecorder = new MediaRecorder(stream);
speakerChunks = [];
speakerRemainingSeconds = 12;
setSpeakerCountdown(speakerRemainingSeconds);
speakerRecorder.ondataavailable = event => {
if (event.data.size > 0) speakerChunks.push(event.data);
};
speakerRecorder.onstop = async () => {
clearSpeakerCountdown();
const blob = new Blob(speakerChunks, { type: "audio/webm" });
const url = URL.createObjectURL(blob);
$("speakerRecordPreview").src = url;
$("speakerRecordPreview").hidden = false;
$("speakerRecordStatus").textContent = "声纹录音完成,正在自动上传...";
try {
const formData = new FormData();
formData.append("file", blob, `speaker-${Date.now()}.webm`);
setUploadProgress("speaker", 0, "准备上传声纹样本");
const payload = await uploadFormData("/api/v1/files", formData, (percent, text) => {
setUploadProgress("speaker", percent, text);
});
$("speakerFileId").value = payload.data.file_id;
$("speakerRecordStatus").textContent = "声纹样本已上传,可以注册或识别。";
setUploadProgress("speaker", 100, "声纹样本上传完成。");
showRaw(payload);
} catch (e) {
showError(e);
$("speakerRecordStatus").textContent = "声纹样本上传失败。";
setUploadProgress("speaker", 0, e.message || "上传失败");
}
};
speakerRecorder.start();
$("speakerRecordStartBtn").disabled = true;
$("speakerRecordStopBtn").disabled = false;
$("speakerRecordStatus").textContent = "正在录制声纹,请保持单人说话。";
$("speakerRecordPreview").hidden = true;
speakerCountdownTimer = setInterval(() => {
speakerRemainingSeconds -= 1;
setSpeakerCountdown(Math.max(0, speakerRemainingSeconds));
if (speakerRemainingSeconds <= 0) stopSpeakerRecord();
}, 1000);
} catch (e) {
showError(e);
}
}
function stopSpeakerRecord() {
clearSpeakerCountdown();
if (speakerRecorder && speakerRecorder.state !== "inactive") {
speakerRecorder.stop();
speakerRecorder.stream.getTracks().forEach(t => t.stop());
$("speakerRecordStartBtn").disabled = false;
$("speakerRecordStopBtn").disabled = true;
}
}
async function createTask() {
stopPolling();
$("createTaskBtn").disabled = true;
let recognitionStarted = false;
try {
const source = await sourcePayload(true);
recognitionStarted = true;
state.taskStartedAt = Date.now();
state.taskEndedAt = null;
$("taskElapsed").textContent = "耗时 00:00";
$("taskEta").textContent = "预计剩余 --";
setStatus($("resultBadge"), "等待结果");
startElapsedTimer();
setLocalProgress("creating", 1, "音频已就绪,正在创建识别任务。");
const payload = {
...source,
config: {
enable_speaker: $("enableSpeaker").checked,
match_speaker_registry: $("matchSpeakerRegistry").checked,
enable_text_cleanup: $("enableTextCleanup").checked,
speaker_threshold: Number($("speakerThreshold").value || 0.6),
word_timestamps: $("wordTimestamps").checked,
hotwords: parseHotwords($("hotwords").value),
sample_rate: Number($("sampleRate").value || 16000),
},
};
const result = await requestJson("/api/v1/asr/transcriptions", {
method: "POST",
headers: headers(),
body: JSON.stringify(payload),
});
$("taskProgressNote").textContent = "任务已创建,正在进入队列。";
renderTask(result);
startPolling();
} catch (error) {
if (recognitionStarted) stopElapsedTimer(true);
throw error;
} finally {
$("createTaskBtn").disabled = false;
}
}
async function pollTask() {
const taskId = $("taskId").value.trim();
if (!taskId) throw new Error("请先创建任务,或在高级参数里填写 task_id。");
const payload = await requestJson(`/api/v1/asr/transcriptions/${encodeURIComponent(taskId)}`, {
headers: headers(false),
});
renderTask(payload);
}
function startPolling() {
stopPolling();
state.pollTimer = setInterval(() => pollTask().catch(showError), 1800);
}
function stopPolling() {
if (state.pollTimer) clearInterval(state.pollTimer);
state.pollTimer = null;
}
async function registerSpeaker() {
const name = $("speakerName").value.trim();
if (!name) throw new Error("请填写姓名。");
const payload = {
...speakerPayload(),
name,
user_id: $("speakerUserId").value.trim() || null,
};
const result = await requestJson("/api/v1/speakers", {
method: "POST",
headers: headers(),
body: JSON.stringify(payload),
});
showRaw(result);
await listSpeakers();
}
async function identifySpeaker() {
const payload = {
...speakerPayload(),
threshold: Number($("speakerThreshold").value || 0.6),
};
const result = await requestJson("/api/v1/speakers/identify", {
method: "POST",
headers: headers(),
body: JSON.stringify(payload),
});
showRaw(result);
}
async function listSpeakers() {
const payload = await requestJson("/api/v1/speakers", { headers: headers(false) });
renderSpeakers(payload);
}
async function deleteSpeaker(id) {
if (!id) return;
const payload = await requestJson(`/api/v1/speakers/${encodeURIComponent(id)}`, {
method: "DELETE",
headers: headers(false),
});
showRaw(payload);
await listSpeakers();
}
window.deleteSpeaker = (id) => deleteSpeaker(id).catch(showError);
function showError(error) {
setStatus($("taskStatus"), error.message || String(error), "err");
showRaw({ error: error.message || String(error) });
}
function showLiveError(error) {
setStatus($("liveStatus"), error.message || String(error), "err");
showLiveRaw({ error: error.message || String(error) });
}
$("checkHealthBtn").addEventListener("click", async () => {
try {
const payload = await requestJson("/stream/v1/asr/health", { headers: headers(false) });
showRaw(payload);
setStatus($("healthStatus"), payload.status || "ok", payload.status === "healthy" ? "ok" : "warn");
} catch (error) {
setStatus($("healthStatus"), error.message || "检查失败", "err");
showError(error);
}
});
$("liveStartBtn").addEventListener("click", () => startRealtimeMeeting().catch(showLiveError));
$("liveStopBtn").addEventListener("click", () => stopRealtimeMeeting().catch(showLiveError));
$("liveSimulateDisconnectBtn").addEventListener("click", () => simulateLiveDisconnect().catch(showLiveError));
$("liveClearBtn").addEventListener("click", clearLiveTranscript);
$("liveClearSessionsBtn").addEventListener("click", () => {
state.liveSessions = [];
renderLiveSessions();
});
$("liveWsPath").addEventListener("input", syncLiveProtocolOptions);
$("copyLiveJsonBtn").addEventListener("click", async () => {
await navigator.clipboard.writeText(JSON.stringify(state.liveMessages || [], null, 2));
});
$("summaryBuildPromptBtn").addEventListener("click", buildSummaryPromptPreview);
$("summaryRunBtn").addEventListener("click", () => runMeetingSummary());
$("summaryCopyPayloadBtn").addEventListener("click", async () => {
const payload = buildSummaryPromptPreview();
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
});
$("summaryCopyPromptBtn").addEventListener("click", async () => {
const payload = buildSummaryPromptPreview();
await navigator.clipboard.writeText(payload.body.messages.map((msg) => `${msg.role.toUpperCase()}\n${msg.content}`).join("\n\n"));
});
$("summaryTemplateSelect").addEventListener("change", () => {
state.activeSummaryTemplateId = $("summaryTemplateSelect").value;
applySelectedSummaryTemplate(true);
});
$("summaryNewTemplateBtn").addEventListener("click", createSummaryTemplate);
$("summarySaveTemplateBtn").addEventListener("click", saveCurrentSummaryTemplate);
$("summaryDeleteTemplateBtn").addEventListener("click", deleteCurrentSummaryTemplate);
$("summaryTemplateName").addEventListener("input", buildSummaryPromptPreview);
$("summaryTemplatePrompt").addEventListener("input", buildSummaryPromptPreview);
$("summaryCopyMarkdownBtn").addEventListener("click", async () => {
await navigator.clipboard.writeText($("summaryMarkdownEditor").value || state.summaryMarkdown || "");
});
$("summaryMarkdownEditor").addEventListener("input", () => {
state.summaryMarkdown = $("summaryMarkdownEditor").value;
$("summaryMarkdownPreview").innerHTML = renderMarkdown(state.summaryMarkdown);
});
document.querySelectorAll(".summary-view-tab").forEach((btn) => {
btn.addEventListener("click", () => setSummaryView(btn.dataset.summaryView));
});
document.querySelectorAll(".summary-mode").forEach((btn) => {
btn.addEventListener("click", () => setSummaryLevel(btn.dataset.summaryLevel));
});
document.querySelectorAll(".summary-source-btn").forEach((btn) => {
btn.addEventListener("click", () => setSummarySource(btn.dataset.summarySource));
});
[
"summaryModelEndpoint",
"summaryModelName",
"summaryApiKey",
"summaryStream",
"summaryMeetingTitle",
"summaryMeetingDate",
"summaryMeetingType",
"summaryOutputLanguage",
"summaryFocus",
"summaryTemplateName",
"summaryTemplatePrompt",
"summaryTranscript",
].forEach((id) => {
$(id).addEventListener("input", buildSummaryPromptPreview);
$(id).addEventListener("change", buildSummaryPromptPreview);
});
document.querySelectorAll(".workspace-tab").forEach((btn) => {
btn.addEventListener("click", () => setWorkspace(btn.dataset.workspace));
});
document.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => setSource(btn.dataset.source));
});
$("audioFile").addEventListener("change", () => {
$("fileId").value = "";
$("fileUrl").value = "";
$("meetingUploadProgress").hidden = true;
$("meetingUploadBar").style.width = "0";
$("meetingRecordPreview").hidden = true;
const file = $("audioFile").files[0];
setUploadState(file ? "已选择文件" : "等待音频");
$("uploadHint").textContent = file
? `${file.name},大小 ${formatBytes(file.size)}。也可以直接点“开始识别”。`
: "也可以直接点“开始识别”,系统会自动上传。";
});
$("uploadBtn").addEventListener("click", () => uploadFile().catch(showError));
$("meetingRecordStartBtn").addEventListener("click", () => startMeetingRecord().catch(showError));
$("meetingRecordStopBtn").addEventListener("click", () => stopMeetingRecord());
$("enableSpeaker").addEventListener("change", syncSpeakerOptions);
$("speakerRecordStartBtn").addEventListener("click", () => startSpeakerRecord());
$("speakerRecordStopBtn").addEventListener("click", () => stopSpeakerRecord());
$("createTaskBtn").addEventListener("click", () => createTask().catch(showError));
$("pollTaskBtn").addEventListener("click", () => pollTask().catch(showError));
$("stopPollBtn").addEventListener("click", stopPolling);
$("registerSpeakerBtn").addEventListener("click", () => registerSpeaker().catch(showError));
$("identifySpeakerBtn").addEventListener("click", () => identifySpeaker().catch(showError));
$("listSpeakersBtn").addEventListener("click", () => listSpeakers().catch(showError));
$("copyJsonBtn").addEventListener("click", async () => {
await navigator.clipboard.writeText(JSON.stringify(state.lastJson || {}, null, 2));
});
syncLiveProtocolOptions();
syncSpeakerOptions();
clearLiveTranscript();
renderLiveSessions();
loadSummaryTemplates();
renderSummaryTemplates();
setSummaryLevel("standard");
buildSummaryPromptPreview();
setWorkspace("live");
</script>
</body>
</html>