Updated the Sidebar component to include an optional className property in the icon type definition for better styling flexibility. In the AssistantPage, introduced new FastGptForm and DifyForm types, updated the assistant type options to reflect their availability, and enhanced the state management for creating and editing assistants. Improved the user experience by refining the view handling logic and adding dedicated forms for FastGPT and Dify configurations.
1535 lines
50 KiB
TypeScript
1535 lines
50 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Bot,
|
||
Boxes,
|
||
Brain,
|
||
Check,
|
||
Database,
|
||
MessageSquareText,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Plus,
|
||
Rocket,
|
||
Search,
|
||
Sparkles,
|
||
Trash2,
|
||
Workflow,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Save,
|
||
Mic,
|
||
Send,
|
||
MessageCircle,
|
||
HelpCircle,
|
||
Waypoints,
|
||
AudioLines,
|
||
Square,
|
||
} 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 { VoiceVisualizer } from "@/components/ui/voice-visualizer";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { useEffect, useRef, useState } from "react";
|
||
|
||
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 AssistantType = "提示词" | "工作流" | "Dify" | "FastGPT";
|
||
|
||
const assistantTypes: AssistantType[] = ["提示词", "工作流", "Dify", "FastGPT"];
|
||
|
||
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 AssistantListItem = {
|
||
id: string;
|
||
name: string;
|
||
type: AssistantType;
|
||
updatedAt: string;
|
||
};
|
||
|
||
const mockAssistants: AssistantListItem[] = [
|
||
{
|
||
id: "asst_001",
|
||
name: "政务视频咨询助手",
|
||
type: "提示词",
|
||
updatedAt: "2026-06-05 18:20",
|
||
},
|
||
{
|
||
id: "asst_002",
|
||
name: "热线工单辅助助手",
|
||
type: "工作流",
|
||
updatedAt: "2026-06-04 15:12",
|
||
},
|
||
{
|
||
id: "asst_003",
|
||
name: "Dify 知识库问答助手",
|
||
type: "Dify",
|
||
updatedAt: "2026-06-02 09:48",
|
||
},
|
||
{
|
||
id: "asst_004",
|
||
name: "FastGPT 售后咨询助手",
|
||
type: "FastGPT",
|
||
updatedAt: "2026-05-29 11:06",
|
||
},
|
||
{
|
||
id: "asst_005",
|
||
name: "医院挂号问询助手",
|
||
type: "提示词",
|
||
updatedAt: "2026-05-25 14:30",
|
||
},
|
||
{
|
||
id: "asst_006",
|
||
name: "政务办事进度助手",
|
||
type: "工作流",
|
||
updatedAt: "2026-05-22 10:15",
|
||
},
|
||
{
|
||
id: "asst_007",
|
||
name: "Dify 智能客服助手",
|
||
type: "Dify",
|
||
updatedAt: "2026-05-18 09:02",
|
||
},
|
||
{
|
||
id: "asst_008",
|
||
name: "FastGPT 智能催收助手",
|
||
type: "FastGPT",
|
||
updatedAt: "2026-05-15 17:20",
|
||
},
|
||
{
|
||
id: "asst_009",
|
||
name: "高校课程咨询助手",
|
||
type: "提示词",
|
||
updatedAt: "2026-05-10 12:48",
|
||
},
|
||
{
|
||
id: "asst_010",
|
||
name: "企业入驻流程助手",
|
||
type: "工作流",
|
||
updatedAt: "2026-05-06 08:43",
|
||
},
|
||
{
|
||
id: "asst_011",
|
||
name: "Dify 政策助手",
|
||
type: "Dify",
|
||
updatedAt: "2026-05-01 16:45",
|
||
},
|
||
{
|
||
id: "asst_012",
|
||
name: "FastGPT 报修助手",
|
||
type: "FastGPT",
|
||
updatedAt: "2026-04-28 19:34",
|
||
},
|
||
];
|
||
|
||
type DebugMode = "text" | "voice";
|
||
|
||
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: "gpt-realtime-2",
|
||
model: "DeepSeek-V3",
|
||
asr: "SenseVoice",
|
||
voice: "晓宁",
|
||
knowledgeBase: "无",
|
||
enableInterrupt: true,
|
||
});
|
||
const [fastGptForm, setFastGptForm] = useState<FastGptForm>({
|
||
name: "FastGPT 售后咨询助手",
|
||
appId: "",
|
||
apiUrl: "https://api.fastgpt.in/api/v1/chat/completions",
|
||
apiKey: "",
|
||
asr: "SenseVoice",
|
||
voice: "晓宁",
|
||
enableInterrupt: true,
|
||
});
|
||
const [difyForm, setDifyForm] = useState<DifyForm>({
|
||
name: "Dify 知识库问答助手",
|
||
apiUrl: "https://api.dify.ai/v1/chat-messages",
|
||
apiKey: "",
|
||
asr: "SenseVoice",
|
||
voice: "晓宁",
|
||
enableInterrupt: true,
|
||
});
|
||
const [view, setView] = useState<
|
||
"list" | "choose" | "create" | "create-dify" | "create-fastgpt" | "placeholder"
|
||
>("list");
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
// 右侧调试 drawer 的当前模式
|
||
const [debugMode, setDebugMode] = useState<DebugMode>("text");
|
||
// choose 步骤的草稿:名称与已选类型,确认后才决定进入哪个构建页
|
||
const [draftName, setDraftName] = useState("");
|
||
const [draftType, setDraftType] = useState<AssistantType | null>(null);
|
||
|
||
function startCreate() {
|
||
setDraftName("");
|
||
setDraftType(null);
|
||
setView("choose");
|
||
}
|
||
|
||
// 编辑:根据助手类型进入对应的构建/编辑页,并把名称带入相应表单
|
||
function handleEdit(assistant: AssistantListItem) {
|
||
if (assistant.type === "提示词") {
|
||
updateForm("name", assistant.name);
|
||
setView("create");
|
||
} else if (assistant.type === "FastGPT") {
|
||
updateFastGptForm("name", assistant.name);
|
||
setView("create-fastgpt");
|
||
} else if (assistant.type === "Dify") {
|
||
updateDifyForm("name", assistant.name);
|
||
setView("create-dify");
|
||
} else {
|
||
// 工作流:暂时显示占位页
|
||
setDraftName(assistant.name);
|
||
setDraftType(assistant.type);
|
||
setView("placeholder");
|
||
}
|
||
}
|
||
|
||
function confirmType() {
|
||
if (!draftName.trim() || !draftType) {
|
||
return;
|
||
}
|
||
|
||
if (draftType === "提示词") {
|
||
// 提示词类型:复用现有创建表单,并把已填的名称带过去
|
||
updateForm("name", draftName.trim());
|
||
setView("create");
|
||
} else if (draftType === "FastGPT") {
|
||
// FastGPT 类型:进入 FastGPT 构建表单,并把已填的名称带过去
|
||
updateFastGptForm("name", draftName.trim());
|
||
setView("create-fastgpt");
|
||
} else if (draftType === "Dify") {
|
||
// Dify 类型:进入 Dify 构建表单,并把已填的名称带过去
|
||
updateDifyForm("name", draftName.trim());
|
||
setView("create-dify");
|
||
} else {
|
||
// 工作流:暂时显示占位页
|
||
setView("placeholder");
|
||
}
|
||
}
|
||
|
||
const filteredAssistants = mockAssistants.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,
|
||
}));
|
||
}
|
||
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
|
||
variant="destructive"
|
||
className="rounded-lg"
|
||
>
|
||
<Trash2 size={14} />
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{filteredAssistants.length === 0 && (
|
||
<div className="px-5 py-12 text-center">
|
||
<div className="font-medium text-foreground">
|
||
未找到匹配的助手
|
||
</div>
|
||
<div className="mt-2 text-sm text-muted-foreground">
|
||
请调整关键词或筛选条件后再试。
|
||
</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-[920px] 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">
|
||
{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">
|
||
<EditableTitle
|
||
value={difyForm.name}
|
||
onChange={(value) => updateDifyForm("name", value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex shrink-0 gap-2">
|
||
<Button
|
||
variant="outline"
|
||
className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
onClick={() => setView("list")}
|
||
>
|
||
<ChevronLeft size={16} />
|
||
返回
|
||
</Button>
|
||
|
||
<Button className="gap-2">
|
||
<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"
|
||
/>
|
||
<InputField
|
||
label="API Key"
|
||
value={difyForm.apiKey}
|
||
onChange={(value) => updateDifyForm("apiKey", value)}
|
||
placeholder="请输入 Dify API Key"
|
||
type="password"
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Brain size={18} />}
|
||
title="语音配置"
|
||
description="配置本平台的语音识别与播报音色。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
||
>
|
||
<SelectField
|
||
label="语音识别"
|
||
value={difyForm.asr}
|
||
onChange={(value) => updateDifyForm("asr", value)}
|
||
options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]}
|
||
/>
|
||
<SelectField
|
||
label="播报声音"
|
||
value={difyForm.voice}
|
||
onChange={(value) => updateDifyForm("voice", value)}
|
||
options={["晓宁", "晓美", "晓宇", "晓晨"]}
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Sparkles size={18} />}
|
||
title="交互策略"
|
||
description="设置实时视频对话时的交互体验"
|
||
>
|
||
<ToggleRow
|
||
title="允许用户打断"
|
||
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
||
checked={difyForm.enableInterrupt}
|
||
onChange={(checked) =>
|
||
updateDifyForm("enableInterrupt", checked)
|
||
}
|
||
/>
|
||
</SectionCard>
|
||
</div>
|
||
|
||
<DebugDrawer mode={debugMode} onModeChange={setDebugMode} />
|
||
</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">
|
||
<EditableTitle
|
||
value={fastGptForm.name}
|
||
onChange={(value) => updateFastGptForm("name", value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex shrink-0 gap-2">
|
||
<Button
|
||
variant="outline"
|
||
className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
onClick={() => setView("list")}
|
||
>
|
||
<ChevronLeft size={16} />
|
||
返回
|
||
</Button>
|
||
|
||
<Button className="gap-2">
|
||
<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"
|
||
/>
|
||
<InputField
|
||
label="API Key"
|
||
value={fastGptForm.apiKey}
|
||
onChange={(value) => updateFastGptForm("apiKey", value)}
|
||
placeholder="请输入 FastGPT API Key"
|
||
type="password"
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Brain size={18} />}
|
||
title="语音配置"
|
||
description="配置本平台的语音识别与播报音色。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
||
>
|
||
<SelectField
|
||
label="语音识别"
|
||
value={fastGptForm.asr}
|
||
onChange={(value) => updateFastGptForm("asr", value)}
|
||
options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]}
|
||
/>
|
||
<SelectField
|
||
label="播报声音"
|
||
value={fastGptForm.voice}
|
||
onChange={(value) => updateFastGptForm("voice", value)}
|
||
options={["晓宁", "晓美", "晓宇", "晓晨"]}
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Sparkles size={18} />}
|
||
title="交互策略"
|
||
description="设置实时视频对话时的交互体验"
|
||
>
|
||
<ToggleRow
|
||
title="允许用户打断"
|
||
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
||
checked={fastGptForm.enableInterrupt}
|
||
onChange={(checked) =>
|
||
updateFastGptForm("enableInterrupt", checked)
|
||
}
|
||
/>
|
||
</SectionCard>
|
||
</div>
|
||
|
||
<DebugDrawer mode={debugMode} onModeChange={setDebugMode} />
|
||
</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">
|
||
<EditableTitle
|
||
value={form.name}
|
||
onChange={(value) => updateForm("name", value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex shrink-0 gap-2">
|
||
<Button
|
||
variant="outline"
|
||
className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
onClick={() => setView("list")}
|
||
>
|
||
<ChevronLeft size={16} />
|
||
返回
|
||
</Button>
|
||
|
||
<Button className="gap-2">
|
||
<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="配置语音管线的大语言模型、语音识别与播报音色"
|
||
>
|
||
<SelectField
|
||
label="大语言模型"
|
||
value={form.model}
|
||
onChange={(value) => updateForm("model", value)}
|
||
options={["DeepSeek-V3", "Qwen-Max", "Kimi-K2", "Doubao-Pro", "GPT-4o"]}
|
||
/>
|
||
<SelectField
|
||
label="语音识别"
|
||
value={form.asr}
|
||
onChange={(value) => updateForm("asr", value)}
|
||
options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]}
|
||
/>
|
||
<SelectField
|
||
label="播报声音"
|
||
value={form.voice}
|
||
onChange={(value) => updateForm("voice", value)}
|
||
options={["晓宁", "晓美", "晓宇", "晓晨"]}
|
||
/>
|
||
</SectionCard>
|
||
) : (
|
||
<SectionCard
|
||
icon={<Brain size={18} />}
|
||
title="模型配置"
|
||
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
||
>
|
||
<SelectField
|
||
value={form.realtimeModel}
|
||
onChange={(value) => updateForm("realtimeModel", value)}
|
||
options={["gpt-realtime-2", "gpt-realtime", "gpt-4o-realtime-preview"]}
|
||
/>
|
||
</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="选择助手回答时可检索的业务知识来源"
|
||
>
|
||
<SelectField
|
||
value={form.knowledgeBase}
|
||
onChange={(value) => updateForm("knowledgeBase", value)}
|
||
options={["无", "政务政策知识库", "售后知识库", "教育课程知识库", "医疗问答知识库"]}
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Sparkles size={18} />}
|
||
title="交互策略"
|
||
description="设置实时视频对话时的交互体验"
|
||
>
|
||
<ToggleRow
|
||
title="允许用户打断"
|
||
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
||
checked={form.enableInterrupt}
|
||
onChange={(checked) => updateForm("enableInterrupt", checked)}
|
||
/>
|
||
</SectionCard>
|
||
</div>
|
||
|
||
<DebugDrawer mode={debugMode} onModeChange={setDebugMode} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DebugDrawer({
|
||
mode,
|
||
onModeChange,
|
||
}: {
|
||
mode: DebugMode;
|
||
onModeChange: (mode: DebugMode) => void;
|
||
}) {
|
||
const modeTabs: { key: DebugMode; label: string; icon: React.ReactNode }[] = [
|
||
{ key: "text", label: "文字测试", icon: <MessageCircle size={15} /> },
|
||
{ key: "voice", label: "语音测试", icon: <Mic size={15} /> },
|
||
];
|
||
|
||
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="shrink-0 border-b border-hairline p-4">
|
||
<div className="mb-3 text-sm font-medium text-foreground">调试与预览</div>
|
||
<div className="flex gap-1 rounded-full bg-surface-strong p-1">
|
||
{modeTabs.map((tab) => (
|
||
<button
|
||
key={tab.key}
|
||
type="button"
|
||
onClick={() => onModeChange(tab.key)}
|
||
className={[
|
||
"flex flex-1 items-center justify-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors",
|
||
mode === tab.key
|
||
? "bg-card text-foreground shadow-sm"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
].join(" ")}
|
||
>
|
||
{tab.icon}
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{mode === "text" ? <DebugTextPanel /> : <DebugVoicePanel />}
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function DebugTextPanel() {
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||
<div className="flex max-w-[85%] flex-col gap-1">
|
||
<div className="rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
|
||
您好,我是 AI 视频助手,请问有什么可以帮您?
|
||
</div>
|
||
<span className="px-1 text-xs text-muted-soft">助手</span>
|
||
</div>
|
||
|
||
<div className="flex max-w-[85%] flex-col items-end gap-1 self-end">
|
||
<div className="rounded-2xl rounded-tr-sm bg-primary px-4 py-2.5 text-sm leading-6 text-primary-foreground">
|
||
我想咨询一下社保卡的办理流程。
|
||
</div>
|
||
<span className="px-1 text-xs text-muted-soft">我</span>
|
||
</div>
|
||
|
||
<div className="flex max-w-[85%] flex-col gap-1">
|
||
<div className="rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
|
||
社保卡可通过线上或线下渠道办理。线上可在政务服务 App
|
||
提交申请,线下可前往社保经办网点……
|
||
</div>
|
||
<span className="px-1 text-xs text-muted-soft">助手</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="shrink-0 border-t border-hairline p-3">
|
||
<div className="flex items-end gap-2">
|
||
<Textarea
|
||
rows={1}
|
||
placeholder="输入测试消息…"
|
||
className="max-h-28 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 DebugVoicePanel() {
|
||
const [recording, setRecording] = useState(false);
|
||
const [micError, setMicError] = useState(false);
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 p-6 text-center">
|
||
<VoiceVisualizer
|
||
active={recording}
|
||
size={200}
|
||
onError={() => {
|
||
setMicError(true);
|
||
setRecording(false);
|
||
}}
|
||
/>
|
||
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">
|
||
{recording ? "正在聆听…" : "点击开始语音测试"}
|
||
</div>
|
||
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
|
||
{micError
|
||
? "无法访问麦克风,请检查浏览器权限后重试。"
|
||
: "点击下方按钮开始录音,实时识别结果将显示在下方。"}
|
||
</p>
|
||
</div>
|
||
|
||
<Button
|
||
onClick={() => {
|
||
setMicError(false);
|
||
setRecording((value) => !value);
|
||
}}
|
||
variant={recording ? "outline" : "default"}
|
||
className={[
|
||
"gap-2",
|
||
recording
|
||
? "border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
: "",
|
||
].join(" ")}
|
||
>
|
||
{recording ? <Square size={16} /> : <Mic size={16} />}
|
||
{recording ? "结束录音" : "开始语音测试"}
|
||
</Button>
|
||
|
||
<div className="w-full rounded-xl border border-hairline bg-canvas-soft p-4 text-left">
|
||
<div className="caption-label mb-2 text-muted-soft">实时转写</div>
|
||
<p className="text-sm leading-6 text-muted-soft">
|
||
{recording ? "聆听中,请开始说话…" : "等待语音输入…"}
|
||
</p>
|
||
</div>
|
||
</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 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>
|
||
);
|
||
}
|
||
|
||
function SelectField({
|
||
label,
|
||
value,
|
||
options,
|
||
onChange,
|
||
}: {
|
||
label?: string;
|
||
value: string;
|
||
options: 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} onValueChange={onChange}>
|
||
<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">
|
||
{options.map((item) => (
|
||
<SelectItem key={item} value={item}>
|
||
{item}
|
||
</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>
|
||
);
|
||
}
|