Refactor backend to support interface-definition driven model resources

- Introduce a new model structure for managing interface definitions and model resources, enhancing the backend's capability to handle various service integrations.
- Update the Makefile to reflect changes in database seeding and resource management commands.
- Remove the deprecated credentials management routes and replace them with a unified model registry API.
- Modify existing routes and schemas to align with the new model structure, ensuring seamless integration with the frontend.
- Enhance database seeding scripts to populate new model resources and their configurations.
- Update README documentation to reflect the new architecture and usage instructions for model resources and interface definitions.
This commit is contained in:
Xin Wang
2026-06-14 19:36:12 +08:00
parent e25dfd4003
commit 90e3e8a0c0
32 changed files with 2577 additions and 1765 deletions

View File

@@ -14,12 +14,12 @@ import {
Pencil,
Plus,
Rocket,
Search,
Sparkles,
Trash2,
Workflow,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronDown,
Save,
Mic,
Send,
@@ -61,6 +61,11 @@ import { NebulaVisualizer } from "@/components/ui/nebula-visualizer";
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
import { WaveVisualizer } from "@/components/ui/wave-visualizer";
import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline";
import { DataList } from "@/components/ui/data-list";
import { PageHeader } from "@/components/ui/page-header";
import { FilterPills } from "@/components/ui/filter-pills";
import { SearchInput } from "@/components/ui/search-input";
import { ListToolbar } from "@/components/ui/list-toolbar";
import {
Card,
CardContent,
@@ -71,15 +76,19 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
assistantsApi,
credentialsApi,
knowledgeBasesApi,
modelResourcesApi,
type Assistant,
type AssistantType as ApiAssistantType,
type AssistantUpsert,
type Credential,
type KnowledgeBase,
type ModelResource,
} from "@/lib/api";
import { useVoicePreview, type ChatMessage } from "@/hooks/use-voice-preview";
import {
useVoicePreview,
type ChatMessage,
type VoicePreviewStatus,
} from "@/hooks/use-voice-preview";
type RuntimeMode = "pipeline" | "realtime";
@@ -283,12 +292,17 @@ type AssistantListItem = {
name: string;
type: AssistantType;
updatedAt: string;
/** 原始 ISO 时间戳,用于按时间排序(updatedAt 为展示用整形字符串) */
updatedAtRaw: string | null | undefined;
};
type TypeFilter = "全部" | AssistantType;
const typeFilters: TypeFilter[] = ["全部", ...assistantTypes];
// 列表按更新时间排序:newest=最近更新在前(倒叙,默认) / oldest=最早更新在前
type SortOrder = "newest" | "oldest";
export function AssistantPage(props: AssistantPageProps) {
const router = useRouter();
// 编辑中的助手 id(来自路由)
@@ -311,8 +325,8 @@ export function AssistantPage(props: AssistantPageProps) {
const [loadError, setLoadError] = useState<string | null>(null);
// 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥")
const [storedApiKeyMask, setStoredApiKeyMask] = useState("");
// 下拉数据源:模型凭证 + 知识库
const [credentials, setCredentials] = useState<Credential[]>([]);
// 下拉数据源:模型资源 + 知识库
const [modelResources, setModelResources] = useState<ModelResource[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
// 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换
const [view, setView] = useState<View>(() => {
@@ -322,6 +336,7 @@ export function AssistantPage(props: AssistantPageProps) {
});
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
const [currentPage, setCurrentPage] = useState(1);
// choose 步骤的草稿:名称与已选类型,确认后直接建库并进入编辑页
// (工作流占位页也用它展示名称与类型)
@@ -352,14 +367,14 @@ export function AssistantPage(props: AssistantPageProps) {
void loadAssistants();
}, [props.mode, loadAssistants]);
// 进入创建/编辑前加载下拉数据源(模型凭证 + 知识库)
// 进入创建/编辑前加载下拉数据源(模型资源 + 知识库)
const loadResources = useCallback(async () => {
try {
const [creds, kbs] = await Promise.all([
credentialsApi.list(),
modelResourcesApi.list(),
knowledgeBasesApi.list(),
]);
setCredentials(creds);
setModelResources(creds);
setKnowledgeBases(kbs);
} catch {
// 拉取失败时下拉为空,不阻塞表单
@@ -374,9 +389,9 @@ export function AssistantPage(props: AssistantPageProps) {
}, [props.mode, loadResources]);
// 按资源类型生成 {value:id, label:name} 选项
const credOptions = (type: Credential["type"]) =>
credentials
.filter((c) => c.type === type)
const credOptions = (type: ModelResource["capability"]) =>
modelResources
.filter((c) => c.capability === type)
.map((c) => ({ value: c.id, label: c.name }));
const kbOptions = knowledgeBases.map((k) => ({ value: k.id, label: k.name }));
@@ -384,7 +399,7 @@ export function AssistantPage(props: AssistantPageProps) {
router.push("/assistants/new");
}
// 把后端 Assistant 回填进提示词表单(注意:model/asr/voice 等存的是凭证 id)
// 把后端 Assistant 回填进提示词表单(model/asr/voice 等存模型资源 id)
// 返回回填后的表单,供调用方记录"已保存基线"
function fillPromptForm(a: Assistant): AssistantForm {
const next: AssistantForm = {
@@ -392,10 +407,10 @@ export function AssistantPage(props: AssistantPageProps) {
greeting: a.greeting,
prompt: a.prompt,
runtimeMode: a.runtimeMode,
realtimeModel: a.realtimeCredentialId ?? "",
model: a.llmCredentialId ?? "",
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
realtimeModel: a.modelResourceIds.Realtime ?? "",
model: a.modelResourceIds.LLM ?? "",
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
knowledgeBase: a.knowledgeBaseId ?? "",
enableInterrupt: a.enableInterrupt,
};
@@ -458,10 +473,7 @@ export function AssistantPage(props: AssistantPageProps) {
runtimeMode: "pipeline",
greeting: "",
enableInterrupt: true,
llmCredentialId: null,
asrCredentialId: null,
ttsCredentialId: null,
realtimeCredentialId: null,
modelResourceIds: {},
knowledgeBaseId: null,
prompt: "",
apiUrl: "",
@@ -511,10 +523,12 @@ export function AssistantPage(props: AssistantPageProps) {
runtimeMode: form.runtimeMode,
greeting: form.greeting,
enableInterrupt: form.enableInterrupt,
llmCredentialId: form.model || null,
asrCredentialId: form.asr || null,
ttsCredentialId: form.voice || null,
realtimeCredentialId: form.realtimeModel || null,
modelResourceIds: {
...(form.model ? { LLM: form.model } : {}),
...(form.asr ? { ASR: form.asr } : {}),
...(form.voice ? { TTS: form.voice } : {}),
...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}),
},
knowledgeBaseId: form.knowledgeBase || null,
prompt: form.prompt,
}),
@@ -528,8 +542,8 @@ export function AssistantPage(props: AssistantPageProps) {
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
enableInterrupt: a.enableInterrupt,
};
setDifyForm(next);
@@ -542,8 +556,10 @@ export function AssistantPage(props: AssistantPageProps) {
name: difyForm.name.trim(),
type: "dify",
enableInterrupt: difyForm.enableInterrupt,
asrCredentialId: difyForm.asr || null,
ttsCredentialId: difyForm.voice || null,
modelResourceIds: {
...(difyForm.asr ? { ASR: difyForm.asr } : {}),
...(difyForm.voice ? { TTS: difyForm.voice } : {}),
},
apiUrl: difyForm.apiUrl,
apiKey: difyForm.apiKey,
}),
@@ -558,8 +574,8 @@ export function AssistantPage(props: AssistantPageProps) {
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
enableInterrupt: a.enableInterrupt,
};
setFastGptForm(next);
@@ -572,8 +588,10 @@ export function AssistantPage(props: AssistantPageProps) {
name: fastGptForm.name.trim(),
type: "fastgpt",
enableInterrupt: fastGptForm.enableInterrupt,
asrCredentialId: fastGptForm.asr || null,
ttsCredentialId: fastGptForm.voice || null,
modelResourceIds: {
...(fastGptForm.asr ? { ASR: fastGptForm.asr } : {}),
...(fastGptForm.voice ? { TTS: fastGptForm.voice } : {}),
},
appId: fastGptForm.appId,
apiUrl: fastGptForm.apiUrl,
apiKey: fastGptForm.apiKey,
@@ -589,9 +607,9 @@ export function AssistantPage(props: AssistantPageProps) {
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
model: a.llmCredentialId ?? "",
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
model: a.modelResourceIds.LLM ?? "",
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
enableInterrupt: a.enableInterrupt,
};
setOpenCodeForm(next);
@@ -642,9 +660,11 @@ export function AssistantPage(props: AssistantPageProps) {
name: openCodeForm.name.trim(),
type: "opencode",
enableInterrupt: openCodeForm.enableInterrupt,
llmCredentialId: openCodeForm.model || null,
asrCredentialId: openCodeForm.asr || null,
ttsCredentialId: openCodeForm.voice || null,
modelResourceIds: {
...(openCodeForm.model ? { LLM: openCodeForm.model } : {}),
...(openCodeForm.asr ? { ASR: openCodeForm.asr } : {}),
...(openCodeForm.voice ? { TTS: openCodeForm.voice } : {}),
},
prompt: openCodeForm.prompt,
apiUrl: openCodeForm.apiUrl,
apiKey: openCodeForm.apiKey,
@@ -673,6 +693,7 @@ export function AssistantPage(props: AssistantPageProps) {
name: a.name,
type: typeToLabel[a.type],
updatedAt: formatTimestamp(a.updatedAt),
updatedAtRaw: a.updatedAt,
}));
const filteredAssistants = listItems.filter((assistant) => {
if (typeFilter !== "全部" && assistant.type !== typeFilter) {
@@ -691,12 +712,23 @@ export function AssistantPage(props: AssistantPageProps) {
.includes(keyword);
});
// 按更新时间排序(以原始 ISO 时间戳为准);缺失时间戳视为最早,id 作为稳定的次级排序键
const timeValue = (iso: string | null | undefined) => {
const t = iso ? new Date(iso).getTime() : NaN;
return Number.isNaN(t) ? 0 : t;
};
const sortedAssistants = [...filteredAssistants].sort((a, b) => {
const diff = timeValue(b.updatedAtRaw) - timeValue(a.updatedAtRaw);
if (diff !== 0) return sortOrder === "newest" ? diff : -diff;
return a.id.localeCompare(b.id);
});
const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(filteredAssistants.length / pageSize));
const totalPages = Math.max(1, Math.ceil(sortedAssistants.length / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStart = (safeCurrentPage - 1) * pageSize;
const pageEnd = pageStart + pageSize;
const paginatedAssistants = filteredAssistants.slice(pageStart, pageEnd);
const paginatedAssistants = sortedAssistants.slice(pageStart, pageEnd);
function handleSearchChange(value: string) {
setSearchQuery(value);
@@ -708,6 +740,15 @@ export function AssistantPage(props: AssistantPageProps) {
setCurrentPage(1);
}
function handleSortChange(order: SortOrder) {
setSortOrder(order);
setCurrentPage(1);
}
function toggleSortOrder() {
handleSortChange(sortOrder === "newest" ? "oldest" : "newest");
}
function updateForm<K extends keyof AssistantForm>(
key: K,
value: AssistantForm[K],
@@ -778,103 +819,125 @@ export function AssistantPage(props: AssistantPageProps) {
if (view === "list") {
return (
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<div className="flex flex-col items-start justify-between gap-5 sm:flex-row sm:gap-6">
<div>
<h1 className="font-display display-lg text-ink"></h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
Dify FastGPT
</p>
</div>
<Button
size="lg"
className="w-full shrink-0 gap-2 sm:w-auto"
onClick={startCreate}
>
<Plus size={16} />
</Button>
</div>
<PageHeader
title="助手列表"
description="管理已有的视频助手支持提示词、工作流、Dify 和 FastGPT 类型。"
action={
<Button
size="lg"
className="w-full gap-2 sm:w-auto"
onClick={startCreate}
>
<Plus size={16} />
</Button>
}
/>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap items-center gap-2">
{typeFilters.map((filter) => (
<Button
key={filter}
variant={filter === typeFilter ? "default" : "outline"}
size="sm"
className={
filter === typeFilter
? "rounded-full"
: "rounded-full border-hairline-strong text-muted-foreground hover:text-foreground"
}
onClick={() => handleFilterChange(filter)}
>
{filter}
</Button>
))}
</div>
<div className="relative w-full lg:w-[320px]">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-soft"
<ListToolbar
filters={
<FilterPills
options={typeFilters}
value={typeFilter}
onChange={handleFilterChange}
/>
<Input
}
search={
<SearchInput
value={searchQuery}
onChange={(event) => handleSearchChange(event.target.value)}
className="h-10 border-hairline-strong bg-background pl-9 text-sm text-foreground placeholder:text-muted-soft"
onChange={handleSearchChange}
placeholder="搜索助手名称、类型或 ID..."
className="lg:w-[320px]"
/>
</div>
</div>
}
/>
<div className="overflow-hidden rounded-xl border border-hairline">
<div className="hidden items-center gap-4 bg-surface-strong/60 px-5 py-3 md:flex">
<div className="caption-label flex-1 text-muted-soft">
</div>
<div className="caption-label w-[110px] text-muted-soft">
</div>
<div className="caption-label w-[150px] text-muted-soft">
</div>
<div className="caption-label w-[116px] text-right text-muted-soft">
</div>
</div>
<div className="divide-y divide-hairline">
{paginatedAssistants.map((assistant) => (
<div
key={assistant.id}
className="flex flex-col gap-3 px-5 py-4 text-sm transition-colors hover:bg-surface-strong/40 md:flex-row md:items-center md:gap-4"
>
<div className="min-w-0 flex-1">
<DataList<AssistantListItem>
rows={paginatedAssistants}
rowKey={(assistant) => assistant.id}
loading={listLoading}
loadingText="正在加载助手列表…"
error={listError}
onRetry={() => void loadAssistants()}
empty={{
title: listItems.length === 0 ? "暂无助手" : "未找到匹配的助手",
description:
listItems.length === 0
? "点击右上角「创建助手」开始。"
: "请调整关键词或筛选条件后再试。",
}}
pagination={{
page: safeCurrentPage,
totalPages,
onPageChange: setCurrentPage,
summary:
filteredAssistants.length === 0
? "没有数据"
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredAssistants.length)} / 共 ${filteredAssistants.length} 个助手`,
}}
columns={[
{
key: "name",
header: "助手名称",
width: "md:w-[360px]",
cell: (assistant) => (
<>
<div className="truncate font-medium text-foreground">
{assistant.name}
</div>
<div className="mt-1 text-xs text-muted-soft">
{assistant.id}
</div>
</div>
<div className="md:w-[110px]">
<Badge
variant="secondary"
className="h-6 bg-surface-strong px-3 text-muted-foreground"
>
{assistant.type}
</Badge>
</div>
<div className="text-muted-foreground md:w-[150px]">
{assistant.updatedAt}
</div>
<div className="flex justify-end gap-2 md:w-[116px]">
</>
),
},
{
key: "type",
header: "助手类型",
width: "md:w-[128px]",
cell: (assistant) => (
<Badge
variant="secondary"
className="h-6 bg-surface-strong px-3 text-muted-foreground"
>
{assistant.type}
</Badge>
),
},
{
key: "updatedAt",
width: "md:w-[176px]",
header: (
<button
type="button"
onClick={toggleSortOrder}
className="caption-label -mx-2 inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
aria-label={
sortOrder === "newest"
? "当前按最近更新排序,点击切换为最早更新"
: "当前按最早更新排序,点击切换为最近更新"
}
>
{sortOrder === "newest" ? (
<ChevronDown size={13} />
) : (
<ChevronUp size={13} />
)}
</button>
),
cellClassName:
"whitespace-nowrap tabular-nums text-muted-foreground",
cell: (assistant) => assistant.updatedAt,
},
{
key: "actions",
header: "操作",
align: "right",
cellClassName: "flex justify-end gap-2",
cell: (assistant) => (
<>
<Button
variant="outline"
size="sm"
@@ -920,103 +983,11 @@ export function AssistantPage(props: AssistantPageProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
{listLoading && (
<div className="flex items-center justify-center gap-2 px-5 py-12 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!listLoading && listError && (
<div className="px-5 py-12 text-center">
<div className="font-medium text-destructive"></div>
<div className="mt-2 text-sm text-muted-foreground">
{listError}
</div>
<Button
variant="outline"
size="sm"
className="mt-4 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => void loadAssistants()}
>
</Button>
</div>
)}
{!listLoading && !listError && filteredAssistants.length === 0 && (
<div className="px-5 py-12 text-center">
<div className="font-medium text-foreground">
{listItems.length === 0
? "暂无助手"
: "未找到匹配的助手"}
</div>
<div className="mt-2 text-sm text-muted-foreground">
{listItems.length === 0
? "点击右上角「创建助手」开始。"
: "请调整关键词或筛选条件后再试。"}
</div>
</div>
)}
</div>
</div>
<div className="mt-5 flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div>
{filteredAssistants.length === 0
? "没有数据"
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredAssistants.length)} / 共 ${filteredAssistants.length} 个助手`}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={safeCurrentPage <= 1}
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
aria-label="上一页"
>
<ChevronLeft size={15} />
</Button>
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
(page) => (
<Button
key={page}
variant={page === safeCurrentPage ? "default" : "outline"}
size="sm"
className={[
"h-8 min-w-8 px-2",
page === safeCurrentPage
? ""
: "border-hairline-strong text-muted-foreground hover:text-foreground",
].join(" ")}
onClick={() => setCurrentPage(page)}
>
{page}
</Button>
</>
),
)}
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={safeCurrentPage >= totalPages}
onClick={() =>
setCurrentPage((page) => Math.min(totalPages, page + 1))
}
aria-label="下一页"
>
<ChevronRight size={15} />
</Button>
</div>
</div>
},
]}
/>
</section>
</div>
);
@@ -1744,6 +1715,10 @@ const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] =
{ style: "wave", label: "波形", icon: <Waves size={14} /> },
];
// 中央语音可视化(光环/星云/频谱/波形)暂时隐藏:调试面板固定为
// 「上聊天记录 + 下波形监控」布局。置 true 可恢复可视化视图与样式切换。
const SHOW_VOICE_VIZ = false;
function SegmentedIconGroup({
children,
label,
@@ -1800,38 +1775,40 @@ function DebugDrawer({ assistantId }: { assistantId: string | null }) {
<aside className="hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex">
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
<div className="text-sm font-medium text-foreground"></div>
<div className="flex items-center gap-2">
{!showTranscript && (
<SegmentedIconGroup label="可视化样式">
{VIZ_OPTIONS.map((option) => (
<SegmentedIconButton
key={option.style}
selected={vizStyle === option.style}
label={`可视化样式:${option.label}`}
onClick={() => setVizStyle(option.style)}
>
{option.icon}
</SegmentedIconButton>
))}
{SHOW_VOICE_VIZ && (
<div className="flex items-center gap-2">
{!showTranscript && (
<SegmentedIconGroup label="可视化样式">
{VIZ_OPTIONS.map((option) => (
<SegmentedIconButton
key={option.style}
selected={vizStyle === option.style}
label={`可视化样式:${option.label}`}
onClick={() => setVizStyle(option.style)}
>
{option.icon}
</SegmentedIconButton>
))}
</SegmentedIconGroup>
)}
<SegmentedIconGroup label="预览视图">
<SegmentedIconButton
selected={!showTranscript}
label="语音可视化视图"
onClick={() => setShowTranscript(false)}
>
<Mic size={14} />
</SegmentedIconButton>
<SegmentedIconButton
selected={showTranscript}
label="文字聊天记录视图"
onClick={() => setShowTranscript(true)}
>
<MessageSquareText size={14} />
</SegmentedIconButton>
</SegmentedIconGroup>
)}
<SegmentedIconGroup label="预览视图">
<SegmentedIconButton
selected={!showTranscript}
label="语音可视化视图"
onClick={() => setShowTranscript(false)}
>
<Mic size={14} />
</SegmentedIconButton>
<SegmentedIconButton
selected={showTranscript}
label="文字聊天记录视图"
onClick={() => setShowTranscript(true)}
>
<MessageSquareText size={14} />
</SegmentedIconButton>
</SegmentedIconGroup>
</div>
</div>
)}
</div>
<DebugVoicePanel
@@ -1881,8 +1858,21 @@ function DebugVoicePanel({
<div className="flex min-h-0 flex-1 flex-col">
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
<audio ref={audioRef} autoPlay playsInline className="hidden" />
{showTranscript ? (
<DebugTranscriptPanel messages={messages} recording={recording} />
{!SHOW_VOICE_VIZ || showTranscript ? (
<>
<DebugTranscriptPanel messages={messages} recording={recording} />
<VoiceSessionControls
status={status}
error={error}
micWarning={micWarning}
assistantId={assistantId}
audioInputs={audioInputs}
selectedDeviceId={selectedDeviceId}
setSelectedDeviceId={setSelectedDeviceId}
connect={connect}
disconnect={disconnect}
/>
</>
) : (
<div className="relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-y-auto px-6 py-3 text-center">
<div
@@ -2045,6 +2035,123 @@ function DebugVoicePanel({
);
}
// 会话控制条:状态 + 麦克风选择 + 开始/结束按钮。
// 原本这些控件在中央可视化视图里,可视化隐藏后(SHOW_VOICE_VIZ=false)集中到这一条。
function VoiceSessionControls({
status,
error,
micWarning,
assistantId,
audioInputs,
selectedDeviceId,
setSelectedDeviceId,
connect,
disconnect,
}: {
status: VoicePreviewStatus;
error: string | null;
micWarning: string | null;
assistantId: string | null;
audioInputs: MediaDeviceInfo[];
selectedDeviceId: string;
setSelectedDeviceId: (deviceId: string) => void;
connect: () => Promise<void>;
disconnect: () => void;
}) {
const recording = status === "connecting" || status === "connected";
const hint =
status === "failed"
? error || "连接失败,请确认后端已启动且助手已保存后重试。"
: !assistantId
? "请先保存助手,再开始语音预览。"
: micWarning
? `${micWarning} 可接收助手播报,但无法发送语音。`
: null;
return (
<div className="shrink-0 border-t border-hairline px-3 py-2.5">
<div className="flex items-center gap-2">
<span
className={[
"h-1.5 w-1.5 shrink-0 rounded-full",
recording
? "animate-pulse bg-success"
: status === "failed"
? "bg-destructive"
: "bg-muted-soft",
].join(" ")}
/>
<span className="shrink-0 text-xs text-muted-foreground">
{status === "connecting"
? "连接中…"
: status === "connected"
? micWarning
? "仅收听"
: "进行中"
: status === "failed"
? "连接失败"
: "准备开始"}
</span>
<Select
value={selectedDeviceId || "default"}
onValueChange={(value) =>
setSelectedDeviceId(value === "default" ? "" : value)
}
disabled={recording}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 gap-2 rounded-full border-hairline bg-canvas-soft text-xs text-muted-foreground"
aria-label="选择麦克风"
>
<Mic size={13} className="shrink-0 text-muted-soft" />
<SelectValue placeholder="默认麦克风" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
{audioInputs.map((device, index) => (
<SelectItem key={device.deviceId} value={device.deviceId}>
{device.label || `麦克风 ${index + 1}`}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
disabled={!assistantId || status === "connecting"}
onClick={() => {
if (recording) {
disconnect();
} else {
void connect();
}
}}
className={[
"shrink-0 gap-1.5 rounded-full",
recording ? "bg-destructive text-white hover:bg-destructive/90" : "",
].join(" ")}
aria-label={recording ? "结束语音测试" : "开始语音测试"}
>
{status === "connecting" ? (
<Loader2 size={14} className="animate-spin" />
) : recording ? (
<PhoneOff size={14} />
) : (
<Mic size={14} />
)}
{recording ? "结束对话" : "开始对话"}
</Button>
</div>
{hint && (
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">{hint}</p>
)}
</div>
);
}
// ISO 时间戳 → HH:MM(本地时区),解析失败返回空串
function formatMessageTime(iso: string): string {
const d = new Date(iso);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,203 @@
"use client";
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type DataListColumn<T> = {
/** 唯一键,用于 React key */
key: string;
/** 表头:字符串会自动套用 caption-label 样式;传节点则原样渲染(如可排序按钮) */
header: ReactNode;
/**
* 列宽 Tailwind 类,必须含响应式前缀以仅在桌面端生效(如 "md:w-[176px]"),
* 这样移动端为堆叠卡片、桌面端为定宽列。注意:必须是字面量字符串,
* Tailwind 才能在编译期抽取该 class。留空表示主列,占据剩余空间。
*/
width?: string;
/** 右对齐(用于"操作"等列) */
align?: "left" | "right";
/** 单元格内容 */
cell: (row: T) => ReactNode;
/** 单元格额外类名(如操作列的 "flex justify-end gap-2") */
cellClassName?: string;
};
export type DataListPagination = {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
/** 左侧统计文案,如「显示 1-5 / 共 20 个」 */
summary?: ReactNode;
};
export type DataListProps<T> = {
columns: DataListColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
/** 加载中:替换表格主体显示加载态 */
loading?: boolean;
loadingText?: string;
/** 错误信息:非空时替换主体显示错误态(优先级高于空态) */
error?: string | null;
errorTitle?: string;
onRetry?: () => void;
/** 空态文案(rows 为空且无 loading/error 时显示) */
empty?: { title: string; description?: string };
/** 行点击(可选);提供时整行可点击 */
onRowClick?: (row: T) => void;
pagination?: DataListPagination;
};
export function DataList<T>({
columns,
rows,
rowKey,
loading = false,
loadingText = "正在加载",
error,
errorTitle = "加载失败",
onRetry,
empty,
onRowClick,
pagination,
}: DataListProps<T>) {
return (
<>
<div className="overflow-hidden rounded-xl border border-hairline">
{/* 表头(移动端隐藏,桌面端为一行) */}
<div className="hidden items-center gap-4 bg-surface-strong/60 px-5 py-3 md:flex">
{columns.map((column) => (
<div
key={column.key}
className={cn(
column.width ?? "flex-1",
column.align === "right" && "text-right",
)}
>
{typeof column.header === "string" ? (
<span className="caption-label text-muted-soft">
{column.header}
</span>
) : (
column.header
)}
</div>
))}
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 px-5 py-12 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
{loadingText}
</div>
) : error ? (
<div className="px-5 py-12 text-center">
<div className="font-medium text-destructive">{errorTitle}</div>
<div className="mt-2 text-sm text-muted-foreground">{error}</div>
{onRetry && (
<Button
variant="outline"
size="sm"
className="mt-4 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={onRetry}
>
</Button>
)}
</div>
) : rows.length === 0 ? (
<div className="px-5 py-12 text-center">
<div className="font-medium text-foreground">
{empty?.title ?? "暂无数据"}
</div>
{empty?.description && (
<div className="mt-2 text-sm text-muted-foreground">
{empty.description}
</div>
)}
</div>
) : (
<div className="divide-y divide-hairline">
{rows.map((row) => (
<div
key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
className={cn(
"flex flex-col gap-3 px-5 py-4 text-sm transition-colors hover:bg-surface-strong/40 md:flex-row md:items-center md:gap-4",
onRowClick && "cursor-pointer",
)}
>
{columns.map((column) => (
<div
key={column.key}
className={cn(
column.width ?? "min-w-0 flex-1",
column.cellClassName,
)}
>
{column.cell(row)}
</div>
))}
</div>
))}
</div>
)}
</div>
{pagination && (
<div className="mt-5 flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<div>{pagination.summary}</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={pagination.page <= 1}
onClick={() => pagination.onPageChange(Math.max(1, pagination.page - 1))}
aria-label="上一页"
>
<ChevronLeft size={15} />
</Button>
{Array.from({ length: pagination.totalPages }, (_, index) => index + 1).map(
(page) => (
<Button
key={page}
variant={page === pagination.page ? "default" : "outline"}
size="sm"
className={cn(
"h-8 min-w-8 px-2",
page !== pagination.page &&
"border-hairline-strong text-muted-foreground hover:text-foreground",
)}
onClick={() => pagination.onPageChange(page)}
>
{page}
</Button>
),
)}
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
disabled={pagination.page >= pagination.totalPages}
onClick={() =>
pagination.onPageChange(
Math.min(pagination.totalPages, pagination.page + 1),
)
}
aria-label="下一页"
>
<ChevronRight size={15} />
</Button>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type FilterPillsProps<T extends string> = {
options: readonly T[];
value: T;
onChange: (value: T) => void;
className?: string;
};
/** 一行 pill 形筛选项,统一 active/inactive 样式 */
export function FilterPills<T extends string>({
options,
value,
onChange,
className,
}: FilterPillsProps<T>) {
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
{options.map((option) => {
const active = option === value;
return (
<Button
key={option}
variant={active ? "default" : "outline"}
size="sm"
className={cn(
"rounded-full",
!active &&
"border-hairline-strong text-muted-foreground hover:text-foreground",
)}
onClick={() => onChange(option)}
>
{option}
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export type ListToolbarProps = {
/** 左侧筛选区(通常是 FilterPills) */
filters?: ReactNode;
/** 右侧搜索区(通常是 SearchInput) */
search?: ReactNode;
className?: string;
};
/** 列表页工具栏布局:左筛选 / 右搜索,移动端纵向堆叠 */
export function ListToolbar({ filters, search, className }: ListToolbarProps) {
return (
<div
className={cn(
"mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between",
className,
)}
>
{filters}
{search}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export type PageHeaderProps = {
title: string;
description?: ReactNode;
/** 右侧主操作(如「创建助手」按钮) */
action?: ReactNode;
className?: string;
};
/** 列表/资源页统一页头:标题 + 说明 + 右侧操作,移动端纵向堆叠 */
export function PageHeader({
title,
description,
action,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
"flex flex-col items-start justify-between gap-5 sm:flex-row sm:gap-6",
className,
)}
>
<div>
<h1 className="font-display display-lg text-ink">{title}</h1>
{description && (
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
{description}
</p>
)}
</div>
{action && <div className="w-full shrink-0 sm:w-auto">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export type SearchInputProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** 作用于外层容器,通常用于设定宽度(如 "lg:w-[320px]") */
className?: string;
};
/** 带放大镜图标的搜索框,样式与列表页统一 */
export function SearchInput({
value,
onChange,
placeholder,
className,
}: SearchInputProps) {
return (
<div className={cn("relative w-full", className)}>
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-soft"
/>
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-10 border-hairline-strong bg-background pl-9 text-sm text-foreground placeholder:text-muted-soft"
placeholder={placeholder}
/>
</div>
);
}

View File

@@ -1,7 +1,14 @@
"use client";
import * as React from "react";
import { Activity, ChevronDown, ChevronUp } from "lucide-react";
import {
Activity,
ChevronDown,
ChevronUp,
ChevronsRight,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
@@ -12,18 +19,22 @@ import {
rgba,
} from "@/lib/visualizer-palette";
/** 每格条形代表的音频时长(ms),决定时间轴滚动节奏 */
/** 每条样本代表的音频时长(ms) */
const SAMPLE_MS = 50;
/** 条形宽度/间距(px):滚动速度 = (BAR_WIDTH+BAR_GAP) * 1000/SAMPLE_MS px/s */
const BAR_WIDTH = 2;
const BAR_GAP = 1;
const BAR_STEP = BAR_WIDTH + BAR_GAP;
/** 历史保留上限:2 分钟,超出后丢最旧的样本 */
const MAX_SAMPLES = (2 * 60 * 1000) / SAMPLE_MS;
/** 时间刻度间隔(ms) */
const TICK_MS = 5_000;
/** 每列条形宽度/间距(px) */
const COL_WIDTH = 2;
const COL_GAP = 1;
const COL_STEP = COL_WIDTH + COL_GAP;
/** 缩放档位:每列聚合的时长(ms)。50 = 原始精度,越大看到的时间范围越长 */
const ZOOM_LEVELS_MS_PER_COL = [50, 100, 200, 400, 800, 1600, 3200];
/** 时间刻度候选间隔(ms),按缩放挑选不至于过密的一档 */
const TICK_STEPS_MS = [1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000];
/** 历史保留上限:10 分钟,超出后丢最旧的样本 */
const MAX_SAMPLES = (10 * 60 * 1000) / SAMPLE_MS;
/** 顶部时间轴高度(px) */
const AXIS_HEIGHT = 16;
/** 左侧轨道标签栏宽度(px),波形不会画进这里 */
const LABEL_GUTTER = 40;
type History = {
/** 每 SAMPLE_MS 一条的 RMS 强度(0~1),user/agent 等长同步推进 */
@@ -40,7 +51,10 @@ function makeHistory(): History {
}
/** 当前时域 RMS 强度(0~1);放大系数与 WaveVisualizer 一致,让小音量也可见 */
function rmsLevel(node: AnalyserNode | null, buf: Uint8Array<ArrayBuffer>): number {
function rmsLevel(
node: AnalyserNode | null,
buf: Uint8Array<ArrayBuffer>,
): number {
if (!node) return 0;
node.getByteTimeDomainData(buf);
let sum = 0;
@@ -70,9 +84,10 @@ export type WaveformTimelineProps = {
};
/**
* 双轨波形时间轴:上轨「我」(麦克风)、下轨「助手」(远端音频),
* 按固定节拍采样 RMS 音量,最新样本贴右缘向左滚动,顶部带 m:ss 时间刻度
* 配色取自设计 token(--gradient-*),自动跟随明暗主题。
* 双轨波形时间轴:上轨「我」(麦克风)、下轨「助手」(远端音频)
* 按固定节拍采样 RMS 音量;跟随模式下最新样本贴右缘滚动
* 交互:拖拽 / 滚轮平移回看历史,Ctrl(⌘)+滚轮或右上按钮缩放,
* 回看时出现「回到最新」按钮恢复跟随。配色取自设计 token,自动跟随主题。
*/
export function WaveformTimeline({
userStream,
@@ -84,6 +99,18 @@ export function WaveformTimeline({
const historyRef = React.useRef<History>(makeHistory());
const activeRef = React.useRef(active);
// 视窗状态:缩放档位用 state 驱动按钮 UI,平移量/跟随标志放 ref 供绘制帧读取
const [zoomIdx, setZoomIdx] = React.useState(0);
const [following, setFollowing] = React.useState(true);
const zoomIdxRef = React.useRef(zoomIdx);
const followingRef = React.useRef(following);
/** 距「最新样本」回看了多少毫秒,0 = 跟随直播边缘 */
const offsetMsRef = React.useRef(0);
React.useEffect(() => {
zoomIdxRef.current = zoomIdx;
}, [zoomIdx]);
// active 传 stream 是否存在,避免 useAudioAnalyser 在缺流时去申请麦克风
const userAnalyserRef = useAudioAnalyser({
active: active && Boolean(userStream),
@@ -96,13 +123,74 @@ export function WaveformTimeline({
smoothingTimeConstant: 0.5,
});
// 新会话开始时清空上一轮历史
React.useEffect(() => {
activeRef.current = active;
if (active) {
historyRef.current = makeHistory();
}
}, [active]);
// 上一帧的 active,绘制循环里用它检测「新会话开始」并清空历史
const wasActiveRef = React.useRef(false);
/** 平移 deltaMs(正 = 回看更早);移动后按是否贴回右缘更新跟随态 */
const panBy = React.useCallback((deltaMs: number) => {
const next = Math.max(0, offsetMsRef.current + deltaMs);
offsetMsRef.current = next;
const follow = next <= 0;
followingRef.current = follow;
setFollowing(follow);
}, []);
const backToLive = React.useCallback(() => {
offsetMsRef.current = 0;
followingRef.current = true;
setFollowing(true);
}, []);
const zoomBy = React.useCallback((delta: number) => {
setZoomIdx((idx) =>
Math.min(ZOOM_LEVELS_MS_PER_COL.length - 1, Math.max(0, idx + delta)),
);
}, []);
// 滚轮:平移;Ctrl/⌘+滚轮:缩放。需要 preventDefault,所以手动挂非 passive 监听
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
zoomBy(e.deltaY > 0 ? 1 : -1);
return;
}
const msPerPx = ZOOM_LEVELS_MS_PER_COL[zoomIdxRef.current] / COL_STEP;
panBy(-(e.deltaX || e.deltaY) * msPerPx);
};
canvas.addEventListener("wheel", onWheel, { passive: false });
return () => canvas.removeEventListener("wheel", onWheel);
}, [panBy, zoomBy]);
// 拖拽平移
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.setPointerCapture(e.pointerId);
let lastX = e.clientX;
const onMove = (ev: PointerEvent) => {
const dx = ev.clientX - lastX;
lastX = ev.clientX;
const msPerPx = ZOOM_LEVELS_MS_PER_COL[zoomIdxRef.current] / COL_STEP;
panBy(dx * msPerPx);
};
const onUp = () => {
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
};
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
};
React.useEffect(() => {
const canvas = canvasRef.current;
@@ -130,6 +218,15 @@ export function WaveformTimeline({
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// 新会话开始:清空上一轮历史并恢复跟随
if (activeRef.current && !wasActiveRef.current) {
historyRef.current = makeHistory();
offsetMsRef.current = 0;
followingRef.current = true;
setFollowing(true);
}
wasActiveRef.current = activeRef.current;
// 采样:按固定节拍推入历史,帧率波动时补齐;长时间空窗(面板折叠)则跳过
const hist = historyRef.current;
if (activeRef.current) {
@@ -151,23 +248,41 @@ export function WaveformTimeline({
const textColor = getComputedStyle(canvas).color;
const rowH = (h - AXIS_HEIGHT) / 2;
const n = hist.user.length;
const ticksEvery = TICK_MS / SAMPLE_MS;
// 视窗换算:右缘时间 = 最新时间 - 回看偏移,可见范围由缩放决定
const msPerCol = ZOOM_LEVELS_MS_PER_COL[zoomIdxRef.current];
const plotW = w - LABEL_GUTTER;
const startMs = hist.dropped * SAMPLE_MS; // 仍保留的最旧样本时间
const totalMs = (hist.dropped + n) * SAMPLE_MS; // 最新样本时间
const visibleMs = (plotW / COL_STEP) * msPerCol;
const maxOffset = Math.max(0, totalMs - startMs - visibleMs);
if (followingRef.current) offsetMsRef.current = 0;
else offsetMsRef.current = Math.min(offsetMsRef.current, maxOffset);
const rightMs = totalMs - offsetMsRef.current;
ctx.font = '10px "Inter", system-ui, sans-serif';
ctx.textBaseline = "middle";
// 时间刻度:竖向网格线 + 顶部 m:ss 标签
// 时间刻度:挑一档画出来不至于过密的间隔
const tickMs =
TICK_STEPS_MS.find((s) => (s / msPerCol) * COL_STEP >= 56) ??
TICK_STEPS_MS[TICK_STEPS_MS.length - 1];
ctx.textAlign = "center";
for (let i = 0; i < n; i++) {
const sampleIndex = hist.dropped + i;
if (sampleIndex % ticksEvery !== 0) continue;
const x = w - (n - i) * BAR_STEP;
if (x < 0) continue;
const leftMs = rightMs - visibleMs;
for (
let t = Math.ceil(Math.max(leftMs, 0) / tickMs) * tickMs;
t <= rightMs;
t += tickMs
) {
const x = w - ((rightMs - t) / msPerCol) * COL_STEP;
if (x < LABEL_GUTTER + 1) continue;
ctx.fillStyle = textColor;
ctx.globalAlpha = 0.12;
ctx.fillRect(x, AXIS_HEIGHT, 1, h - AXIS_HEIGHT);
ctx.globalAlpha = 0.75;
ctx.fillText(formatTick(sampleIndex * SAMPLE_MS), Math.max(14, x), AXIS_HEIGHT / 2);
if (x >= LABEL_GUTTER + 14 && x <= w - 14) {
ctx.globalAlpha = 0.75;
ctx.fillText(formatTick(t), x, AXIS_HEIGHT / 2);
}
}
const rows = [
@@ -178,22 +293,31 @@ export function WaveformTimeline({
rows.forEach((row, r) => {
const cy = AXIS_HEIGHT + rowH * r + rowH / 2;
// 中线
// 中线(只画在绘图区)
ctx.globalAlpha = 1;
ctx.fillStyle = rgba(row.color, 0.28);
ctx.fillRect(0, cy - 0.5, w, 1);
ctx.fillRect(LABEL_GUTTER, cy - 0.5, plotW, 1);
// 音量条:最新样本贴右缘,向左回溯到画布边界为止
// 音量条:每列聚合 [t-msPerCol, t) 内样本的峰值,从右缘往左铺
ctx.fillStyle = rgba(row.color, 0.9);
const maxBarH = rowH * 0.86;
for (let i = n - 1; i >= 0; i--) {
const x = w - (n - i) * BAR_STEP;
if (x + BAR_WIDTH < 0) break;
const bh = Math.max(1.5, row.levels[i] * maxBarH);
ctx.fillRect(x, cy - bh / 2, BAR_WIDTH, bh);
for (let c = 0; ; c++) {
const x = w - (c + 1) * COL_STEP;
if (x + COL_WIDTH <= LABEL_GUTTER) break;
const t1 = rightMs - c * msPerCol;
const t0 = t1 - msPerCol;
const i0 = Math.max(0, Math.ceil((t0 - startMs) / SAMPLE_MS));
const i1 = Math.min(n - 1, Math.ceil((t1 - startMs) / SAMPLE_MS) - 1);
if (i1 < 0 || i0 > n - 1 || i0 > i1) continue;
let level = 0;
for (let i = i0; i <= i1; i++) {
if (row.levels[i] > level) level = row.levels[i];
}
const bh = Math.max(1.5, level * maxBarH);
ctx.fillRect(x, cy - bh / 2, COL_WIDTH, bh);
}
// 轨道标签
// 轨道标签:画在左侧 gutter 内,不与波形重叠
ctx.globalAlpha = 0.85;
ctx.fillStyle = textColor;
ctx.textAlign = "left";
@@ -201,6 +325,10 @@ export function WaveformTimeline({
ctx.textAlign = "center";
});
// gutter 与绘图区的分隔线
ctx.globalAlpha = 0.15;
ctx.fillStyle = textColor;
ctx.fillRect(LABEL_GUTTER - 1, AXIS_HEIGHT, 1, h - AXIS_HEIGHT);
ctx.globalAlpha = 1;
};
@@ -209,12 +337,64 @@ export function WaveformTimeline({
}, [userAnalyserRef, agentAnalyserRef]);
return (
<canvas
ref={canvasRef}
role="img"
aria-label="用户与助手语音波形时间轴"
className={cn("block select-none text-muted-foreground", className)}
/>
<div className={cn("relative", className)}>
<canvas
ref={canvasRef}
role="img"
aria-label="用户与助手语音波形时间轴"
onPointerDown={onPointerDown}
style={{ touchAction: "none" }}
className="block h-full w-full cursor-grab select-none text-muted-foreground active:cursor-grabbing"
/>
{/* 浮动控制:回到最新 / 缩放 */}
<div className="absolute right-1 top-0 flex items-center gap-1">
{!following && (
<TimelineControlButton label="回到最新" onClick={backToLive}>
<ChevronsRight size={12} />
</TimelineControlButton>
)}
<TimelineControlButton
label="缩小(查看更长时间)"
disabled={zoomIdx >= ZOOM_LEVELS_MS_PER_COL.length - 1}
onClick={() => zoomBy(1)}
>
<ZoomOut size={12} />
</TimelineControlButton>
<TimelineControlButton
label="放大(查看更多细节)"
disabled={zoomIdx <= 0}
onClick={() => zoomBy(-1)}
>
<ZoomIn size={12} />
</TimelineControlButton>
</div>
</div>
);
}
function TimelineControlButton({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className="flex h-5 w-5 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-soft transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
>
{children}
</button>
);
}