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:
203
frontend/src/components/ui/data-list.tsx
Normal file
203
frontend/src/components/ui/data-list.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/ui/filter-pills.tsx
Normal file
42
frontend/src/components/ui/filter-pills.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/ui/list-toolbar.tsx
Normal file
26
frontend/src/components/ui/list-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/ui/page-header.tsx
Normal file
39
frontend/src/components/ui/page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/ui/search-input.tsx
Normal file
37
frontend/src/components/ui/search-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user