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:
@@ -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
Reference in New Issue
Block a user