2026-06-08 13:51:28 +08:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
Bot,
|
|
|
|
|
Boxes,
|
|
|
|
|
Brain,
|
|
|
|
|
Check,
|
2026-06-09 08:31:19 +08:00
|
|
|
Copy,
|
2026-06-08 13:51:28 +08:00
|
|
|
Database,
|
2026-06-09 12:49:18 +08:00
|
|
|
Eye,
|
|
|
|
|
EyeOff,
|
2026-06-08 13:51:28 +08:00
|
|
|
MessageSquareText,
|
|
|
|
|
MoreHorizontal,
|
|
|
|
|
Pencil,
|
|
|
|
|
Plus,
|
|
|
|
|
Rocket,
|
|
|
|
|
Search,
|
|
|
|
|
Sparkles,
|
|
|
|
|
Trash2,
|
|
|
|
|
Workflow,
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Save,
|
|
|
|
|
Mic,
|
|
|
|
|
Send,
|
|
|
|
|
HelpCircle,
|
|
|
|
|
Waypoints,
|
|
|
|
|
AudioLines,
|
|
|
|
|
Terminal,
|
2026-06-09 09:48:43 +08:00
|
|
|
Loader2,
|
2026-06-09 15:28:24 +08:00
|
|
|
PhoneOff,
|
2026-06-09 16:28:45 +08:00
|
|
|
Orbit,
|
|
|
|
|
Waves,
|
2026-06-08 13:51:28 +08:00
|
|
|
} 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";
|
2026-06-09 16:28:45 +08:00
|
|
|
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
|
2026-06-10 10:17:46 +08:00
|
|
|
import { NebulaVisualizer } from "@/components/ui/nebula-visualizer";
|
2026-06-09 16:28:45 +08:00
|
|
|
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
|
|
|
|
|
import { WaveVisualizer } from "@/components/ui/wave-visualizer";
|
2026-06-08 13:51:28 +08:00
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from "@/components/ui/card";
|
2026-06-09 09:48:43 +08:00
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
2026-06-10 14:39:52 +08:00
|
|
|
import { useRouter } from "next/navigation";
|
2026-06-09 09:48:43 +08:00
|
|
|
import {
|
|
|
|
|
assistantsApi,
|
|
|
|
|
credentialsApi,
|
|
|
|
|
knowledgeBasesApi,
|
|
|
|
|
type Assistant,
|
|
|
|
|
type AssistantType as ApiAssistantType,
|
|
|
|
|
type AssistantUpsert,
|
|
|
|
|
type Credential,
|
|
|
|
|
type KnowledgeBase,
|
|
|
|
|
} from "@/lib/api";
|
2026-06-10 15:11:34 +08:00
|
|
|
import { useVoicePreview, type ChatMessage } from "@/hooks/use-voice-preview";
|
2026-06-08 13:51:28 +08:00
|
|
|
|
|
|
|
|
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;
|
2026-06-09 12:59:07 +08:00
|
|
|
model: string;
|
2026-06-08 13:51:28 +08:00
|
|
|
asr: string;
|
|
|
|
|
voice: string;
|
|
|
|
|
enableInterrupt: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type AssistantType = "提示词" | "工作流" | "Dify" | "FastGPT" | "OpenCode";
|
|
|
|
|
|
|
|
|
|
const assistantTypes: AssistantType[] = [
|
|
|
|
|
"提示词",
|
|
|
|
|
"工作流",
|
|
|
|
|
"Dify",
|
|
|
|
|
"FastGPT",
|
|
|
|
|
"OpenCode",
|
|
|
|
|
];
|
|
|
|
|
|
2026-06-09 09:48:43 +08:00
|
|
|
// 后端 type(英文) ↔ 列表展示标签(中文)
|
|
|
|
|
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
|
|
|
|
prompt: "提示词",
|
|
|
|
|
workflow: "工作流",
|
|
|
|
|
dify: "Dify",
|
|
|
|
|
fastgpt: "FastGPT",
|
|
|
|
|
opencode: "OpenCode",
|
|
|
|
|
};
|
2026-06-10 14:39:52 +08:00
|
|
|
const typeFromLabel: Record<AssistantType, ApiAssistantType> = {
|
|
|
|
|
提示词: "prompt",
|
|
|
|
|
工作流: "workflow",
|
|
|
|
|
Dify: "dify",
|
|
|
|
|
FastGPT: "fastgpt",
|
|
|
|
|
OpenCode: "opencode",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 后端 type → 编辑器视图(工作流暂为占位页)
|
|
|
|
|
const typeToView = {
|
|
|
|
|
prompt: "create",
|
|
|
|
|
dify: "create-dify",
|
|
|
|
|
fastgpt: "create-fastgpt",
|
|
|
|
|
opencode: "create-opencode",
|
|
|
|
|
workflow: "placeholder",
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
type View = "list" | "choose" | "loading" | (typeof typeToView)[ApiAssistantType];
|
|
|
|
|
|
|
|
|
|
// 路由驱动的页面模式:
|
|
|
|
|
// /assistants → list | /assistants/new → choose(引导,确认即建库) | /assistants/[id] → edit
|
|
|
|
|
export type AssistantPageProps =
|
|
|
|
|
| { mode: "list" }
|
|
|
|
|
| { mode: "choose" }
|
|
|
|
|
| { mode: "edit"; assistantId: string };
|
|
|
|
|
|
|
|
|
|
// 各类型的空白表单模板(新建用)
|
|
|
|
|
function blankPromptForm(name: string): AssistantForm {
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
greeting: "",
|
|
|
|
|
prompt: "",
|
|
|
|
|
runtimeMode: "pipeline",
|
|
|
|
|
realtimeModel: "",
|
|
|
|
|
model: "",
|
|
|
|
|
asr: "",
|
|
|
|
|
voice: "",
|
|
|
|
|
knowledgeBase: "",
|
|
|
|
|
enableInterrupt: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function blankFastGptForm(name: string): FastGptForm {
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
appId: "",
|
|
|
|
|
apiUrl: "",
|
|
|
|
|
apiKey: "",
|
|
|
|
|
asr: "",
|
|
|
|
|
voice: "",
|
|
|
|
|
enableInterrupt: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function blankDifyForm(name: string): DifyForm {
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
apiUrl: "",
|
|
|
|
|
apiKey: "",
|
|
|
|
|
asr: "",
|
|
|
|
|
voice: "",
|
|
|
|
|
enableInterrupt: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function blankOpenCodeForm(name: string): OpenCodeForm {
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
prompt: "",
|
|
|
|
|
apiUrl: "",
|
|
|
|
|
apiKey: "",
|
|
|
|
|
model: "",
|
|
|
|
|
asr: "",
|
|
|
|
|
voice: "",
|
|
|
|
|
enableInterrupt: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-06-09 09:48:43 +08:00
|
|
|
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())}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 13:51:28 +08:00
|
|
|
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];
|
|
|
|
|
|
2026-06-10 14:39:52 +08:00
|
|
|
export function AssistantPage(props: AssistantPageProps) {
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
// 编辑中的助手 id(来自路由)
|
|
|
|
|
const editingId = props.mode === "edit" ? props.assistantId : null;
|
|
|
|
|
|
|
|
|
|
const [form, setForm] = useState<AssistantForm>(() => blankPromptForm(""));
|
|
|
|
|
const [fastGptForm, setFastGptForm] = useState<FastGptForm>(() =>
|
|
|
|
|
blankFastGptForm(""),
|
|
|
|
|
);
|
|
|
|
|
const [difyForm, setDifyForm] = useState<DifyForm>(() => blankDifyForm(""));
|
|
|
|
|
const [openCodeForm, setOpenCodeForm] = useState<OpenCodeForm>(() =>
|
|
|
|
|
blankOpenCodeForm(""),
|
|
|
|
|
);
|
2026-06-09 09:48:43 +08:00
|
|
|
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
|
|
|
|
const [listLoading, setListLoading] = useState(true);
|
|
|
|
|
const [listError, setListError] = useState<string | null>(null);
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
const [saveError, setSaveError] = useState<string | null>(null);
|
2026-06-10 14:39:52 +08:00
|
|
|
// 编辑模式:加载指定 id 助手失败时的错误
|
|
|
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
|
|
|
// 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥")
|
|
|
|
|
const [storedApiKeyMask, setStoredApiKeyMask] = useState("");
|
2026-06-09 09:48:43 +08:00
|
|
|
// 下拉数据源:模型凭证 + 知识库
|
|
|
|
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
|
|
|
|
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
2026-06-10 14:39:52 +08:00
|
|
|
// 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换
|
|
|
|
|
const [view, setView] = useState<View>(() => {
|
|
|
|
|
if (props.mode === "choose") return "choose";
|
|
|
|
|
if (props.mode === "edit") return "loading";
|
|
|
|
|
return "list";
|
|
|
|
|
});
|
2026-06-08 13:51:28 +08:00
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
2026-06-10 14:39:52 +08:00
|
|
|
// choose 步骤的草稿:名称与已选类型,确认后直接建库并进入编辑页
|
|
|
|
|
// (工作流占位页也用它展示名称与类型)
|
2026-06-08 13:51:28 +08:00
|
|
|
const [draftName, setDraftName] = useState("");
|
|
|
|
|
const [draftType, setDraftType] = useState<AssistantType | null>(null);
|
2026-06-10 14:39:52 +08:00
|
|
|
// 引导页:创建请求进行中 / 创建失败
|
|
|
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
|
|
|
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
|
|
|
|
|
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
2026-06-08 13:51:28 +08:00
|
|
|
|
2026-06-09 09:48:43 +08:00
|
|
|
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(() => {
|
2026-06-10 14:39:52 +08:00
|
|
|
// 列表页挂载时拉取助手列表(与后端同步)
|
|
|
|
|
if (props.mode !== "list") return;
|
2026-06-09 09:48:43 +08:00
|
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
|
|
|
void loadAssistants();
|
2026-06-10 14:39:52 +08:00
|
|
|
}, [props.mode, loadAssistants]);
|
2026-06-09 09:48:43 +08:00
|
|
|
|
|
|
|
|
// 进入创建/编辑前加载下拉数据源(模型凭证 + 知识库)
|
|
|
|
|
const loadResources = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const [creds, kbs] = await Promise.all([
|
|
|
|
|
credentialsApi.list(),
|
|
|
|
|
knowledgeBasesApi.list(),
|
|
|
|
|
]);
|
|
|
|
|
setCredentials(creds);
|
|
|
|
|
setKnowledgeBases(kbs);
|
|
|
|
|
} catch {
|
|
|
|
|
// 拉取失败时下拉为空,不阻塞表单
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-06-10 14:39:52 +08:00
|
|
|
// 编辑页挂载时加载下拉数据源
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (props.mode !== "edit") return;
|
|
|
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
|
|
|
void loadResources();
|
|
|
|
|
}, [props.mode, loadResources]);
|
|
|
|
|
|
2026-06-09 09:48:43 +08:00
|
|
|
// 按资源类型生成 {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 }));
|
|
|
|
|
|
2026-06-08 13:51:28 +08:00
|
|
|
function startCreate() {
|
2026-06-10 14:39:52 +08:00
|
|
|
router.push("/assistants/new");
|
2026-06-09 09:48:43 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 把后端 Assistant 回填进提示词表单(注意:model/asr/voice 等存的是凭证 id)
|
2026-06-10 14:39:52 +08:00
|
|
|
// 返回回填后的表单,供调用方记录"已保存基线"
|
|
|
|
|
function fillPromptForm(a: Assistant): AssistantForm {
|
|
|
|
|
const next: AssistantForm = {
|
2026-06-09 09:48:43 +08:00
|
|
|
name: a.name,
|
|
|
|
|
greeting: a.greeting,
|
2026-06-09 10:37:29 +08:00
|
|
|
prompt: a.prompt,
|
2026-06-09 09:48:43 +08:00
|
|
|
runtimeMode: a.runtimeMode,
|
|
|
|
|
realtimeModel: a.realtimeCredentialId ?? "",
|
|
|
|
|
model: a.llmCredentialId ?? "",
|
|
|
|
|
asr: a.asrCredentialId ?? "",
|
|
|
|
|
voice: a.ttsCredentialId ?? "",
|
|
|
|
|
knowledgeBase: a.knowledgeBaseId ?? "",
|
|
|
|
|
enableInterrupt: a.enableInterrupt,
|
2026-06-10 14:39:52 +08:00
|
|
|
};
|
|
|
|
|
setForm(next);
|
|
|
|
|
return next;
|
2026-06-09 09:48:43 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-10 14:39:52 +08:00
|
|
|
// 编辑:跳转到 /assistants/[id],由编辑页按助手类型回填表单
|
|
|
|
|
function handleEdit(assistant: AssistantListItem) {
|
|
|
|
|
router.push(`/assistants/${assistant.id}`);
|
2026-06-08 13:51:28 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-10 14:39:52 +08:00
|
|
|
// 引导页确认:直接创建到数据库拿到 id,再进入该助手的编辑页
|
|
|
|
|
async function confirmType() {
|
|
|
|
|
if (!draftName.trim() || !draftType || creating) {
|
2026-06-08 13:51:28 +08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 14:39:52 +08:00
|
|
|
setCreating(true);
|
|
|
|
|
setCreateError(null);
|
|
|
|
|
try {
|
|
|
|
|
const created = await assistantsApi.create(
|
|
|
|
|
baseUpsert({
|
|
|
|
|
name: draftName.trim(),
|
|
|
|
|
type: typeFromLabel[draftType],
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
router.push(`/assistants/${created.id}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setCreateError(error instanceof Error ? error.message : "创建失败");
|
|
|
|
|
setCreating(false);
|
2026-06-08 13:51:28 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:48:43 +08:00
|
|
|
// 复制助手:服务端整行复制(含真 key,密钥不经浏览器)
|
|
|
|
|
async function handleDuplicate(assistant: AssistantListItem) {
|
|
|
|
|
try {
|
|
|
|
|
await assistantsApi.duplicate(assistant.id);
|
|
|
|
|
await loadAssistants();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setListError(error instanceof Error ? error.message : "复制失败");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-09 08:31:19 +08:00
|
|
|
|
2026-06-09 09:48:43 +08:00
|
|
|
// 删除助手
|
|
|
|
|
async function handleDelete(id: string) {
|
|
|
|
|
try {
|
|
|
|
|
await assistantsApi.remove(id);
|
|
|
|
|
await loadAssistants();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setListError(error instanceof Error ? error.message : "删除失败");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-09 08:31:19 +08:00
|
|
|
|
2026-06-09 10:37:29 +08:00
|
|
|
// 平铺字段的空白 upsert,各类型按需覆盖(后端会按 type 清掉无关字段)
|
|
|
|
|
function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
|
|
|
|
|
return {
|
|
|
|
|
name: "",
|
2026-06-09 09:48:43 +08:00
|
|
|
type: "prompt",
|
|
|
|
|
runtimeMode: "pipeline",
|
|
|
|
|
greeting: "",
|
2026-06-09 10:37:29 +08:00
|
|
|
enableInterrupt: true,
|
|
|
|
|
llmCredentialId: null,
|
|
|
|
|
asrCredentialId: null,
|
|
|
|
|
ttsCredentialId: null,
|
2026-06-09 09:48:43 +08:00
|
|
|
realtimeCredentialId: null,
|
|
|
|
|
knowledgeBaseId: null,
|
2026-06-09 10:37:29 +08:00
|
|
|
prompt: "",
|
|
|
|
|
apiUrl: "",
|
|
|
|
|
apiKey: "",
|
|
|
|
|
appId: "",
|
|
|
|
|
graph: {},
|
|
|
|
|
...over,
|
2026-06-09 09:48:43 +08:00
|
|
|
};
|
2026-06-09 10:37:29 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-10 14:39:52 +08:00
|
|
|
// 保存:停留在编辑页,刷新密钥掩码并把当前表单记为已保存基线(按钮随之置灰)
|
2026-06-09 10:37:29 +08:00
|
|
|
async function save(payload: AssistantUpsert) {
|
2026-06-10 14:39:52 +08:00
|
|
|
if (!editingId) return;
|
2026-06-09 10:37:29 +08:00
|
|
|
setSaving(true);
|
|
|
|
|
setSaveError(null);
|
2026-06-09 09:48:43 +08:00
|
|
|
try {
|
2026-06-10 14:39:52 +08:00
|
|
|
const updated = await assistantsApi.update(editingId, payload);
|
|
|
|
|
setStoredApiKeyMask(updated.apiKey ?? "");
|
|
|
|
|
// 密钥输入框清空(空值=保留已存密钥),并以清空后的表单为新基线
|
|
|
|
|
if (view === "create") {
|
|
|
|
|
setSavedSnapshot(JSON.stringify(form));
|
|
|
|
|
} else if (view === "create-dify") {
|
|
|
|
|
const next = { ...difyForm, apiKey: "" };
|
|
|
|
|
setDifyForm(next);
|
|
|
|
|
setSavedSnapshot(JSON.stringify(next));
|
|
|
|
|
} else if (view === "create-fastgpt") {
|
|
|
|
|
const next = { ...fastGptForm, apiKey: "" };
|
|
|
|
|
setFastGptForm(next);
|
|
|
|
|
setSavedSnapshot(JSON.stringify(next));
|
|
|
|
|
} else if (view === "create-opencode") {
|
|
|
|
|
const next = { ...openCodeForm, apiKey: "" };
|
|
|
|
|
setOpenCodeForm(next);
|
|
|
|
|
setSavedSnapshot(JSON.stringify(next));
|
|
|
|
|
}
|
2026-06-09 09:48:43 +08:00
|
|
|
} catch (error) {
|
|
|
|
|
setSaveError(error instanceof Error ? error.message : "保存失败");
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 10:37:29 +08:00
|
|
|
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 ----
|
2026-06-10 14:39:52 +08:00
|
|
|
function fillDifyForm(a: Assistant): DifyForm {
|
|
|
|
|
const next: DifyForm = {
|
2026-06-09 10:37:29 +08:00
|
|
|
name: a.name,
|
|
|
|
|
apiUrl: a.apiUrl,
|
2026-06-09 12:49:18 +08:00
|
|
|
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
|
|
|
|
apiKey: "",
|
2026-06-09 10:37:29 +08:00
|
|
|
asr: a.asrCredentialId ?? "",
|
|
|
|
|
voice: a.ttsCredentialId ?? "",
|
|
|
|
|
enableInterrupt: a.enableInterrupt,
|
2026-06-10 14:39:52 +08:00
|
|
|
};
|
|
|
|
|
setDifyForm(next);
|
|
|
|
|
return next;
|
2026-06-09 10:37:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:48:43 +08:00
|
|
|
// ---- FastGPT ----
|
2026-06-10 14:39:52 +08:00
|
|
|
function fillFastGptForm(a: Assistant): FastGptForm {
|
|
|
|
|
const next: FastGptForm = {
|
2026-06-09 09:48:43 +08:00
|
|
|
name: a.name,
|
2026-06-09 10:37:29 +08:00
|
|
|
appId: a.appId,
|
|
|
|
|
apiUrl: a.apiUrl,
|
2026-06-09 12:49:18 +08:00
|
|
|
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
|
|
|
|
apiKey: "",
|
2026-06-09 09:48:43 +08:00
|
|
|
asr: a.asrCredentialId ?? "",
|
|
|
|
|
voice: a.ttsCredentialId ?? "",
|
|
|
|
|
enableInterrupt: a.enableInterrupt,
|
2026-06-10 14:39:52 +08:00
|
|
|
};
|
|
|
|
|
setFastGptForm(next);
|
|
|
|
|
return next;
|
2026-06-09 09:48:43 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 10:37:29 +08:00
|
|
|
function handleSaveFastGpt() {
|
|
|
|
|
void save(
|
|
|
|
|
baseUpsert({
|
|
|
|
|
name: fastGptForm.name.trim(),
|
|
|
|
|
type: "fastgpt",
|
|
|
|
|
enableInterrupt: fastGptForm.enableInterrupt,
|
|
|
|
|
asrCredentialId: fastGptForm.asr || null,
|
|
|
|
|
ttsCredentialId: fastGptForm.voice || null,
|
2026-06-09 09:48:43 +08:00
|
|
|
appId: fastGptForm.appId,
|
|
|
|
|
apiUrl: fastGptForm.apiUrl,
|
|
|
|
|
apiKey: fastGptForm.apiKey,
|
2026-06-09 10:37:29 +08:00
|
|
|
}),
|
|
|
|
|
);
|
2026-06-09 09:48:43 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 12:59:07 +08:00
|
|
|
// ---- OpenCode ----
|
2026-06-10 14:39:52 +08:00
|
|
|
function fillOpenCodeForm(a: Assistant): OpenCodeForm {
|
|
|
|
|
const next: OpenCodeForm = {
|
2026-06-09 12:59:07 +08:00
|
|
|
name: a.name,
|
|
|
|
|
prompt: a.prompt,
|
|
|
|
|
apiUrl: a.apiUrl,
|
|
|
|
|
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
|
|
|
|
apiKey: "",
|
2026-06-10 10:17:46 +08:00
|
|
|
model: a.llmCredentialId ?? "",
|
2026-06-09 12:59:07 +08:00
|
|
|
asr: a.asrCredentialId ?? "",
|
|
|
|
|
voice: a.ttsCredentialId ?? "",
|
|
|
|
|
enableInterrupt: a.enableInterrupt,
|
2026-06-10 14:39:52 +08:00
|
|
|
};
|
|
|
|
|
setOpenCodeForm(next);
|
|
|
|
|
return next;
|
2026-06-09 12:59:07 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-10 14:39:52 +08:00
|
|
|
// 编辑模式:按路由中的 id 拉取助手,回填对应类型的表单后再切换视图
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (props.mode !== "edit" || !editingId) return;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
|
|
|
|
const assistant = await assistantsApi.get(editingId);
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setStoredApiKeyMask(assistant.apiKey ?? "");
|
|
|
|
|
if (assistant.type === "prompt") {
|
|
|
|
|
setSavedSnapshot(JSON.stringify(fillPromptForm(assistant)));
|
|
|
|
|
} else if (assistant.type === "fastgpt") {
|
|
|
|
|
setSavedSnapshot(JSON.stringify(fillFastGptForm(assistant)));
|
|
|
|
|
} else if (assistant.type === "dify") {
|
|
|
|
|
setSavedSnapshot(JSON.stringify(fillDifyForm(assistant)));
|
|
|
|
|
} else if (assistant.type === "opencode") {
|
|
|
|
|
setSavedSnapshot(JSON.stringify(fillOpenCodeForm(assistant)));
|
|
|
|
|
} else {
|
|
|
|
|
// 工作流:暂时显示占位页
|
|
|
|
|
setDraftName(assistant.name);
|
|
|
|
|
setDraftType(typeToLabel[assistant.type]);
|
|
|
|
|
}
|
|
|
|
|
setView(typeToView[assistant.type]);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setLoadError(
|
|
|
|
|
error instanceof Error ? error.message : "加载助手失败",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [props.mode, editingId]);
|
|
|
|
|
|
2026-06-09 12:59:07 +08:00
|
|
|
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,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 14:39:52 +08:00
|
|
|
// 当前编辑器表单相对已保存基线是否有改动(决定保存按钮是否可点)
|
|
|
|
|
const activeFormJson =
|
|
|
|
|
view === "create"
|
|
|
|
|
? JSON.stringify(form)
|
|
|
|
|
: view === "create-dify"
|
|
|
|
|
? JSON.stringify(difyForm)
|
|
|
|
|
: view === "create-fastgpt"
|
|
|
|
|
? JSON.stringify(fastGptForm)
|
|
|
|
|
: view === "create-opencode"
|
|
|
|
|
? JSON.stringify(openCodeForm)
|
|
|
|
|
: null;
|
|
|
|
|
const dirty =
|
|
|
|
|
activeFormJson !== null &&
|
|
|
|
|
savedSnapshot !== null &&
|
|
|
|
|
activeFormJson !== savedSnapshot;
|
|
|
|
|
|
2026-06-09 09:48:43 +08:00
|
|
|
const listItems: AssistantListItem[] = assistants.map((a) => ({
|
|
|
|
|
id: a.id,
|
|
|
|
|
name: a.name,
|
|
|
|
|
type: typeToLabel[a.type],
|
|
|
|
|
updatedAt: formatTimestamp(a.updatedAt),
|
|
|
|
|
}));
|
|
|
|
|
const filteredAssistants = listItems.filter((assistant) => {
|
2026-06-08 13:51:28 +08:00
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
}
|
2026-06-10 14:39:52 +08:00
|
|
|
if (view === "loading") {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
{loadError ? (
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<div className="font-medium text-destructive">加载助手失败</div>
|
|
|
|
|
<div className="mt-2 text-sm text-muted-foreground">
|
|
|
|
|
{loadError}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="mt-4 border-hairline-strong text-muted-foreground hover:text-foreground"
|
|
|
|
|
onClick={() => router.push("/assistants")}
|
|
|
|
|
>
|
|
|
|
|
返回列表
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
|
|
<Loader2 size={16} className="animate-spin" />
|
|
|
|
|
正在加载助手…
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 13:51:28 +08:00
|
|
|
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"
|
|
|
|
|
>
|
2026-06-09 08:31:19 +08:00
|
|
|
<DropdownMenuItem
|
|
|
|
|
className="rounded-lg"
|
|
|
|
|
onSelect={() => handleDuplicate(assistant)}
|
|
|
|
|
>
|
|
|
|
|
<Copy size={14} />
|
|
|
|
|
复制
|
|
|
|
|
</DropdownMenuItem>
|
2026-06-08 13:51:28 +08:00
|
|
|
<DropdownMenuItem
|
|
|
|
|
variant="destructive"
|
|
|
|
|
className="rounded-lg"
|
2026-06-09 09:48:43 +08:00
|
|
|
onSelect={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void handleDelete(assistant.id);
|
|
|
|
|
}}
|
2026-06-08 13:51:28 +08:00
|
|
|
>
|
|
|
|
|
<Trash2 size={14} />
|
|
|
|
|
删除
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
|
2026-06-09 09:48:43 +08:00
|
|
|
{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 && (
|
2026-06-08 13:51:28 +08:00
|
|
|
<div className="px-5 py-12 text-center">
|
|
|
|
|
<div className="font-medium text-foreground">
|
2026-06-09 09:48:43 +08:00
|
|
|
{listItems.length === 0
|
|
|
|
|
? "暂无助手"
|
|
|
|
|
: "未找到匹配的助手"}
|
2026-06-08 13:51:28 +08:00
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 text-sm text-muted-foreground">
|
2026-06-09 09:48:43 +08:00
|
|
|
{listItems.length === 0
|
|
|
|
|
? "点击右上角「创建助手」开始。"
|
|
|
|
|
: "请调整关键词或筛选条件后再试。"}
|
2026-06-08 13:51:28 +08:00
|
|
|
</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") {
|
|
|
|
|
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">
|
2026-06-10 14:39:52 +08:00
|
|
|
先为助手取个名字,再选择构建方式。确认后将立即创建助手并进入编辑页。
|
2026-06-08 13:51:28 +08:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="lg"
|
|
|
|
|
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
2026-06-10 14:39:52 +08:00
|
|
|
onClick={() => router.push("/assistants")}
|
2026-06-08 13:51:28 +08:00
|
|
|
>
|
|
|
|
|
<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">
|
2026-06-10 14:39:52 +08:00
|
|
|
{createError && (
|
|
|
|
|
<span className="text-xs text-destructive">{createError}</span>
|
|
|
|
|
)}
|
2026-06-08 13:51:28 +08:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="lg"
|
|
|
|
|
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
2026-06-10 14:39:52 +08:00
|
|
|
disabled={creating}
|
|
|
|
|
onClick={() => router.push("/assistants")}
|
2026-06-08 13:51:28 +08:00
|
|
|
>
|
|
|
|
|
取消
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="lg"
|
|
|
|
|
className="gap-2"
|
2026-06-10 14:39:52 +08:00
|
|
|
disabled={!draftName.trim() || !draftType || creating}
|
|
|
|
|
onClick={() => void confirmType()}
|
2026-06-08 13:51:28 +08:00
|
|
|
>
|
2026-06-10 14:39:52 +08:00
|
|
|
{creating ? (
|
|
|
|
|
<Loader2 size={16} className="animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Rocket size={16} />
|
|
|
|
|
)}
|
|
|
|
|
创建助手
|
2026-06-08 13:51:28 +08:00
|
|
|
</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"
|
2026-06-10 14:39:52 +08:00
|
|
|
onClick={() => router.push("/assistants")}
|
2026-06-08 13:51:28 +08:00
|
|
|
>
|
|
|
|
|
<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">
|
2026-06-10 14:39:52 +08:00
|
|
|
<EditorBackButton onClick={() => router.push("/assistants")} />
|
2026-06-08 13:51:28 +08:00
|
|
|
<EditableTitle
|
|
|
|
|
value={difyForm.name}
|
|
|
|
|
onChange={(value) => updateDifyForm("name", value)}
|
|
|
|
|
/>
|
2026-06-09 16:48:40 +08:00
|
|
|
<AssistantIdentity assistantId={editingId} />
|
2026-06-08 13:51:28 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex shrink-0 gap-2">
|
2026-06-09 09:48:43 +08:00
|
|
|
{saveError && (
|
|
|
|
|
<span className="self-center text-xs text-destructive">
|
|
|
|
|
{saveError}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
className="gap-2"
|
2026-06-10 14:39:52 +08:00
|
|
|
disabled={saving || !dirty || !difyForm.name.trim()}
|
2026-06-09 09:48:43 +08:00
|
|
|
onClick={() => void handleSaveDify()}
|
|
|
|
|
>
|
|
|
|
|
{saving ? (
|
|
|
|
|
<Loader2 size={16} className="animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Save size={16} />
|
|
|
|
|
)}
|
2026-06-08 13:51:28 +08:00
|
|
|
保存
|
|
|
|
|
</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"
|
|
|
|
|
/>
|
2026-06-09 12:49:18 +08:00
|
|
|
<SecretInputField
|
2026-06-08 13:51:28 +08:00
|
|
|
label="API Key"
|
|
|
|
|
value={difyForm.apiKey}
|
|
|
|
|
onChange={(value) => updateDifyForm("apiKey", value)}
|
|
|
|
|
placeholder="请输入 Dify API Key"
|
2026-06-09 12:53:03 +08:00
|
|
|
storedValueMask={storedApiKeyMask}
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
icon={<Brain size={18} />}
|
|
|
|
|
title="语音配置"
|
2026-06-09 09:48:43 +08:00
|
|
|
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
2026-06-08 13:51:28 +08:00
|
|
|
>
|
2026-06-09 09:48:43 +08:00
|
|
|
<ResourceSelectField
|
2026-06-08 13:51:28 +08:00
|
|
|
label="语音识别"
|
|
|
|
|
value={difyForm.asr}
|
|
|
|
|
onChange={(value) => updateDifyForm("asr", value)}
|
2026-06-09 09:48:43 +08:00
|
|
|
options={credOptions("ASR")}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
2026-06-09 09:48:43 +08:00
|
|
|
<ResourceSelectField
|
|
|
|
|
label="语音合成"
|
2026-06-08 13:51:28 +08:00
|
|
|
value={difyForm.voice}
|
|
|
|
|
onChange={(value) => updateDifyForm("voice", value)}
|
2026-06-09 09:48:43 +08:00
|
|
|
options={credOptions("TTS")}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
icon={<Sparkles size={18} />}
|
|
|
|
|
title="交互策略"
|
|
|
|
|
description="设置实时视频对话时的交互体验"
|
|
|
|
|
>
|
|
|
|
|
<ToggleRow
|
|
|
|
|
title="允许用户打断"
|
|
|
|
|
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
|
|
|
|
checked={difyForm.enableInterrupt}
|
|
|
|
|
onChange={(checked) =>
|
|
|
|
|
updateDifyForm("enableInterrupt", checked)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
<DebugDrawer assistantId={editingId} />
|
2026-06-08 13:51:28 +08:00
|
|
|
</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">
|
2026-06-10 14:39:52 +08:00
|
|
|
<EditorBackButton onClick={() => router.push("/assistants")} />
|
2026-06-08 13:51:28 +08:00
|
|
|
<EditableTitle
|
|
|
|
|
value={fastGptForm.name}
|
|
|
|
|
onChange={(value) => updateFastGptForm("name", value)}
|
|
|
|
|
/>
|
2026-06-09 16:48:40 +08:00
|
|
|
<AssistantIdentity assistantId={editingId} />
|
2026-06-08 13:51:28 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex shrink-0 gap-2">
|
2026-06-09 09:48:43 +08:00
|
|
|
{saveError && (
|
|
|
|
|
<span className="self-center text-xs text-destructive">
|
|
|
|
|
{saveError}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
className="gap-2"
|
2026-06-10 14:39:52 +08:00
|
|
|
disabled={saving || !dirty || !fastGptForm.name.trim()}
|
2026-06-09 09:48:43 +08:00
|
|
|
onClick={() => void handleSaveFastGpt()}
|
|
|
|
|
>
|
|
|
|
|
{saving ? (
|
|
|
|
|
<Loader2 size={16} className="animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Save size={16} />
|
|
|
|
|
)}
|
2026-06-08 13:51:28 +08:00
|
|
|
保存
|
|
|
|
|
</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"
|
|
|
|
|
/>
|
2026-06-09 12:49:18 +08:00
|
|
|
<SecretInputField
|
2026-06-08 13:51:28 +08:00
|
|
|
label="API Key"
|
|
|
|
|
value={fastGptForm.apiKey}
|
|
|
|
|
onChange={(value) => updateFastGptForm("apiKey", value)}
|
|
|
|
|
placeholder="请输入 FastGPT API Key"
|
2026-06-09 12:53:03 +08:00
|
|
|
storedValueMask={storedApiKeyMask}
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
icon={<Brain size={18} />}
|
|
|
|
|
title="语音配置"
|
2026-06-09 09:48:43 +08:00
|
|
|
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
2026-06-08 13:51:28 +08:00
|
|
|
>
|
2026-06-09 09:48:43 +08:00
|
|
|
<ResourceSelectField
|
2026-06-08 13:51:28 +08:00
|
|
|
label="语音识别"
|
|
|
|
|
value={fastGptForm.asr}
|
|
|
|
|
onChange={(value) => updateFastGptForm("asr", value)}
|
2026-06-09 09:48:43 +08:00
|
|
|
options={credOptions("ASR")}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
2026-06-09 09:48:43 +08:00
|
|
|
<ResourceSelectField
|
|
|
|
|
label="语音合成"
|
2026-06-08 13:51:28 +08:00
|
|
|
value={fastGptForm.voice}
|
|
|
|
|
onChange={(value) => updateFastGptForm("voice", value)}
|
2026-06-09 09:48:43 +08:00
|
|
|
options={credOptions("TTS")}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
icon={<Sparkles size={18} />}
|
|
|
|
|
title="交互策略"
|
|
|
|
|
description="设置实时视频对话时的交互体验"
|
|
|
|
|
>
|
|
|
|
|
<ToggleRow
|
|
|
|
|
title="允许用户打断"
|
|
|
|
|
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
|
|
|
|
checked={fastGptForm.enableInterrupt}
|
|
|
|
|
onChange={(checked) =>
|
|
|
|
|
updateFastGptForm("enableInterrupt", checked)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
<DebugDrawer assistantId={editingId} />
|
2026-06-08 13:51:28 +08:00
|
|
|
</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">
|
2026-06-10 14:39:52 +08:00
|
|
|
<EditorBackButton onClick={() => router.push("/assistants")} />
|
2026-06-08 13:51:28 +08:00
|
|
|
<EditableTitle
|
|
|
|
|
value={openCodeForm.name}
|
|
|
|
|
onChange={(value) => updateOpenCodeForm("name", value)}
|
|
|
|
|
/>
|
2026-06-09 16:48:40 +08:00
|
|
|
<AssistantIdentity assistantId={editingId} />
|
2026-06-08 13:51:28 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex shrink-0 gap-2">
|
2026-06-09 12:59:07 +08:00
|
|
|
{saveError && (
|
|
|
|
|
<span className="self-center text-xs text-destructive">
|
|
|
|
|
{saveError}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
className="gap-2"
|
2026-06-10 14:39:52 +08:00
|
|
|
disabled={saving || !dirty || !openCodeForm.name.trim()}
|
2026-06-09 12:59:07 +08:00
|
|
|
onClick={() => void handleSaveOpenCode()}
|
|
|
|
|
>
|
|
|
|
|
{saving ? (
|
|
|
|
|
<Loader2 size={16} className="animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Save size={16} />
|
|
|
|
|
)}
|
2026-06-08 13:51:28 +08:00
|
|
|
保存
|
|
|
|
|
</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"
|
|
|
|
|
/>
|
2026-06-09 12:49:18 +08:00
|
|
|
<SecretInputField
|
2026-06-08 13:51:28 +08:00
|
|
|
label="API Key"
|
|
|
|
|
value={openCodeForm.apiKey}
|
|
|
|
|
onChange={(value) => updateOpenCodeForm("apiKey", value)}
|
|
|
|
|
placeholder="请输入 OpenCode API Key"
|
2026-06-09 12:59:07 +08:00
|
|
|
storedValueMask={storedApiKeyMask}
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
|
|
|
|
</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} />}
|
2026-06-09 12:59:07 +08:00
|
|
|
title="模型与语音配置"
|
|
|
|
|
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
|
2026-06-08 13:51:28 +08:00
|
|
|
>
|
2026-06-09 12:59:07 +08:00
|
|
|
<ResourceSelectField
|
|
|
|
|
label="大语言模型"
|
|
|
|
|
value={openCodeForm.model}
|
|
|
|
|
onChange={(value) => updateOpenCodeForm("model", value)}
|
|
|
|
|
options={credOptions("LLM")}
|
|
|
|
|
noneLabel="无"
|
|
|
|
|
/>
|
|
|
|
|
<ResourceSelectField
|
2026-06-08 13:51:28 +08:00
|
|
|
label="语音识别"
|
|
|
|
|
value={openCodeForm.asr}
|
|
|
|
|
onChange={(value) => updateOpenCodeForm("asr", value)}
|
2026-06-09 12:59:07 +08:00
|
|
|
options={credOptions("ASR")}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
2026-06-09 12:59:07 +08:00
|
|
|
<ResourceSelectField
|
|
|
|
|
label="语音合成"
|
2026-06-08 13:51:28 +08:00
|
|
|
value={openCodeForm.voice}
|
|
|
|
|
onChange={(value) => updateOpenCodeForm("voice", value)}
|
2026-06-09 12:59:07 +08:00
|
|
|
options={credOptions("TTS")}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
icon={<Sparkles size={18} />}
|
|
|
|
|
title="交互策略"
|
|
|
|
|
description="设置实时视频对话时的交互体验"
|
|
|
|
|
>
|
|
|
|
|
<ToggleRow
|
|
|
|
|
title="允许用户打断"
|
|
|
|
|
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
|
|
|
|
checked={openCodeForm.enableInterrupt}
|
|
|
|
|
onChange={(checked) =>
|
|
|
|
|
updateOpenCodeForm("enableInterrupt", checked)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
<DebugDrawer assistantId={editingId} />
|
2026-06-08 13:51:28 +08:00
|
|
|
</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">
|
2026-06-10 14:39:52 +08:00
|
|
|
<EditorBackButton onClick={() => router.push("/assistants")} />
|
2026-06-08 13:51:28 +08:00
|
|
|
<EditableTitle
|
|
|
|
|
value={form.name}
|
|
|
|
|
onChange={(value) => updateForm("name", value)}
|
|
|
|
|
/>
|
2026-06-09 16:48:40 +08:00
|
|
|
<AssistantIdentity assistantId={editingId} />
|
2026-06-08 13:51:28 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex shrink-0 gap-2">
|
2026-06-09 09:48:43 +08:00
|
|
|
{saveError && (
|
|
|
|
|
<span className="self-center text-xs text-destructive">
|
|
|
|
|
{saveError}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
className="gap-2"
|
2026-06-10 14:39:52 +08:00
|
|
|
disabled={saving || !dirty || !form.name.trim()}
|
2026-06-09 09:48:43 +08:00
|
|
|
onClick={() => void handleSavePrompt()}
|
|
|
|
|
>
|
|
|
|
|
{saving ? (
|
|
|
|
|
<Loader2 size={16} className="animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Save size={16} />
|
|
|
|
|
)}
|
2026-06-08 13:51:28 +08:00
|
|
|
保存
|
|
|
|
|
</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="模型配置"
|
2026-06-09 09:48:43 +08:00
|
|
|
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
2026-06-08 13:51:28 +08:00
|
|
|
>
|
2026-06-09 09:48:43 +08:00
|
|
|
<ResourceSelectField
|
2026-06-08 13:51:28 +08:00
|
|
|
label="大语言模型"
|
|
|
|
|
value={form.model}
|
|
|
|
|
onChange={(value) => updateForm("model", value)}
|
2026-06-09 09:48:43 +08:00
|
|
|
options={credOptions("LLM")}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
2026-06-09 09:48:43 +08:00
|
|
|
<ResourceSelectField
|
2026-06-08 13:51:28 +08:00
|
|
|
label="语音识别"
|
|
|
|
|
value={form.asr}
|
|
|
|
|
onChange={(value) => updateForm("asr", value)}
|
2026-06-09 09:48:43 +08:00
|
|
|
options={credOptions("ASR")}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
2026-06-09 09:48:43 +08:00
|
|
|
<ResourceSelectField
|
|
|
|
|
label="语音合成"
|
2026-06-08 13:51:28 +08:00
|
|
|
value={form.voice}
|
|
|
|
|
onChange={(value) => updateForm("voice", value)}
|
2026-06-09 09:48:43 +08:00
|
|
|
options={credOptions("TTS")}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
) : (
|
|
|
|
|
<SectionCard
|
|
|
|
|
icon={<Brain size={18} />}
|
|
|
|
|
title="模型配置"
|
|
|
|
|
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
|
|
|
|
>
|
2026-06-09 09:48:43 +08:00
|
|
|
<ResourceSelectField
|
|
|
|
|
label="Realtime 模型"
|
2026-06-08 13:51:28 +08:00
|
|
|
value={form.realtimeModel}
|
|
|
|
|
onChange={(value) => updateForm("realtimeModel", value)}
|
2026-06-09 09:48:43 +08:00
|
|
|
options={credOptions("Realtime")}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
|
|
|
|
</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="选择助手回答时可检索的业务知识来源"
|
|
|
|
|
>
|
2026-06-09 09:48:43 +08:00
|
|
|
<ResourceSelectField
|
2026-06-08 13:51:28 +08:00
|
|
|
value={form.knowledgeBase}
|
|
|
|
|
onChange={(value) => updateForm("knowledgeBase", value)}
|
2026-06-09 09:48:43 +08:00
|
|
|
options={kbOptions}
|
|
|
|
|
noneLabel="无"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
<SectionCard
|
|
|
|
|
icon={<Sparkles size={18} />}
|
|
|
|
|
title="交互策略"
|
|
|
|
|
description="设置实时视频对话时的交互体验"
|
|
|
|
|
>
|
|
|
|
|
<ToggleRow
|
|
|
|
|
title="允许用户打断"
|
|
|
|
|
description="用户说话时,助手可以停止当前播报并重新理解用户输入"
|
|
|
|
|
checked={form.enableInterrupt}
|
|
|
|
|
onChange={(checked) => updateForm("enableInterrupt", checked)}
|
|
|
|
|
/>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
<DebugDrawer assistantId={editingId} />
|
2026-06-08 13:51:28 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
type VizStyle = "aura" | "nebula" | "bars" | "wave";
|
2026-06-09 16:28:45 +08:00
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
const VIZ_OPTIONS: { style: VizStyle; label: string; icon: React.ReactNode }[] =
|
|
|
|
|
[
|
|
|
|
|
{ style: "aura", label: "光环", icon: <Orbit size={14} /> },
|
|
|
|
|
{ style: "nebula", label: "星云", icon: <Sparkles size={14} /> },
|
|
|
|
|
{ style: "bars", label: "频谱", icon: <AudioLines size={14} /> },
|
|
|
|
|
{ style: "wave", label: "波形", icon: <Waves size={14} /> },
|
|
|
|
|
];
|
2026-06-09 16:28:45 +08:00
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
function SegmentedIconGroup({
|
|
|
|
|
children,
|
|
|
|
|
label,
|
|
|
|
|
}: {
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
label: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
role="group"
|
|
|
|
|
aria-label={label}
|
|
|
|
|
className="flex items-center gap-0.5 rounded-full border border-hairline bg-canvas-soft p-0.5"
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SegmentedIconButton({
|
|
|
|
|
selected,
|
|
|
|
|
label,
|
|
|
|
|
onClick,
|
|
|
|
|
children,
|
|
|
|
|
}: {
|
|
|
|
|
selected: boolean;
|
|
|
|
|
label: string;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
aria-label={label}
|
|
|
|
|
aria-pressed={selected}
|
|
|
|
|
title={label}
|
|
|
|
|
className={[
|
|
|
|
|
"flex h-7 w-7 items-center justify-center rounded-full transition-colors",
|
|
|
|
|
selected
|
|
|
|
|
? "bg-surface-strong text-foreground shadow-sm"
|
|
|
|
|
: "text-muted-soft hover:text-foreground",
|
|
|
|
|
].join(" ")}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DebugDrawer({ assistantId }: { assistantId: string | null }) {
|
2026-06-09 15:28:24 +08:00
|
|
|
const [showTranscript, setShowTranscript] = useState(false);
|
2026-06-10 10:17:46 +08:00
|
|
|
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
|
2026-06-08 13:51:28 +08:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<aside className="hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex">
|
2026-06-10 10:17:46 +08:00
|
|
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
|
2026-06-09 15:28:24 +08:00
|
|
|
<div className="text-sm font-medium text-foreground">调试与预览</div>
|
2026-06-09 16:28:45 +08:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{!showTranscript && (
|
2026-06-10 10:17:46 +08:00
|
|
|
<SegmentedIconGroup label="可视化样式">
|
|
|
|
|
{VIZ_OPTIONS.map((option) => (
|
|
|
|
|
<SegmentedIconButton
|
|
|
|
|
key={option.style}
|
|
|
|
|
selected={vizStyle === option.style}
|
|
|
|
|
label={`可视化样式:${option.label}`}
|
|
|
|
|
onClick={() => setVizStyle(option.style)}
|
|
|
|
|
>
|
|
|
|
|
{option.icon}
|
|
|
|
|
</SegmentedIconButton>
|
|
|
|
|
))}
|
|
|
|
|
</SegmentedIconGroup>
|
2026-06-09 16:28:45 +08:00
|
|
|
)}
|
2026-06-10 10:17:46 +08:00
|
|
|
<SegmentedIconGroup label="预览视图">
|
|
|
|
|
<SegmentedIconButton
|
|
|
|
|
selected={!showTranscript}
|
|
|
|
|
label="语音可视化视图"
|
|
|
|
|
onClick={() => setShowTranscript(false)}
|
|
|
|
|
>
|
|
|
|
|
<Mic size={14} />
|
|
|
|
|
</SegmentedIconButton>
|
|
|
|
|
<SegmentedIconButton
|
|
|
|
|
selected={showTranscript}
|
|
|
|
|
label="文字聊天记录视图"
|
|
|
|
|
onClick={() => setShowTranscript(true)}
|
|
|
|
|
>
|
|
|
|
|
<MessageSquareText size={14} />
|
|
|
|
|
</SegmentedIconButton>
|
|
|
|
|
</SegmentedIconGroup>
|
2026-06-09 16:28:45 +08:00
|
|
|
</div>
|
2026-06-08 13:51:28 +08:00
|
|
|
</div>
|
|
|
|
|
|
2026-06-10 10:17:46 +08:00
|
|
|
<DebugVoicePanel
|
|
|
|
|
showTranscript={showTranscript}
|
|
|
|
|
vizStyle={vizStyle}
|
|
|
|
|
assistantId={assistantId}
|
|
|
|
|
/>
|
2026-06-08 13:51:28 +08:00
|
|
|
</aside>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 16:28:45 +08:00
|
|
|
function DebugVoicePanel({
|
|
|
|
|
showTranscript,
|
|
|
|
|
vizStyle,
|
2026-06-10 10:17:46 +08:00
|
|
|
assistantId,
|
2026-06-09 16:28:45 +08:00
|
|
|
}: {
|
|
|
|
|
showTranscript: boolean;
|
|
|
|
|
vizStyle: VizStyle;
|
2026-06-10 10:17:46 +08:00
|
|
|
assistantId: string | null;
|
2026-06-09 16:28:45 +08:00
|
|
|
}) {
|
2026-06-10 12:36:18 +08:00
|
|
|
const {
|
|
|
|
|
status,
|
|
|
|
|
error,
|
|
|
|
|
micWarning,
|
|
|
|
|
localStream,
|
2026-06-10 15:11:34 +08:00
|
|
|
messages,
|
2026-06-10 15:26:33 +08:00
|
|
|
audioInputs,
|
|
|
|
|
selectedDeviceId,
|
|
|
|
|
setSelectedDeviceId,
|
2026-06-10 15:11:34 +08:00
|
|
|
sendText,
|
2026-06-10 12:36:18 +08:00
|
|
|
connect,
|
|
|
|
|
disconnect,
|
|
|
|
|
audioRef,
|
|
|
|
|
} = useVoicePreview(assistantId);
|
2026-06-10 10:17:46 +08:00
|
|
|
// 连接中或已连通都视作"会话进行中"
|
|
|
|
|
const recording = status === "connecting" || status === "connected";
|
2026-06-10 15:11:34 +08:00
|
|
|
const [textDraft, setTextDraft] = useState("");
|
|
|
|
|
|
|
|
|
|
function handleSendText() {
|
|
|
|
|
if (sendText(textDraft)) {
|
|
|
|
|
setTextDraft("");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-09 15:28:24 +08:00
|
|
|
|
2026-06-08 13:51:28 +08:00
|
|
|
return (
|
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
2026-06-10 10:17:46 +08:00
|
|
|
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
|
|
|
|
|
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
2026-06-09 15:28:24 +08:00
|
|
|
{showTranscript ? (
|
2026-06-10 15:11:34 +08:00
|
|
|
<DebugTranscriptPanel messages={messages} recording={recording} />
|
2026-06-09 15:28:24 +08:00
|
|
|
) : (
|
2026-06-09 16:28:45 +08:00
|
|
|
<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">
|
2026-06-09 15:28:24 +08:00
|
|
|
<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%)",
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-06-08 13:51:28 +08:00
|
|
|
|
2026-06-09 15:28:24 +08:00
|
|
|
<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>
|
2026-06-08 13:51:28 +08:00
|
|
|
|
2026-06-09 16:28:45 +08:00
|
|
|
<div className="relative flex h-[200px] w-[240px] shrink-0 items-center justify-center">
|
|
|
|
|
{(() => {
|
|
|
|
|
const shared = {
|
2026-06-10 10:17:46 +08:00
|
|
|
active: Boolean(localStream),
|
|
|
|
|
stream: localStream,
|
2026-06-09 16:28:45 +08:00
|
|
|
className: "relative shrink-0",
|
|
|
|
|
} as const;
|
|
|
|
|
if (vizStyle === "aura")
|
|
|
|
|
return <AuraVisualizer {...shared} size={200} />;
|
2026-06-10 10:17:46 +08:00
|
|
|
if (vizStyle === "nebula")
|
|
|
|
|
return <NebulaVisualizer {...shared} size={200} />;
|
2026-06-09 16:28:45 +08:00
|
|
|
if (vizStyle === "bars")
|
2026-06-10 10:17:46 +08:00
|
|
|
return <SpectrumVisualizer {...shared} size={200} />;
|
2026-06-09 16:28:45 +08:00
|
|
|
return <WaveVisualizer {...shared} size={200} />;
|
|
|
|
|
})()}
|
|
|
|
|
</div>
|
2026-06-08 13:51:28 +08:00
|
|
|
|
2026-06-09 16:28:45 +08:00
|
|
|
<div className="relative max-w-xs space-y-1.5">
|
|
|
|
|
<div className="font-display display-sm text-foreground">
|
2026-06-10 10:17:46 +08:00
|
|
|
{status === "connecting"
|
|
|
|
|
? "连接中…"
|
|
|
|
|
: status === "connected"
|
2026-06-10 12:36:18 +08:00
|
|
|
? micWarning
|
|
|
|
|
? "仅收听模式"
|
|
|
|
|
: "我在聆听"
|
2026-06-10 10:17:46 +08:00
|
|
|
: "开始一次语音对话"}
|
2026-06-09 15:28:24 +08:00
|
|
|
</div>
|
2026-06-09 16:28:45 +08:00
|
|
|
<p className="mx-auto text-xs leading-5 text-muted-foreground">
|
2026-06-10 12:36:18 +08:00
|
|
|
{status === "failed"
|
2026-06-10 10:17:46 +08:00
|
|
|
? error ||
|
|
|
|
|
"连接失败,请确认后端已启动且助手已保存后重试。"
|
|
|
|
|
: !assistantId
|
|
|
|
|
? "请先保存助手,再开始语音预览。"
|
2026-06-10 12:36:18 +08:00
|
|
|
: micWarning
|
|
|
|
|
? `${micWarning} 可接收助手播报,但无法发送语音。`
|
2026-06-10 10:17:46 +08:00
|
|
|
: recording
|
|
|
|
|
? "直接说话即可。助手会在您停顿后自然回应。"
|
|
|
|
|
: "测试语音识别、响应速度与助手的播报效果。"}
|
2026-06-09 15:28:24 +08:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-10 15:26:33 +08:00
|
|
|
<div className="relative w-full max-w-xs">
|
|
|
|
|
<Select
|
|
|
|
|
value={selectedDeviceId || "default"}
|
|
|
|
|
onValueChange={(value) =>
|
|
|
|
|
setSelectedDeviceId(value === "default" ? "" : value)
|
|
|
|
|
}
|
|
|
|
|
disabled={recording}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full justify-center gap-2 rounded-full border-hairline bg-canvas-soft text-xs text-muted-foreground"
|
|
|
|
|
aria-label="选择麦克风"
|
|
|
|
|
>
|
|
|
|
|
<Mic size={13} className="shrink-0 text-muted-soft" />
|
|
|
|
|
<SelectValue placeholder="默认麦克风" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="default">默认麦克风</SelectItem>
|
|
|
|
|
{audioInputs.map((device, index) => (
|
|
|
|
|
<SelectItem key={device.deviceId} value={device.deviceId}>
|
|
|
|
|
{device.label || `麦克风 ${index + 1}`}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-09 16:28:45 +08:00
|
|
|
<Button
|
2026-06-10 10:17:46 +08:00
|
|
|
disabled={!assistantId || status === "connecting"}
|
2026-06-09 16:28:45 +08:00
|
|
|
onClick={() => {
|
2026-06-10 10:17:46 +08:00
|
|
|
if (recording) {
|
|
|
|
|
disconnect();
|
|
|
|
|
} else {
|
|
|
|
|
void connect();
|
|
|
|
|
}
|
2026-06-09 16:28:45 +08:00
|
|
|
}}
|
|
|
|
|
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 ? "结束语音测试" : "开始语音测试"}
|
|
|
|
|
>
|
2026-06-10 10:17:46 +08:00
|
|
|
{status === "connecting" ? (
|
|
|
|
|
<Loader2 size={18} className="animate-spin" />
|
|
|
|
|
) : recording ? (
|
|
|
|
|
<PhoneOff size={18} />
|
|
|
|
|
) : (
|
|
|
|
|
<Mic size={18} />
|
|
|
|
|
)}
|
2026-06-09 16:28:45 +08:00
|
|
|
{recording ? "结束对话" : "开始对话"}
|
|
|
|
|
</Button>
|
2026-06-09 15:28:24 +08:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="shrink-0 border-t border-hairline bg-card p-3">
|
2026-06-08 13:51:28 +08:00
|
|
|
<div className="flex items-end gap-2">
|
|
|
|
|
<Textarea
|
|
|
|
|
rows={1}
|
2026-06-10 15:11:34 +08:00
|
|
|
value={textDraft}
|
|
|
|
|
disabled={status !== "connected"}
|
|
|
|
|
onChange={(event) => setTextDraft(event.target.value)}
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
handleSendText();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
placeholder={
|
|
|
|
|
status === "connected"
|
|
|
|
|
? "输入文字发送给助手,将打断当前播报…"
|
|
|
|
|
: "开始对话后可输入文字…"
|
|
|
|
|
}
|
2026-06-09 15:28:24 +08:00
|
|
|
className="max-h-24 min-h-10 flex-1 resize-none border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
|
2026-06-08 13:51:28 +08:00
|
|
|
/>
|
2026-06-10 15:11:34 +08:00
|
|
|
<Button
|
|
|
|
|
size="icon"
|
|
|
|
|
className="shrink-0"
|
|
|
|
|
aria-label="发送调试消息"
|
|
|
|
|
disabled={status !== "connected" || !textDraft.trim()}
|
|
|
|
|
onClick={handleSendText}
|
|
|
|
|
>
|
2026-06-08 13:51:28 +08:00
|
|
|
<Send size={16} />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 15:11:34 +08:00
|
|
|
// ISO 时间戳 → HH:MM(本地时区),解析失败返回空串
|
|
|
|
|
function formatMessageTime(iso: string): string {
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
|
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
|
|
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DebugTranscriptPanel({
|
|
|
|
|
messages,
|
|
|
|
|
recording,
|
|
|
|
|
}: {
|
|
|
|
|
messages: ChatMessage[];
|
|
|
|
|
recording: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
// 新消息时滚到底部
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const el = scrollRef.current;
|
|
|
|
|
if (el) el.scrollTop = el.scrollHeight;
|
|
|
|
|
}, [messages]);
|
|
|
|
|
|
|
|
|
|
if (messages.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
|
|
|
|
|
<MessageSquareText size={28} className="text-muted-soft" />
|
|
|
|
|
<div className="text-sm font-medium text-foreground">
|
|
|
|
|
{recording ? "暂无聊天记录" : "尚未开始对话"}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="max-w-xs text-xs leading-5 text-muted-foreground">
|
|
|
|
|
{recording
|
|
|
|
|
? "开口说话或在下方输入文字,对话内容会实时显示在这里。"
|
|
|
|
|
: "点击「开始对话」后,语音与文字消息会实时显示在这里。"}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 13:51:28 +08:00
|
|
|
return (
|
2026-06-10 15:11:34 +08:00
|
|
|
<div
|
|
|
|
|
ref={scrollRef}
|
|
|
|
|
className="flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4"
|
|
|
|
|
>
|
2026-06-09 15:28:24 +08:00
|
|
|
<div className="flex flex-col gap-4">
|
2026-06-10 15:11:34 +08:00
|
|
|
{messages.map((message) => {
|
|
|
|
|
const time = formatMessageTime(message.timestamp);
|
|
|
|
|
return message.role === "assistant" ? (
|
|
|
|
|
<div
|
|
|
|
|
key={message.id}
|
|
|
|
|
className="flex max-w-[88%] flex-col gap-1 self-start"
|
|
|
|
|
>
|
|
|
|
|
<span className="px-1 text-[11px] text-muted-soft">
|
|
|
|
|
助手{time ? ` · ${time}` : ""}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="whitespace-pre-wrap rounded-2xl rounded-tl-sm bg-surface-strong px-4 py-2.5 text-sm leading-6 text-foreground">
|
|
|
|
|
{message.content}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div
|
|
|
|
|
key={message.id}
|
|
|
|
|
className="flex max-w-[88%] flex-col items-end gap-1 self-end"
|
|
|
|
|
>
|
|
|
|
|
<span className="px-1 text-[11px] text-muted-soft">
|
|
|
|
|
我{time ? ` · ${time}` : ""}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="whitespace-pre-wrap rounded-2xl rounded-tr-sm bg-primary px-4 py-2.5 text-sm leading-6 text-primary-foreground">
|
|
|
|
|
{message.content}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-06-08 13:51:28 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 12:49:18 +08:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 16:48:40 +08:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 13:51:28 +08:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 12:49:18 +08:00
|
|
|
function SecretInputField({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
placeholder,
|
2026-06-09 12:53:03 +08:00
|
|
|
storedValueMask,
|
2026-06-09 12:49:18 +08:00
|
|
|
onChange,
|
|
|
|
|
}: {
|
|
|
|
|
label?: string;
|
|
|
|
|
value: string;
|
|
|
|
|
placeholder?: string;
|
2026-06-09 12:53:03 +08:00
|
|
|
storedValueMask: string;
|
2026-06-09 12:49:18 +08:00
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [showValue, setShowValue] = useState(false);
|
2026-06-09 12:53:03 +08:00
|
|
|
const hasStoredValue = Boolean(storedValueMask);
|
2026-06-09 12:49:18 +08:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<label className="block">
|
|
|
|
|
{label && (
|
|
|
|
|
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
|
|
|
|
|
)}
|
2026-06-09 12:53:03 +08:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-06-09 12:49:18 +08:00
|
|
|
<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">
|
2026-06-09 12:53:03 +08:00
|
|
|
仅显示当前密钥首尾用于识别。留空可保持原密钥,输入新值将覆盖原密钥。
|
2026-06-09 12:49:18 +08:00
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</label>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 13:51:28 +08:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:48:43 +08:00
|
|
|
// 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 13:51:28 +08:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|