Files
ai-video-fullstack/frontend/src/components/pages/AssistantPage.tsx
T

2237 lines
73 KiB
TypeScript
Raw Normal View History

"use client";
import {
Bot,
Boxes,
Brain,
Check,
Copy,
Database,
Eye,
EyeOff,
MessageSquareText,
MoreHorizontal,
Pencil,
Plus,
Rocket,
Search,
Sparkles,
Trash2,
Workflow,
ChevronLeft,
ChevronRight,
Save,
Mic,
Send,
HelpCircle,
Waypoints,
AudioLines,
Terminal,
Loader2,
PhoneOff,
Orbit,
Waves,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
import { WaveVisualizer } from "@/components/ui/wave-visualizer";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useCallback, useEffect, useRef, useState } from "react";
import {
assistantsApi,
credentialsApi,
knowledgeBasesApi,
type Assistant,
type AssistantType as ApiAssistantType,
type AssistantUpsert,
type Credential,
type KnowledgeBase,
} from "@/lib/api";
type RuntimeMode = "pipeline" | "realtime";
type AssistantForm = {
name: string;
greeting: string;
prompt: string;
runtimeMode: RuntimeMode;
realtimeModel: string;
model: string;
asr: string;
voice: string;
knowledgeBase: string;
enableInterrupt: boolean;
};
type FastGptForm = {
name: string;
appId: string;
apiUrl: string;
apiKey: string;
asr: string;
voice: string;
enableInterrupt: boolean;
};
type DifyForm = {
name: string;
apiUrl: string;
apiKey: string;
asr: string;
voice: string;
enableInterrupt: boolean;
};
type OpenCodeForm = {
name: string;
prompt: string;
apiUrl: string;
apiKey: string;
model: string;
asr: string;
voice: string;
enableInterrupt: boolean;
};
type AssistantType = "提示词" | "工作流" | "Dify" | "FastGPT" | "OpenCode";
const assistantTypes: AssistantType[] = [
"提示词",
"工作流",
"Dify",
"FastGPT",
"OpenCode",
];
// 后端 type(英文) ↔ 列表展示标签(中文)
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
prompt: "提示词",
workflow: "工作流",
dify: "Dify",
fastgpt: "FastGPT",
opencode: "OpenCode",
};
function formatTimestamp(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`;
}
type AssistantTypeOption = {
type: AssistantType;
label: string;
description: string;
icon: React.ReactNode;
/** 提示词、Dify、FastGPT 类型已落地,工作流暂时显示占位页 */
available: boolean;
};
const assistantTypeOptions: AssistantTypeOption[] = [
{
type: "提示词",
label: "使用提示词构建",
description: "通过提示词、模型与语音快速搭建对话助手,适合大多数场景。",
icon: <MessageSquareText size={20} />,
available: true,
},
{
type: "工作流",
label: "使用工作流构建",
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
icon: <Workflow size={20} />,
available: false,
},
{
type: "Dify",
label: "使用 Dify 构建",
description: "对接 Dify 应用,复用其编排能力与知识库配置。",
icon: <Boxes size={20} />,
available: true,
},
{
type: "FastGPT",
label: "使用 FastGPT 构建",
description: "对接 FastGPT 应用,复用其知识库问答与工作流能力。",
icon: <Database size={20} />,
available: true,
},
{
type: "OpenCode",
label: "使用 OpenCode 构建",
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
icon: <Terminal size={20} />,
available: true,
},
];
type AssistantListItem = {
id: string;
name: string;
type: AssistantType;
updatedAt: string;
};
type TypeFilter = "全部" | AssistantType;
const typeFilters: TypeFilter[] = ["全部", ...assistantTypes];
export function AssistantPage() {
const [form, setForm] = useState<AssistantForm>({
name: "政务视频咨询助手",
greeting: "您好,我是 AI 视频助手,请问有什么可以帮您?",
prompt:
"你是一名专业的政务视频咨询助手,负责为市民提供政策解读、办事指南和常见问题解答。\n\n请遵循以下原则:\n1. 回答准确、简洁,使用通俗易懂的语言\n2. 不确定的信息应明确告知,不编造政策内容\n3. 涉及具体办事流程时,引导用户前往官方渠道核实",
runtimeMode: "pipeline",
realtimeModel: "",
model: "",
asr: "",
voice: "",
knowledgeBase: "",
enableInterrupt: true,
});
const [fastGptForm, setFastGptForm] = useState<FastGptForm>({
name: "FastGPT 售后咨询助手",
appId: "",
apiUrl: "https://api.fastgpt.in/api/v1/chat/completions",
apiKey: "",
asr: "",
voice: "",
enableInterrupt: true,
});
const [difyForm, setDifyForm] = useState<DifyForm>({
name: "Dify 知识库问答助手",
apiUrl: "https://api.dify.ai/v1/chat-messages",
apiKey: "",
asr: "",
voice: "",
enableInterrupt: true,
});
const [openCodeForm, setOpenCodeForm] = useState<OpenCodeForm>({
name: "OpenCode 代码助手",
prompt:
"你是一个代码助手的语音交互界面,请用简洁、口语化的方式回答用户关于代码与工程的问题。",
apiUrl: "http://localhost:4096",
apiKey: "",
model: "",
asr: "",
voice: "",
enableInterrupt: true,
});
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [listLoading, setListLoading] = useState(true);
const [listError, setListError] = useState<string | null>(null);
// 编辑中的助手 id;null = 新建
const [editingId, setEditingId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// 下拉数据源:模型凭证 + 知识库
const [credentials, setCredentials] = useState<Credential[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [view, setView] = useState<
| "list"
| "choose"
| "create"
| "create-dify"
| "create-fastgpt"
| "create-opencode"
| "placeholder"
>("list");
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
const [currentPage, setCurrentPage] = useState(1);
// choose 步骤的草稿:名称与已选类型,确认后才决定进入哪个构建页
const [draftName, setDraftName] = useState("");
const [draftType, setDraftType] = useState<AssistantType | null>(null);
const loadAssistants = useCallback(async () => {
setListLoading(true);
setListError(null);
try {
setAssistants(await assistantsApi.list());
} catch (error) {
setListError(error instanceof Error ? error.message : "加载失败");
} finally {
setListLoading(false);
}
}, []);
useEffect(() => {
// 挂载时拉取助手列表(与后端同步)
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadAssistants();
}, [loadAssistants]);
// 进入创建/编辑前加载下拉数据源(模型凭证 + 知识库)
const loadResources = useCallback(async () => {
try {
const [creds, kbs] = await Promise.all([
credentialsApi.list(),
knowledgeBasesApi.list(),
]);
setCredentials(creds);
setKnowledgeBases(kbs);
} catch {
// 拉取失败时下拉为空,不阻塞表单
}
}, []);
// 按资源类型生成 {value:id, label:name} 选项
const credOptions = (type: Credential["type"]) =>
credentials
.filter((c) => c.type === type)
.map((c) => ({ value: c.id, label: c.name }));
const kbOptions = knowledgeBases.map((k) => ({ value: k.id, label: k.name }));
function startCreate() {
setDraftName("");
setDraftType(null);
setView("choose");
}
// 提示词类型的空白模板(新建用)
function blankPromptForm(name: string): AssistantForm {
return {
name,
greeting: "",
prompt: "",
runtimeMode: "pipeline",
realtimeModel: "",
model: "",
asr: "",
voice: "",
knowledgeBase: "",
enableInterrupt: true,
};
}
// 把后端 Assistant 回填进提示词表单(注意:model/asr/voice 等存的是凭证 id)
function fillPromptForm(a: Assistant) {
setForm({
name: a.name,
greeting: a.greeting,
prompt: a.prompt,
runtimeMode: a.runtimeMode,
realtimeModel: a.realtimeCredentialId ?? "",
model: a.llmCredentialId ?? "",
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
knowledgeBase: a.knowledgeBaseId ?? "",
enableInterrupt: a.enableInterrupt,
});
}
// 编辑:根据助手类型进入对应的构建/编辑页
async function handleEdit(assistant: AssistantListItem) {
if (assistant.type === "提示词") {
void loadResources();
setSaveError(null);
setEditingId(assistant.id);
try {
fillPromptForm(await assistantsApi.get(assistant.id));
setView("create");
} catch (error) {
setListError(error instanceof Error ? error.message : "加载助手失败");
}
} else if (assistant.type === "FastGPT") {
void loadResources();
setSaveError(null);
setEditingId(assistant.id);
try {
fillFastGptForm(await assistantsApi.get(assistant.id));
setView("create-fastgpt");
} catch (error) {
setListError(error instanceof Error ? error.message : "加载助手失败");
}
} else if (assistant.type === "Dify") {
void loadResources();
setSaveError(null);
setEditingId(assistant.id);
try {
fillDifyForm(await assistantsApi.get(assistant.id));
setView("create-dify");
} catch (error) {
setListError(error instanceof Error ? error.message : "加载助手失败");
}
} else if (assistant.type === "OpenCode") {
void loadResources();
setSaveError(null);
setEditingId(assistant.id);
try {
fillOpenCodeForm(await assistantsApi.get(assistant.id));
setView("create-opencode");
} catch (error) {
setListError(error instanceof Error ? error.message : "加载助手失败");
}
} else {
// 工作流:暂时显示占位页
setDraftName(assistant.name);
setDraftType(assistant.type);
setView("placeholder");
}
}
function confirmType() {
if (!draftName.trim() || !draftType) {
return;
}
if (draftType === "提示词") {
// 提示词类型:新建,空白模板 + 带入名称
void loadResources();
setEditingId(null);
setSaveError(null);
setForm(blankPromptForm(draftName.trim()));
setView("create");
} else if (draftType === "FastGPT") {
// FastGPT 类型:新建,清空表单 + 带入名称
void loadResources();
setEditingId(null);
setSaveError(null);
setFastGptForm({
name: draftName.trim(),
appId: "",
apiUrl: "",
apiKey: "",
model: "",
asr: "",
voice: "",
enableInterrupt: true,
});
setView("create-fastgpt");
} else if (draftType === "Dify") {
// Dify 类型:新建,清空表单 + 带入名称
void loadResources();
setEditingId(null);
setSaveError(null);
setDifyForm({
name: draftName.trim(),
apiUrl: "",
apiKey: "",
asr: "",
voice: "",
enableInterrupt: true,
});
setView("create-dify");
} else if (draftType === "OpenCode") {
// OpenCode 类型:新建,清空表单 + 带入名称
void loadResources();
setEditingId(null);
setSaveError(null);
setOpenCodeForm({
name: draftName.trim(),
prompt: "",
apiUrl: "",
apiKey: "",
asr: "",
voice: "",
enableInterrupt: true,
});
setView("create-opencode");
} else {
// 工作流:暂时显示占位页
setView("placeholder");
}
}
// 复制助手:服务端整行复制(含真 key,密钥不经浏览器)
async function handleDuplicate(assistant: AssistantListItem) {
try {
await assistantsApi.duplicate(assistant.id);
await loadAssistants();
} catch (error) {
setListError(error instanceof Error ? error.message : "复制失败");
}
}
// 删除助手
async function handleDelete(id: string) {
try {
await assistantsApi.remove(id);
await loadAssistants();
} catch (error) {
setListError(error instanceof Error ? error.message : "删除失败");
}
}
// 平铺字段的空白 upsert,各类型按需覆盖(后端会按 type 清掉无关字段)
function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
return {
name: "",
type: "prompt",
runtimeMode: "pipeline",
greeting: "",
enableInterrupt: true,
llmCredentialId: null,
asrCredentialId: null,
ttsCredentialId: null,
realtimeCredentialId: null,
knowledgeBaseId: null,
prompt: "",
apiUrl: "",
apiKey: "",
appId: "",
graph: {},
...over,
};
}
// 统一的保存:新建 POST / 编辑 PUT
async function save(payload: AssistantUpsert) {
setSaving(true);
setSaveError(null);
try {
if (editingId) await assistantsApi.update(editingId, payload);
else await assistantsApi.create(payload);
await loadAssistants();
setView("list");
} catch (error) {
setSaveError(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
function handleSavePrompt() {
void save(
baseUpsert({
name: form.name.trim(),
type: "prompt",
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,
knowledgeBaseId: form.knowledgeBase || null,
prompt: form.prompt,
}),
);
}
// ---- Dify ----
function fillDifyForm(a: Assistant) {
setDifyForm({
name: a.name,
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
model: a.llmCredentialId ?? "",
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
enableInterrupt: a.enableInterrupt,
});
}
function handleSaveDify() {
void save(
baseUpsert({
name: difyForm.name.trim(),
type: "dify",
enableInterrupt: difyForm.enableInterrupt,
asrCredentialId: difyForm.asr || null,
ttsCredentialId: difyForm.voice || null,
apiUrl: difyForm.apiUrl,
apiKey: difyForm.apiKey,
}),
);
}
// ---- FastGPT ----
function fillFastGptForm(a: Assistant) {
setFastGptForm({
name: a.name,
appId: a.appId,
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
enableInterrupt: a.enableInterrupt,
});
}
function handleSaveFastGpt() {
void save(
baseUpsert({
name: fastGptForm.name.trim(),
type: "fastgpt",
enableInterrupt: fastGptForm.enableInterrupt,
asrCredentialId: fastGptForm.asr || null,
ttsCredentialId: fastGptForm.voice || null,
appId: fastGptForm.appId,
apiUrl: fastGptForm.apiUrl,
apiKey: fastGptForm.apiKey,
}),
);
}
// ---- OpenCode ----
function fillOpenCodeForm(a: Assistant) {
setOpenCodeForm({
name: a.name,
prompt: a.prompt,
apiUrl: a.apiUrl,
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
apiKey: "",
asr: a.asrCredentialId ?? "",
voice: a.ttsCredentialId ?? "",
enableInterrupt: a.enableInterrupt,
});
}
function handleSaveOpenCode() {
void save(
baseUpsert({
name: openCodeForm.name.trim(),
type: "opencode",
enableInterrupt: openCodeForm.enableInterrupt,
llmCredentialId: openCodeForm.model || null,
asrCredentialId: openCodeForm.asr || null,
ttsCredentialId: openCodeForm.voice || null,
prompt: openCodeForm.prompt,
apiUrl: openCodeForm.apiUrl,
apiKey: openCodeForm.apiKey,
}),
);
}
const listItems: AssistantListItem[] = assistants.map((a) => ({
id: a.id,
name: a.name,
type: typeToLabel[a.type],
updatedAt: formatTimestamp(a.updatedAt),
}));
const storedApiKeyMask =
(editingId &&
assistants.find((assistant) => assistant.id === editingId)?.apiKey) ||
"";
const filteredAssistants = listItems.filter((assistant) => {
if (typeFilter !== "全部" && assistant.type !== typeFilter) {
return false;
}
const keyword = searchQuery.trim().toLowerCase();
if (!keyword) {
return true;
}
return [assistant.name, assistant.id, assistant.type, assistant.updatedAt]
.join(" ")
.toLowerCase()
.includes(keyword);
});
const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(filteredAssistants.length / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStart = (safeCurrentPage - 1) * pageSize;
const pageEnd = pageStart + pageSize;
const paginatedAssistants = filteredAssistants.slice(pageStart, pageEnd);
function handleSearchChange(value: string) {
setSearchQuery(value);
setCurrentPage(1);
}
function handleFilterChange(filter: TypeFilter) {
setTypeFilter(filter);
setCurrentPage(1);
}
function updateForm<K extends keyof AssistantForm>(
key: K,
value: AssistantForm[K],
) {
setForm((prev) => ({
...prev,
[key]: value,
}));
}
function updateFastGptForm<K extends keyof FastGptForm>(
key: K,
value: FastGptForm[K],
) {
setFastGptForm((prev) => ({
...prev,
[key]: value,
}));
}
function updateDifyForm<K extends keyof DifyForm>(
key: K,
value: DifyForm[K],
) {
setDifyForm((prev) => ({
...prev,
[key]: value,
}));
}
function updateOpenCodeForm<K extends keyof OpenCodeForm>(
key: K,
value: OpenCodeForm[K],
) {
setOpenCodeForm((prev) => ({
...prev,
[key]: value,
}));
}
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>
<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"
/>
<Input
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"
placeholder="搜索助手名称、类型或 ID..."
/>
</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">
<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]">
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={() => handleEdit(assistant)}
>
<Pencil size={14} />
编辑
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${assistant.name} 更多操作`}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem
className="rounded-lg"
onSelect={() => handleDuplicate(assistant)}
>
<Copy size={14} />
复制
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
className="rounded-lg"
onSelect={(event) => {
event.preventDefault();
void handleDelete(assistant.id);
}}
>
<Trash2 size={14} />
删除
</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>
);
}
if (view === "choose") {
const selectedOption = assistantTypeOptions.find(
(option) => option.type === draftType,
);
return (
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-8">
<div className="flex items-start justify-between 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">
先为助手取个名字,再选择构建方式。不同方式将进入不同的构建流程。
</p>
</div>
<Button
variant="outline"
size="lg"
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setView("list")}
>
<ChevronLeft size={16} />
返回列表
</Button>
</div>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<label className="block">
<div className="mb-2 text-sm font-medium text-foreground">
助手名称
</div>
<Input
value={draftName}
autoFocus
onChange={(event) => setDraftName(event.target.value)}
placeholder="请输入助手名称"
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
</section>
<section className="flex flex-col gap-4">
<div className="text-sm font-medium text-foreground">构建方式</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{assistantTypeOptions.map((option) => {
const selected = draftType === option.type;
return (
<button
key={option.type}
type="button"
onClick={() => setDraftType(option.type)}
className={`group relative flex flex-col gap-4 rounded-2xl border bg-card p-5 text-left transition-colors ${
selected
? "border-primary ring-1 ring-primary"
: "border-hairline hover:border-hairline-strong"
}`}
>
<div className="flex items-center justify-between">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-surface-strong text-foreground">
{option.icon}
</div>
{selected ? (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check size={14} />
</span>
) : (
!option.available && (
<Badge
variant="secondary"
className="h-6 bg-surface-strong px-3 text-xs text-muted-foreground"
>
即将上线
</Badge>
)
)}
</div>
<div>
<div className="text-base font-medium text-foreground">
{option.label}
</div>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
{option.description}
</p>
</div>
</button>
);
})}
</div>
</section>
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
size="lg"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setView("list")}
>
取消
</Button>
<Button
size="lg"
className="gap-2"
disabled={!draftName.trim() || !draftType}
onClick={confirmType}
>
<Rocket size={16} />
{selectedOption && !selectedOption.available
? "下一步"
: "开始构建"}
</Button>
</div>
</div>
);
}
if (view === "placeholder") {
return (
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-6">
<div className="flex items-start justify-between gap-6">
<div>
<h1 className="font-display display-lg text-ink">
{draftName.trim() || "创建助手"}
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
{draftType} 构建方式正在开发中,敬请期待。
</p>
</div>
<Button
variant="outline"
size="lg"
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={() => setView("list")}
>
<ChevronLeft size={16} />
返回列表
</Button>
</div>
<section className="relative overflow-hidden rounded-3xl border border-hairline bg-canvas-soft px-10 py-20 text-center">
<div
aria-hidden
className="pointer-events-none absolute -right-24 top-0 h-72 w-72 rounded-full opacity-50 blur-3xl"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in srgb, var(--gradient-sky) 50%, transparent), transparent 70%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute -left-20 bottom-0 h-64 w-64 rounded-full opacity-45 blur-3xl"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in srgb, var(--gradient-lavender) 50%, transparent), transparent 70%)",
}}
/>
<div className="relative">
<div className="caption-label inline-flex rounded-full bg-surface-strong px-3 py-1 text-muted-foreground">
建设中
</div>
<p className="font-display display-sm mx-auto mt-5 max-w-md text-ink">
{draftType} 构建页面正在编写中
</p>
<p className="mx-auto mt-3 max-w-md text-sm leading-7 text-body">
页面骨架与设计语言已就绪,后续将填充具体构建流程与数据。
</p>
</div>
</section>
</div>
);
}
if (view === "create-dify") {
return (
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={() => setView("list")} />
<EditableTitle
value={difyForm.name}
onChange={(value) => updateDifyForm("name", value)}
/>
<AssistantIdentity assistantId={editingId} />
</div>
<div className="flex shrink-0 gap-2">
{saveError && (
<span className="self-center text-xs text-destructive">
{saveError}
</span>
)}
<Button
className="gap-2"
disabled={saving || !difyForm.name.trim()}
onClick={() => void handleSaveDify()}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
保存
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 gap-6">
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
<SectionCard
icon={<Boxes size={18} />}
title="Dify 应用配置"
description="填写 API URL 与 API Key 以对接 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
>
<InputField
label="API URL"
value={difyForm.apiUrl}
onChange={(value) => updateDifyForm("apiUrl", value)}
placeholder="https://api.dify.ai/v1/chat-messages"
/>
<SecretInputField
label="API Key"
value={difyForm.apiKey}
onChange={(value) => updateDifyForm("apiKey", value)}
placeholder="请输入 Dify API Key"
storedValueMask={storedApiKeyMask}
/>
</SectionCard>
<SectionCard
icon={<Brain size={18} />}
title="语音配置"
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
>
<ResourceSelectField
label="语音识别"
value={difyForm.asr}
onChange={(value) => updateDifyForm("asr", value)}
options={credOptions("ASR")}
noneLabel="无"
/>
<ResourceSelectField
label="语音合成"
value={difyForm.voice}
onChange={(value) => updateDifyForm("voice", value)}
options={credOptions("TTS")}
noneLabel="无"
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={18} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<ToggleRow
title="允许用户打断"
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
checked={difyForm.enableInterrupt}
onChange={(checked) =>
updateDifyForm("enableInterrupt", checked)
}
/>
</SectionCard>
</div>
<DebugDrawer />
</div>
</div>
);
}
if (view === "create-fastgpt") {
return (
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={() => setView("list")} />
<EditableTitle
value={fastGptForm.name}
onChange={(value) => updateFastGptForm("name", value)}
/>
<AssistantIdentity assistantId={editingId} />
</div>
<div className="flex shrink-0 gap-2">
{saveError && (
<span className="self-center text-xs text-destructive">
{saveError}
</span>
)}
<Button
className="gap-2"
disabled={saving || !fastGptForm.name.trim()}
onClick={() => void handleSaveFastGpt()}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
保存
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 gap-6">
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
<SectionCard
icon={<Database size={18} />}
title="FastGPT 应用配置"
description="填写 App ID、API URL 与 API Key 以对接 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
>
<InputField
label="App ID"
value={fastGptForm.appId}
onChange={(value) => updateFastGptForm("appId", value)}
placeholder="请输入 FastGPT 应用 ID"
/>
<InputField
label="API URL"
value={fastGptForm.apiUrl}
onChange={(value) => updateFastGptForm("apiUrl", value)}
placeholder="https://api.fastgpt.in/api/v1/chat/completions"
/>
<SecretInputField
label="API Key"
value={fastGptForm.apiKey}
onChange={(value) => updateFastGptForm("apiKey", value)}
placeholder="请输入 FastGPT API Key"
storedValueMask={storedApiKeyMask}
/>
</SectionCard>
<SectionCard
icon={<Brain size={18} />}
title="语音配置"
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
>
<ResourceSelectField
label="语音识别"
value={fastGptForm.asr}
onChange={(value) => updateFastGptForm("asr", value)}
options={credOptions("ASR")}
noneLabel="无"
/>
<ResourceSelectField
label="语音合成"
value={fastGptForm.voice}
onChange={(value) => updateFastGptForm("voice", value)}
options={credOptions("TTS")}
noneLabel="无"
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={18} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<ToggleRow
title="允许用户打断"
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
checked={fastGptForm.enableInterrupt}
onChange={(checked) =>
updateFastGptForm("enableInterrupt", checked)
}
/>
</SectionCard>
</div>
<DebugDrawer />
</div>
</div>
);
}
if (view === "create-opencode") {
return (
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={() => setView("list")} />
<EditableTitle
value={openCodeForm.name}
onChange={(value) => updateOpenCodeForm("name", value)}
/>
<AssistantIdentity assistantId={editingId} />
</div>
<div className="flex shrink-0 gap-2">
{saveError && (
<span className="self-center text-xs text-destructive">
{saveError}
</span>
)}
<Button
className="gap-2"
disabled={saving || !openCodeForm.name.trim()}
onClick={() => void handleSaveOpenCode()}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
保存
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 gap-6">
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
<SectionCard
icon={<Terminal size={18} />}
title="OpenCode 服务配置"
description="填写 OpenCode 服务地址与 API Key 以对接代码助手。"
>
<InputField
label="OpenCode URL"
value={openCodeForm.apiUrl}
onChange={(value) => updateOpenCodeForm("apiUrl", value)}
placeholder="http://localhost:4096"
/>
<SecretInputField
label="API Key"
value={openCodeForm.apiKey}
onChange={(value) => updateOpenCodeForm("apiKey", value)}
placeholder="请输入 OpenCode API Key"
storedValueMask={storedApiKeyMask}
/>
</SectionCard>
<SectionCard
icon={<MessageSquareText size={18} />}
title="提示词"
description="描述助手的角色、能力和回答要求"
>
<TextAreaField
value={openCodeForm.prompt}
onChange={(value) => updateOpenCodeForm("prompt", value)}
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
rows={8}
/>
</SectionCard>
<SectionCard
icon={<Brain size={18} />}
title="模型与语音配置"
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
>
<ResourceSelectField
label="大语言模型"
value={openCodeForm.model}
onChange={(value) => updateOpenCodeForm("model", value)}
options={credOptions("LLM")}
noneLabel="无"
/>
<ResourceSelectField
label="语音识别"
value={openCodeForm.asr}
onChange={(value) => updateOpenCodeForm("asr", value)}
options={credOptions("ASR")}
noneLabel="无"
/>
<ResourceSelectField
label="语音合成"
value={openCodeForm.voice}
onChange={(value) => updateOpenCodeForm("voice", value)}
options={credOptions("TTS")}
noneLabel="无"
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={18} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<ToggleRow
title="允许用户打断"
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
checked={openCodeForm.enableInterrupt}
onChange={(checked) =>
updateOpenCodeForm("enableInterrupt", checked)
}
/>
</SectionCard>
</div>
<DebugDrawer />
</div>
</div>
);
}
return (
<div className="-mt-6 flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
<div className="flex min-w-0 items-center gap-2">
<EditorBackButton onClick={() => setView("list")} />
<EditableTitle
value={form.name}
onChange={(value) => updateForm("name", value)}
/>
<AssistantIdentity assistantId={editingId} />
</div>
<div className="flex shrink-0 gap-2">
{saveError && (
<span className="self-center text-xs text-destructive">
{saveError}
</span>
)}
<Button
className="gap-2"
disabled={saving || !form.name.trim()}
onClick={() => void handleSavePrompt()}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
保存
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 gap-6">
<div className="min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
<SectionCard>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
role="button"
tabIndex={0}
onClick={() => updateForm("runtimeMode", "pipeline")}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
updateForm("runtimeMode", "pipeline");
}
}}
className={[
"cursor-pointer rounded-2xl border p-5 text-left transition-colors",
form.runtimeMode === "pipeline"
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
].join(" ")}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Waypoints size={18} />
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Pipeline 模式</span>
<HelpHint text="通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。" />
</div>
</div>
{form.runtimeMode === "pipeline" && (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check size={14} />
</span>
)}
</div>
</div>
<div
role="button"
tabIndex={0}
onClick={() => updateForm("runtimeMode", "realtime")}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
updateForm("runtimeMode", "realtime");
}
}}
className={[
"cursor-pointer rounded-2xl border p-5 text-left transition-colors",
form.runtimeMode === "realtime"
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
].join(" ")}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<AudioLines size={18} />
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Realtime 模式</span>
<HelpHint text="使用原生实时语音模型,模型直接处理音频输入并生成语音回复。" />
</div>
</div>
{form.runtimeMode === "realtime" && (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check size={14} />
</span>
)}
</div>
</div>
</div>
</SectionCard>
<SectionCard
icon={<MessageSquareText size={18} />}
title="提示词"
description="描述助手的角色、能力和回答要求"
>
<TextAreaField
value={form.prompt}
onChange={(value) => updateForm("prompt", value)}
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
rows={8}
/>
</SectionCard>
{form.runtimeMode === "pipeline" ? (
<SectionCard
icon={<Brain size={18} />}
title="模型配置"
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
>
<ResourceSelectField
label="大语言模型"
value={form.model}
onChange={(value) => updateForm("model", value)}
options={credOptions("LLM")}
noneLabel="无"
/>
<ResourceSelectField
label="语音识别"
value={form.asr}
onChange={(value) => updateForm("asr", value)}
options={credOptions("ASR")}
noneLabel="无"
/>
<ResourceSelectField
label="语音合成"
value={form.voice}
onChange={(value) => updateForm("voice", value)}
options={credOptions("TTS")}
noneLabel="无"
/>
</SectionCard>
) : (
<SectionCard
icon={<Brain size={18} />}
title="模型配置"
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
>
<ResourceSelectField
label="Realtime 模型"
value={form.realtimeModel}
onChange={(value) => updateForm("realtimeModel", value)}
options={credOptions("Realtime")}
noneLabel="无"
/>
</SectionCard>
)}
<SectionCard
icon={<Bot size={18} />}
title="开场白"
description="助手与用户首次对话时的开场语"
>
<TextAreaField
value={form.greeting}
onChange={(value) => updateForm("greeting", value)}
placeholder="请输入助手开场白"
/>
</SectionCard>
<SectionCard
icon={<Database size={18} />}
title="知识库配置"
description="选择助手回答时可检索的业务知识来源"
>
<ResourceSelectField
value={form.knowledgeBase}
onChange={(value) => updateForm("knowledgeBase", value)}
options={kbOptions}
noneLabel="无"
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={18} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<ToggleRow
title="允许用户打断"
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
checked={form.enableInterrupt}
onChange={(checked) => updateForm("enableInterrupt", checked)}
/>
</SectionCard>
</div>
<DebugDrawer />
</div>
</div>
);
}
type VizStyle = "aura" | "bars" | "wave";
const VIZ_ORDER: VizStyle[] = ["aura", "bars", "wave"];
const VIZ_LABEL: Record<VizStyle, string> = {
aura: "光环",
bars: "频谱",
wave: "波形",
};
function DebugDrawer() {
const [showTranscript, setShowTranscript] = useState(false);
const [vizStyle, setVizStyle] = useState<VizStyle>("wave");
return (
<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-4">
<div className="text-sm font-medium text-foreground">调试与预览</div>
<div className="flex items-center gap-2">
{!showTranscript && (
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() =>
setVizStyle(
(value) =>
VIZ_ORDER[
(VIZ_ORDER.indexOf(value) + 1) % VIZ_ORDER.length
],
)
}
aria-label={`切换可视化样式(当前:${VIZ_LABEL[vizStyle]})`}
title={`可视化:${VIZ_LABEL[vizStyle]}`}
>
{vizStyle === "aura" ? (
<Orbit size={16} />
) : vizStyle === "bars" ? (
<AudioLines size={16} />
) : (
<Waves size={16} />
)}
</Button>
)}
<Button
type="button"
variant={showTranscript ? "default" : "outline"}
size="icon"
className="h-8 w-8 rounded-full text-xs font-medium"
onClick={() => setShowTranscript((value) => !value)}
aria-label={showTranscript ? "显示音频可视化" : "显示文字聊天记录"}
aria-pressed={showTranscript}
>
文
</Button>
</div>
</div>
<DebugVoicePanel showTranscript={showTranscript} vizStyle={vizStyle} />
</aside>
);
}
function DebugVoicePanel({
showTranscript,
vizStyle,
}: {
showTranscript: boolean;
vizStyle: VizStyle;
}) {
const [recording, setRecording] = useState(false);
const [micError, setMicError] = useState(false);
return (
<div className="flex min-h-0 flex-1 flex-col">
{showTranscript ? (
<DebugTranscriptPanel />
) : (
<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
className="pointer-events-none absolute left-1/2 top-2 h-72 w-72 -translate-x-1/2 rounded-full opacity-50 blur-3xl"
style={{
background:
"radial-gradient(circle, color-mix(in srgb, var(--gradient-sky) 42%, transparent), color-mix(in srgb, var(--gradient-lavender) 18%, transparent) 48%, transparent 74%)",
}}
/>
<Badge
variant="secondary"
className="relative gap-1.5 rounded-full border border-hairline bg-canvas-soft px-3 py-1 text-[11px] font-medium text-muted-foreground shadow-none"
>
<span
className={[
"h-1.5 w-1.5 rounded-full",
recording ? "animate-pulse bg-success" : "bg-muted-soft",
].join(" ")}
/>
{recording ? "会话进行中" : "准备开始"}
</Badge>
<div className="relative flex h-[200px] w-[240px] shrink-0 items-center justify-center">
{(() => {
const onVizError = () => {
setMicError(true);
setRecording(false);
};
const shared = {
active: recording,
className: "relative shrink-0",
onError: onVizError,
} as const;
if (vizStyle === "aura")
return <AuraVisualizer {...shared} size={200} />;
if (vizStyle === "bars")
return (
<SpectrumVisualizer {...shared} size={200} barCount={64} />
);
return <WaveVisualizer {...shared} size={200} />;
})()}
</div>
<div className="relative max-w-xs space-y-1.5">
<div className="font-display display-sm text-foreground">
{recording ? "我在聆听" : "开始一次语音对话"}
</div>
<p className="mx-auto text-xs leading-5 text-muted-foreground">
{micError
? "无法访问麦克风,请检查浏览器权限后重试。"
: recording
? "直接说话即可。助手会在您停顿后自然回应。"
: "测试语音识别、响应速度与助手的播报效果。"}
</p>
</div>
<Button
onClick={() => {
setMicError(false);
setRecording((value) => !value);
}}
className={[
"relative h-11 gap-2 rounded-full px-6 text-sm font-medium shadow-sm transition-transform hover:scale-[1.03]",
recording
? "bg-destructive text-white hover:bg-destructive/90"
: "",
].join(" ")}
aria-label={recording ? "结束语音测试" : "开始语音测试"}
>
{recording ? <PhoneOff size={18} /> : <Mic size={18} />}
{recording ? "结束对话" : "开始对话"}
</Button>
</div>
)}
<div className="shrink-0 border-t border-hairline bg-card p-3">
<div className="flex items-end gap-2">
<Textarea
rows={1}
placeholder="输入文字以模拟用户消息…"
className="max-h-24 min-h-10 flex-1 resize-none border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
<Button size="icon" className="shrink-0" aria-label="发送调试消息">
<Send size={16} />
</Button>
</div>
</div>
</div>
);
}
function DebugTranscriptPanel() {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4">
<div className="flex flex-col gap-4">
<div className="flex max-w-[88%] flex-col gap-1 self-start">
<span className="px-1 text-[11px] text-muted-soft">助手 · 10:24</span>
<div className="rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
您好,我是 AI 视频助手,请问有什么可以帮您?
</div>
</div>
<div className="flex max-w-[88%] flex-col items-end gap-1 self-end">
<span className="px-1 text-[11px] text-muted-soft">我 · 10:25</span>
<div className="rounded-2xl rounded-tr-sm bg-primary px-4 py-2.5 text-sm leading-6 text-primary-foreground">
我想了解一下社保卡的办理流程。
</div>
</div>
<div className="flex max-w-[88%] flex-col gap-1 self-start">
<span className="px-1 text-[11px] text-muted-soft">助手 · 10:25</span>
<div className="rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
社保卡可通过线上或线下渠道办理。线上可在政务服务 App
提交申请,线下可前往社保经办网点。
</div>
</div>
</div>
</div>
);
}
function EditorBackButton({ onClick }: { onClick: () => void }) {
return (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={onClick}
aria-label="返回助手列表"
>
<ChevronLeft size={20} />
</Button>
);
}
function AssistantIdentity({ assistantId }: { assistantId: string | null }) {
const [copied, setCopied] = useState(false);
async function copyId() {
if (!assistantId) return;
await navigator.clipboard.writeText(assistantId);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
}
return (
<div className="ml-1 flex shrink-0 items-center rounded-full border border-hairline bg-canvas-soft pl-2.5 text-[11px] text-muted-foreground">
<span className="font-mono">
{assistantId ? `ID · ${assistantId}` : "ID · 保存后生成"}
</span>
{assistantId && (
<button
type="button"
onClick={() => void copyId()}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
aria-label={copied ? "助手 ID 已复制" : "复制助手 ID"}
title={copied ? "已复制" : "复制 ID"}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
)}
{!assistantId && <span className="h-7 w-2" aria-hidden />}
</div>
);
}
function EditableTitle({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
function startEdit() {
setDraft(value);
setEditing(true);
}
function commit() {
const next = draft.trim();
if (next) {
onChange(next);
}
setEditing(false);
}
if (editing) {
return (
<input
ref={inputRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
} else if (event.key === "Escape") {
event.preventDefault();
setEditing(false);
}
}}
className="font-display display-sm w-[min(60vw,420px)] border-b border-primary bg-transparent text-ink outline-none"
/>
);
}
return (
<button
type="button"
onClick={startEdit}
title="点击修改助手名称"
className="group -mx-2 flex min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-strong"
>
<span className="font-display display-sm truncate text-ink">
{value || "未命名助手"}
</span>
<Pencil
size={16}
className="shrink-0 text-muted-soft opacity-0 transition-opacity group-hover:opacity-100"
/>
</button>
);
}
function HelpHint({ text }: { text: string }) {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="查看说明"
onClick={(event) => event.stopPropagation()}
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
>
<HelpCircle size={14} />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-72 text-sm leading-6 text-muted-foreground"
>
{text}
</PopoverContent>
</Popover>
);
}
function SectionCard({
icon,
title,
description,
children,
}: {
icon?: React.ReactNode;
title?: string;
description?: string;
children: React.ReactNode;
}) {
const hasHeader = Boolean(title);
return (
<Card className="rounded-2xl border-hairline bg-card text-card-foreground shadow-sm">
{hasHeader && (
<CardHeader>
<div className="flex items-center gap-3">
{icon && (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
{icon}
</div>
)}
<div className="flex items-center gap-1.5">
<CardTitle className="text-base font-medium">{title}</CardTitle>
{description && <HelpHint text={description} />}
</div>
</div>
</CardHeader>
)}
<CardContent className={hasHeader ? "space-y-4" : undefined}>
{children}
</CardContent>
</Card>
);
}
function InputField({
label,
value,
placeholder,
type = "text",
onChange,
}: {
label?: string;
value: string;
placeholder?: string;
type?: string;
onChange: (value: string) => void;
}) {
return (
<label className="block">
{label && (
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
)}
<Input
value={value}
type={type}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
);
}
function SecretInputField({
label,
value,
placeholder,
storedValueMask,
onChange,
}: {
label?: string;
value: string;
placeholder?: string;
storedValueMask: string;
onChange: (value: string) => void;
}) {
const [showValue, setShowValue] = useState(false);
const hasStoredValue = Boolean(storedValueMask);
return (
<label className="block">
{label && (
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
)}
{hasStoredValue && (
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>当前密钥</span>
<code className="rounded-md bg-surface-strong px-2 py-1 font-mono text-foreground">
{storedValueMask}
</code>
</div>
)}
<div className="relative">
<Input
value={value}
type={showValue ? "text" : "password"}
placeholder={
hasStoredValue ? "已配置,留空则保持不变" : placeholder
}
autoComplete="new-password"
onChange={(event) => {
const nextValue = event.target.value;
if (!nextValue) setShowValue(false);
onChange(nextValue);
}}
className="border-hairline-strong bg-background pr-10 text-foreground placeholder:text-muted-soft"
/>
<button
type="button"
disabled={!value}
onClick={() => setShowValue((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-soft transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
aria-label={showValue ? "隐藏 API Key" : "显示 API Key"}
>
{showValue ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{hasStoredValue && (
<p className="mt-2 text-xs leading-5 text-muted-foreground">
仅显示当前密钥首尾用于识别。留空可保持原密钥,输入新值将覆盖原密钥。
</p>
)}
</label>
);
}
function TextAreaField({
label,
value,
placeholder,
rows = 4,
onChange,
}: {
label?: string;
value: string;
placeholder?: string;
rows?: number;
onChange: (value: string) => void;
}) {
return (
<label className="block">
{label && (
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
)}
<Textarea
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
rows={rows}
className="resize-none border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
/>
</label>
);
}
// Radix Select 不允许空字符串 value,用哨兵表示"未选/无"
const NONE_VALUE = "__none__";
function ResourceSelectField({
label,
value,
options,
noneLabel,
onChange,
}: {
label?: string;
value: string;
options: { value: string; label: string }[];
/** 提供则在顶部加一个"无/默认"选项,选中映射为空串 */
noneLabel?: string;
onChange: (value: string) => void;
}) {
return (
<div className="block">
{label && (
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
)}
<Select
value={value || NONE_VALUE}
onValueChange={(v) => onChange(v === NONE_VALUE ? "" : v)}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue placeholder={label ? `请选择${label}` : "请选择"} />
</SelectTrigger>
<SelectContent className="border-hairline bg-popover text-popover-foreground">
{noneLabel && (
<SelectItem value={NONE_VALUE}>{noneLabel}</SelectItem>
)}
{options.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function ToggleRow({
title,
description,
checked,
onChange,
}: {
title: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<div className="flex items-center justify-between rounded-xl border border-hairline bg-canvas-soft p-4">
<div>
<div className="font-medium text-foreground">{title}</div>
<div className="mt-1 text-sm text-muted-foreground">{description}</div>
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}