4957 lines
163 KiB
HTML
4957 lines
163 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>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="一行一个,格式参考老项目: 通义千问 2.0 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 写输出结构,例如: ## 会议概述 ## 关键议题 ## 行动项"></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 / <think> 时会显示在这里。</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) {
|
||
const existingPartialIndex = state.liveTranscript.findIndex(
|
||
(item) => item.partial && (segmentIndex === null || item.segmentIndex === segmentIndex),
|
||
);
|
||
if (existingPartialIndex >= 0) {
|
||
const existingPartial = state.liveTranscript[existingPartialIndex];
|
||
existingPartial.label = label;
|
||
existingPartial.text = safeText;
|
||
existingPartial.speaker = speaker;
|
||
existingPartial.speakerPending = speakerPending;
|
||
existingPartial.segmentIndex = segmentIndex;
|
||
existingPartial.speakerKey = speakerKey || existingPartial.speakerKey || (segmentIndex === null ? "unknown" : `segment-${segmentIndex}`);
|
||
existingPartial.startTimeMs = startTimeMs;
|
||
existingPartial.endTimeMs = endTimeMs;
|
||
renderLiveTranscript();
|
||
return;
|
||
}
|
||
}
|
||
if (!partial) {
|
||
// A late speaker update for an older segment must not erase the live preview.
|
||
const partialIndex = state.liveTranscript.findIndex(
|
||
(item) => item.partial && (
|
||
segmentIndex === null
|
||
? item.segmentIndex === null
|
||
: item.segmentIndex === segmentIndex
|
||
),
|
||
);
|
||
if (partialIndex >= 0) state.liveTranscript.splice(partialIndex, 1);
|
||
}
|
||
|
||
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) => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
}[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>
|