- Introduce new fields for knowledge retrieval configuration in AssistantConfig and Assistant models, including mode, top_n, and score_threshold. - Implement KnowledgeRetrievalConfig schema with validation for top_n. - Update backend services and routes to handle knowledge retrieval settings. - Enhance frontend components to support knowledge retrieval configuration, including a new dialog for advanced settings. - Add tests for knowledge retrieval configuration validation and description generation.
3439 lines
110 KiB
TypeScript
3439 lines
110 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Bot,
|
||
Boxes,
|
||
Brain,
|
||
Check,
|
||
Copy,
|
||
Database,
|
||
MessageSquareText,
|
||
MoreHorizontal,
|
||
Pencil,
|
||
Plus,
|
||
Rocket,
|
||
Sparkles,
|
||
Trash2,
|
||
Workflow,
|
||
ChevronLeft,
|
||
ChevronUp,
|
||
ChevronDown,
|
||
Save,
|
||
Mic,
|
||
Send,
|
||
HelpCircle,
|
||
Waypoints,
|
||
AudioLines,
|
||
Terminal,
|
||
Loader2,
|
||
PhoneOff,
|
||
Orbit,
|
||
Waves,
|
||
Bug,
|
||
Video,
|
||
Smartphone,
|
||
Wrench,
|
||
Settings2,
|
||
X,
|
||
} from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { NetworkQualityIndicator } from "@/components/network-quality-indicator";
|
||
import { TurnConfigEditor } from "@/components/turn-config-editor";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetDescription,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
} from "@/components/ui/sheet";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import { AuraVisualizer } from "@/components/ui/aura-visualizer";
|
||
import { NebulaVisualizer } from "@/components/ui/nebula-visualizer";
|
||
import { SpectrumVisualizer } from "@/components/ui/spectrum-visualizer";
|
||
import { WaveVisualizer } from "@/components/ui/wave-visualizer";
|
||
import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline";
|
||
import { DataList } from "@/components/ui/data-list";
|
||
import { PageHeader } from "@/components/ui/page-header";
|
||
import { FilterPills } from "@/components/ui/filter-pills";
|
||
import { SearchInput } from "@/components/ui/search-input";
|
||
import { ListToolbar } from "@/components/ui/list-toolbar";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
assistantsApi,
|
||
knowledgeBasesApi,
|
||
modelResourcesApi,
|
||
toolsApi,
|
||
type Assistant,
|
||
type AssistantType as ApiAssistantType,
|
||
type AssistantUpsert,
|
||
type KnowledgeBase,
|
||
type KnowledgeRetrievalConfig,
|
||
type ModelResource,
|
||
type Tool,
|
||
type TurnConfig,
|
||
} from "@/lib/api";
|
||
import {
|
||
useVoicePreview,
|
||
type ChatMessage,
|
||
type VoicePreview,
|
||
type VoicePreviewStatus,
|
||
} from "@/hooks/use-voice-preview";
|
||
import {
|
||
useCameraPreview,
|
||
type CameraPreview,
|
||
} from "@/hooks/use-camera-preview";
|
||
import {
|
||
WorkflowEditor,
|
||
type WorkflowSettings,
|
||
} from "@/components/workflow/WorkflowEditor";
|
||
import {
|
||
defaultGraph,
|
||
type WorkflowGraph,
|
||
} from "@/components/workflow/specs";
|
||
|
||
type RuntimeMode = "pipeline" | "realtime";
|
||
|
||
type AssistantForm = {
|
||
name: string;
|
||
greeting: string;
|
||
prompt: string;
|
||
runtimeMode: RuntimeMode;
|
||
realtimeModel: string;
|
||
model: string;
|
||
asr: string;
|
||
voice: string;
|
||
knowledgeBase: string;
|
||
knowledgeRetrievalConfig: KnowledgeRetrievalConfig;
|
||
enableInterrupt: boolean;
|
||
turnConfig: TurnConfig;
|
||
visionEnabled: boolean;
|
||
visionModelResourceId: string;
|
||
toolIds: string[];
|
||
};
|
||
|
||
type FastGptForm = {
|
||
name: string;
|
||
agent: string;
|
||
asr: string;
|
||
voice: string;
|
||
enableInterrupt: boolean;
|
||
turnConfig: TurnConfig;
|
||
};
|
||
|
||
type DifyForm = {
|
||
name: string;
|
||
agent: string;
|
||
asr: string;
|
||
voice: string;
|
||
enableInterrupt: boolean;
|
||
turnConfig: TurnConfig;
|
||
};
|
||
|
||
type OpenCodeForm = {
|
||
name: string;
|
||
prompt: string;
|
||
agent: string;
|
||
model: string;
|
||
asr: string;
|
||
voice: string;
|
||
enableInterrupt: boolean;
|
||
turnConfig: TurnConfig;
|
||
visionEnabled: boolean;
|
||
visionModelResourceId: string;
|
||
};
|
||
|
||
type AssistantType = "提示词" | "工作流" | "Dify" | "FastGPT" | "OpenCode";
|
||
|
||
const assistantTypes: AssistantType[] = [
|
||
"提示词",
|
||
"工作流",
|
||
"Dify",
|
||
"FastGPT",
|
||
"OpenCode",
|
||
];
|
||
|
||
function defaultTurnConfig(): TurnConfig {
|
||
return {
|
||
bargeIn: {
|
||
strategy: "vad",
|
||
},
|
||
vad: {
|
||
confidence: 0.7,
|
||
startSecs: 0.2,
|
||
stopSecs: 0.2,
|
||
minVolume: 0.6,
|
||
},
|
||
turnDetection: {
|
||
strategy: "silence",
|
||
silenceTimeoutSecs: 0.6,
|
||
},
|
||
};
|
||
}
|
||
|
||
function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
|
||
return {
|
||
mode: "automatic",
|
||
topN: 5,
|
||
scoreThreshold: 0,
|
||
};
|
||
}
|
||
|
||
// 后端 type(英文) ↔ 列表展示标签(中文)
|
||
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
||
prompt: "提示词",
|
||
workflow: "工作流",
|
||
dify: "Dify",
|
||
fastgpt: "FastGPT",
|
||
opencode: "OpenCode",
|
||
};
|
||
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: "workflow",
|
||
} 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: "",
|
||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||
enableInterrupt: true,
|
||
turnConfig: defaultTurnConfig(),
|
||
visionEnabled: false,
|
||
visionModelResourceId: "",
|
||
toolIds: [],
|
||
};
|
||
}
|
||
|
||
function blankFastGptForm(name: string): FastGptForm {
|
||
return {
|
||
name,
|
||
agent: "",
|
||
asr: "",
|
||
voice: "",
|
||
enableInterrupt: true,
|
||
turnConfig: defaultTurnConfig(),
|
||
};
|
||
}
|
||
|
||
function blankDifyForm(name: string): DifyForm {
|
||
return {
|
||
name,
|
||
agent: "",
|
||
asr: "",
|
||
voice: "",
|
||
enableInterrupt: true,
|
||
turnConfig: defaultTurnConfig(),
|
||
};
|
||
}
|
||
|
||
function blankOpenCodeForm(name: string): OpenCodeForm {
|
||
return {
|
||
name,
|
||
prompt: "",
|
||
agent: "",
|
||
model: "",
|
||
asr: "",
|
||
voice: "",
|
||
enableInterrupt: true,
|
||
turnConfig: defaultTurnConfig(),
|
||
visionEnabled: false,
|
||
visionModelResourceId: "",
|
||
};
|
||
}
|
||
function formatTimestamp(iso?: string | null): string {
|
||
if (!iso) return "";
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
|
||
d.getHours(),
|
||
)}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
type AssistantTypeOption = {
|
||
type: AssistantType;
|
||
label: string;
|
||
description: string;
|
||
icon: React.ReactNode;
|
||
/** 提示词、Dify、FastGPT 类型已落地,工作流暂时显示占位页 */
|
||
available: boolean;
|
||
};
|
||
|
||
const assistantTypeOptions: AssistantTypeOption[] = [
|
||
{
|
||
type: "提示词",
|
||
label: "使用提示词构建",
|
||
description: "通过提示词、模型与语音快速搭建对话助手,适合大多数场景。",
|
||
icon: <MessageSquareText size={20} />,
|
||
available: true,
|
||
},
|
||
{
|
||
type: "工作流",
|
||
label: "使用工作流构建",
|
||
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
|
||
icon: <Workflow size={20} />,
|
||
available: false,
|
||
},
|
||
{
|
||
type: "Dify",
|
||
label: "使用 Dify 构建",
|
||
description: "对接 Dify 应用,复用其编排能力与知识库配置。",
|
||
icon: <Boxes size={20} />,
|
||
available: true,
|
||
},
|
||
{
|
||
type: "FastGPT",
|
||
label: "使用 FastGPT 构建",
|
||
description: "对接 FastGPT 应用,复用其知识库问答与工作流能力。",
|
||
icon: <Database size={20} />,
|
||
available: true,
|
||
},
|
||
{
|
||
type: "OpenCode",
|
||
label: "使用 OpenCode 构建",
|
||
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
|
||
icon: <Terminal size={20} />,
|
||
available: true,
|
||
},
|
||
];
|
||
|
||
type AssistantListItem = {
|
||
id: string;
|
||
name: string;
|
||
type: AssistantType;
|
||
updatedAt: string;
|
||
/** 原始 ISO 时间戳,用于按时间排序(updatedAt 为展示用整形字符串) */
|
||
updatedAtRaw: string | null | undefined;
|
||
};
|
||
|
||
type TypeFilter = "全部" | AssistantType;
|
||
|
||
const typeFilters: TypeFilter[] = ["全部", ...assistantTypes];
|
||
|
||
// 列表按更新时间排序:newest=最近更新在前(倒叙,默认) / oldest=最早更新在前
|
||
type SortOrder = "newest" | "oldest";
|
||
|
||
export function AssistantPage(props: AssistantPageProps) {
|
||
const router = useRouter();
|
||
// 编辑中的助手 id(来自路由)
|
||
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(""),
|
||
);
|
||
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);
|
||
// 编辑模式:加载指定 id 助手失败时的错误
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
// 编辑模式:后端返回的打码 API Key(用于编辑页展示"当前密钥")
|
||
// 下拉数据源:模型资源 + 知识库
|
||
const [modelResources, setModelResources] = useState<ModelResource[]>([]);
|
||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||
const [tools, setTools] = useState<Tool[]>([]);
|
||
// 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换
|
||
const [view, setView] = useState<View>(() => {
|
||
if (props.mode === "choose") return "choose";
|
||
if (props.mode === "edit") return "loading";
|
||
return "list";
|
||
});
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
|
||
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
// choose 步骤的草稿:名称与已选类型,确认后直接建库并进入编辑页
|
||
// (工作流占位页也用它展示名称与类型)
|
||
const [draftName, setDraftName] = useState("");
|
||
const [draftType, setDraftType] = useState<AssistantType | null>(null);
|
||
// 引导页:创建请求进行中 / 创建失败
|
||
const [creating, setCreating] = useState(false);
|
||
const [createError, setCreateError] = useState<string | null>(null);
|
||
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
|
||
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
||
|
||
// workflow 编辑器:名称 + 图(nodes/edges)。graph 实时由画布回传。
|
||
const [workflowName, setWorkflowName] = useState("");
|
||
const [workflowGraph, setWorkflowGraph] = useState<WorkflowGraph>(() =>
|
||
defaultGraph(),
|
||
);
|
||
const [workflowSettings, setWorkflowSettings] = useState<WorkflowSettings>({
|
||
allowInterrupt: true,
|
||
turnConfig: defaultTurnConfig(),
|
||
});
|
||
const [debugOpen, setDebugOpen] = useState(false);
|
||
const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
|
||
|
||
const loadAssistants = useCallback(async () => {
|
||
setListLoading(true);
|
||
setListError(null);
|
||
try {
|
||
setAssistants(await assistantsApi.list());
|
||
} catch (error) {
|
||
setListError(error instanceof Error ? error.message : "加载失败");
|
||
} finally {
|
||
setListLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// 列表页挂载时拉取助手列表(与后端同步)
|
||
if (props.mode !== "list") return;
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
void loadAssistants();
|
||
}, [props.mode, loadAssistants]);
|
||
|
||
// 进入创建/编辑前加载下拉数据源(模型资源 + 知识库)
|
||
const loadResources = useCallback(async () => {
|
||
try {
|
||
const [creds, kbs, toolRows] = await Promise.all([
|
||
modelResourcesApi.list(),
|
||
knowledgeBasesApi.list(),
|
||
toolsApi.list(),
|
||
]);
|
||
setModelResources(creds);
|
||
setKnowledgeBases(kbs);
|
||
setTools(toolRows);
|
||
} catch {
|
||
// 拉取失败时下拉为空,不阻塞表单
|
||
}
|
||
}, []);
|
||
|
||
// 编辑页挂载时加载下拉数据源
|
||
useEffect(() => {
|
||
if (props.mode !== "edit") return;
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
void loadResources();
|
||
}, [props.mode, loadResources]);
|
||
|
||
// 按资源类型生成 {value:id, label:name} 选项
|
||
const credOptions = (type: ModelResource["capability"]) =>
|
||
modelResources
|
||
.filter((c) => c.capability === type)
|
||
.map((c) => ({ value: c.id, label: c.name }));
|
||
const agentOptions = (interfaceType: "dify" | "fastgpt" | "opencode") =>
|
||
modelResources
|
||
.filter(
|
||
(resource) =>
|
||
resource.capability === "Agent" &&
|
||
resource.interfaceType === interfaceType,
|
||
)
|
||
.map((resource) => ({ value: resource.id, label: resource.name }));
|
||
const visionModelOptionsFor = (currentModelId: string) => modelResources
|
||
.filter(
|
||
(c) =>
|
||
c.capability === "LLM" &&
|
||
c.supportImageInput &&
|
||
c.id !== currentModelId,
|
||
)
|
||
.map((c) => ({ value: c.id, label: c.name }));
|
||
const kbOptions = knowledgeBases.map((k) => ({ value: k.id, label: k.name }));
|
||
|
||
function handlePromptVisionEnabledChange(enabled: boolean) {
|
||
updateForm("visionEnabled", enabled);
|
||
if (!enabled) updateForm("visionModelResourceId", "");
|
||
}
|
||
|
||
function handlePromptModelChange(value: string) {
|
||
updateForm("model", value);
|
||
if (form.visionModelResourceId === value) {
|
||
updateForm("visionModelResourceId", "");
|
||
}
|
||
}
|
||
|
||
function handleOpenCodeVisionEnabledChange(enabled: boolean) {
|
||
updateOpenCodeForm("visionEnabled", enabled);
|
||
if (!enabled) updateOpenCodeForm("visionModelResourceId", "");
|
||
}
|
||
|
||
function handleOpenCodeModelChange(value: string) {
|
||
updateOpenCodeForm("model", value);
|
||
if (openCodeForm.visionModelResourceId === value) {
|
||
updateOpenCodeForm("visionModelResourceId", "");
|
||
}
|
||
}
|
||
|
||
function startCreate() {
|
||
router.push("/assistants/new");
|
||
}
|
||
|
||
// 把后端 Assistant 回填进提示词表单(model/asr/voice 等存模型资源 id)
|
||
// 返回回填后的表单,供调用方记录"已保存基线"
|
||
function fillPromptForm(a: Assistant): AssistantForm {
|
||
const next: AssistantForm = {
|
||
name: a.name,
|
||
greeting: a.greeting,
|
||
prompt: a.prompt,
|
||
runtimeMode: a.runtimeMode,
|
||
realtimeModel: a.modelResourceIds.Realtime ?? "",
|
||
model: a.modelResourceIds.LLM ?? "",
|
||
asr: a.modelResourceIds.ASR ?? "",
|
||
voice: a.modelResourceIds.TTS ?? "",
|
||
knowledgeBase: a.knowledgeBaseId ?? "",
|
||
knowledgeRetrievalConfig:
|
||
a.knowledgeRetrievalConfig ?? defaultKnowledgeRetrievalConfig(),
|
||
enableInterrupt: a.enableInterrupt,
|
||
turnConfig: a.turnConfig,
|
||
visionEnabled: a.visionEnabled,
|
||
visionModelResourceId: a.visionModelResourceId ?? "",
|
||
toolIds: a.toolIds ?? [],
|
||
};
|
||
setForm(next);
|
||
return next;
|
||
}
|
||
|
||
// 编辑:跳转到 /assistants/[id],由编辑页按助手类型回填表单
|
||
function handleEdit(assistant: AssistantListItem) {
|
||
router.push(`/assistants/${assistant.id}`);
|
||
}
|
||
|
||
// 引导页确认:直接创建到数据库拿到 id,再进入该助手的编辑页
|
||
async function confirmType() {
|
||
if (!draftName.trim() || !draftType || creating) {
|
||
return;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 复制助手:服务端整行复制(含真 key,密钥不经浏览器)
|
||
async function handleDuplicate(assistant: AssistantListItem) {
|
||
try {
|
||
await assistantsApi.duplicate(assistant.id);
|
||
await loadAssistants();
|
||
} catch (error) {
|
||
setListError(error instanceof Error ? error.message : "复制失败");
|
||
}
|
||
}
|
||
|
||
// 删除助手
|
||
async function handleDelete(id: string) {
|
||
try {
|
||
await assistantsApi.remove(id);
|
||
await loadAssistants();
|
||
} catch (error) {
|
||
setListError(error instanceof Error ? error.message : "删除失败");
|
||
}
|
||
}
|
||
|
||
// 平铺字段的空白 upsert,各类型按需覆盖(后端会按 type 清掉无关字段)
|
||
function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
|
||
return {
|
||
name: "",
|
||
type: "prompt",
|
||
runtimeMode: "pipeline",
|
||
greeting: "",
|
||
enableInterrupt: true,
|
||
turnConfig: defaultTurnConfig(),
|
||
visionEnabled: false,
|
||
visionModelResourceId: null,
|
||
modelResourceIds: {},
|
||
knowledgeBaseId: null,
|
||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||
toolIds: [],
|
||
prompt: "",
|
||
apiUrl: "",
|
||
apiKey: "",
|
||
appId: "",
|
||
graph: {},
|
||
...over,
|
||
};
|
||
}
|
||
|
||
// 保存后停留在编辑页,并把当前表单记为已保存基线(按钮随之置灰)。
|
||
async function save(payload: AssistantUpsert) {
|
||
if (!editingId) return;
|
||
setSaving(true);
|
||
setSaveError(null);
|
||
try {
|
||
await assistantsApi.update(editingId, payload);
|
||
if (view === "create") {
|
||
setSavedSnapshot(JSON.stringify(form));
|
||
} else if (view === "create-dify") {
|
||
const next = { ...difyForm };
|
||
setDifyForm(next);
|
||
setSavedSnapshot(JSON.stringify(next));
|
||
} else if (view === "create-fastgpt") {
|
||
const next = { ...fastGptForm };
|
||
setFastGptForm(next);
|
||
setSavedSnapshot(JSON.stringify(next));
|
||
} else if (view === "create-opencode") {
|
||
const next = { ...openCodeForm };
|
||
setOpenCodeForm(next);
|
||
setSavedSnapshot(JSON.stringify(next));
|
||
} else if (view === "workflow") {
|
||
setSavedSnapshot(
|
||
JSON.stringify({
|
||
name: workflowName,
|
||
graph: workflowGraph,
|
||
settings: workflowSettings,
|
||
}),
|
||
);
|
||
}
|
||
} catch (error) {
|
||
setSaveError(error instanceof Error ? error.message : "保存失败");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
function handleSavePrompt() {
|
||
void save(
|
||
baseUpsert({
|
||
name: form.name.trim(),
|
||
type: "prompt",
|
||
runtimeMode: form.runtimeMode,
|
||
greeting: form.greeting,
|
||
enableInterrupt: form.enableInterrupt,
|
||
turnConfig: form.turnConfig,
|
||
visionEnabled: form.visionEnabled,
|
||
visionModelResourceId: form.visionModelResourceId || null,
|
||
modelResourceIds: {
|
||
...(form.model ? { LLM: form.model } : {}),
|
||
...(form.asr ? { ASR: form.asr } : {}),
|
||
...(form.voice ? { TTS: form.voice } : {}),
|
||
...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}),
|
||
},
|
||
knowledgeBaseId: form.runtimeMode === "pipeline" ? form.knowledgeBase || null : null,
|
||
knowledgeRetrievalConfig: form.knowledgeRetrievalConfig,
|
||
toolIds: form.toolIds,
|
||
prompt: form.prompt,
|
||
}),
|
||
);
|
||
}
|
||
|
||
// ---- Dify ----
|
||
function fillDifyForm(a: Assistant): DifyForm {
|
||
const next: DifyForm = {
|
||
name: a.name,
|
||
agent: a.modelResourceIds.Agent ?? "",
|
||
asr: a.modelResourceIds.ASR ?? "",
|
||
voice: a.modelResourceIds.TTS ?? "",
|
||
enableInterrupt: a.enableInterrupt,
|
||
turnConfig: a.turnConfig,
|
||
};
|
||
setDifyForm(next);
|
||
return next;
|
||
}
|
||
|
||
function handleSaveDify() {
|
||
void save(
|
||
baseUpsert({
|
||
name: difyForm.name.trim(),
|
||
type: "dify",
|
||
enableInterrupt: difyForm.enableInterrupt,
|
||
turnConfig: difyForm.turnConfig,
|
||
modelResourceIds: {
|
||
...(difyForm.agent ? { Agent: difyForm.agent } : {}),
|
||
...(difyForm.asr ? { ASR: difyForm.asr } : {}),
|
||
...(difyForm.voice ? { TTS: difyForm.voice } : {}),
|
||
},
|
||
}),
|
||
);
|
||
}
|
||
|
||
// ---- FastGPT ----
|
||
function fillFastGptForm(a: Assistant): FastGptForm {
|
||
const next: FastGptForm = {
|
||
name: a.name,
|
||
agent: a.modelResourceIds.Agent ?? "",
|
||
asr: a.modelResourceIds.ASR ?? "",
|
||
voice: a.modelResourceIds.TTS ?? "",
|
||
enableInterrupt: a.enableInterrupt,
|
||
turnConfig: a.turnConfig,
|
||
};
|
||
setFastGptForm(next);
|
||
return next;
|
||
}
|
||
|
||
function handleSaveFastGpt() {
|
||
void save(
|
||
baseUpsert({
|
||
name: fastGptForm.name.trim(),
|
||
type: "fastgpt",
|
||
enableInterrupt: fastGptForm.enableInterrupt,
|
||
turnConfig: fastGptForm.turnConfig,
|
||
modelResourceIds: {
|
||
...(fastGptForm.agent ? { Agent: fastGptForm.agent } : {}),
|
||
...(fastGptForm.asr ? { ASR: fastGptForm.asr } : {}),
|
||
...(fastGptForm.voice ? { TTS: fastGptForm.voice } : {}),
|
||
},
|
||
}),
|
||
);
|
||
}
|
||
|
||
// ---- OpenCode ----
|
||
function fillOpenCodeForm(a: Assistant): OpenCodeForm {
|
||
const next: OpenCodeForm = {
|
||
name: a.name,
|
||
prompt: a.prompt,
|
||
agent: a.modelResourceIds.Agent ?? "",
|
||
model: a.modelResourceIds.LLM ?? "",
|
||
asr: a.modelResourceIds.ASR ?? "",
|
||
voice: a.modelResourceIds.TTS ?? "",
|
||
enableInterrupt: a.enableInterrupt,
|
||
turnConfig: a.turnConfig,
|
||
visionEnabled: a.visionEnabled,
|
||
visionModelResourceId: a.visionModelResourceId ?? "",
|
||
};
|
||
setOpenCodeForm(next);
|
||
return next;
|
||
}
|
||
|
||
// 编辑模式:按路由中的 id 拉取助手,回填对应类型的表单后再切换视图
|
||
useEffect(() => {
|
||
if (props.mode !== "edit" || !editingId) return;
|
||
let cancelled = false;
|
||
|
||
void (async () => {
|
||
try {
|
||
const assistant = await assistantsApi.get(editingId);
|
||
if (cancelled) return;
|
||
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 {
|
||
// 工作流:回填名称与图(空图回落到默认 开始→智能体→结束)
|
||
const graph =
|
||
assistant.graph &&
|
||
Array.isArray((assistant.graph as WorkflowGraph).nodes) &&
|
||
(assistant.graph as WorkflowGraph).nodes.length > 0
|
||
? (assistant.graph as WorkflowGraph)
|
||
: defaultGraph();
|
||
const wfSettings: WorkflowSettings = {
|
||
llm: assistant.modelResourceIds.LLM,
|
||
asr: assistant.modelResourceIds.ASR,
|
||
tts: assistant.modelResourceIds.TTS,
|
||
allowInterrupt: assistant.enableInterrupt,
|
||
turnConfig: assistant.turnConfig,
|
||
};
|
||
setWorkflowName(assistant.name);
|
||
setWorkflowGraph(graph);
|
||
setWorkflowSettings(wfSettings);
|
||
setSavedSnapshot(
|
||
JSON.stringify({
|
||
name: assistant.name,
|
||
graph,
|
||
settings: wfSettings,
|
||
}),
|
||
);
|
||
}
|
||
setView(typeToView[assistant.type]);
|
||
} catch (error) {
|
||
if (!cancelled) {
|
||
setLoadError(
|
||
error instanceof Error ? error.message : "加载助手失败",
|
||
);
|
||
}
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [props.mode, editingId]);
|
||
|
||
function handleSaveOpenCode() {
|
||
void save(
|
||
baseUpsert({
|
||
name: openCodeForm.name.trim(),
|
||
type: "opencode",
|
||
enableInterrupt: openCodeForm.enableInterrupt,
|
||
turnConfig: openCodeForm.turnConfig,
|
||
visionEnabled: openCodeForm.visionEnabled,
|
||
visionModelResourceId: openCodeForm.visionModelResourceId || null,
|
||
modelResourceIds: {
|
||
...(openCodeForm.agent ? { Agent: openCodeForm.agent } : {}),
|
||
...(openCodeForm.model ? { LLM: openCodeForm.model } : {}),
|
||
...(openCodeForm.asr ? { ASR: openCodeForm.asr } : {}),
|
||
...(openCodeForm.voice ? { TTS: openCodeForm.voice } : {}),
|
||
},
|
||
prompt: openCodeForm.prompt,
|
||
}),
|
||
);
|
||
}
|
||
|
||
function handleSaveWorkflow() {
|
||
void save(
|
||
baseUpsert({
|
||
name: workflowName.trim(),
|
||
type: "workflow",
|
||
enableInterrupt: workflowSettings.allowInterrupt,
|
||
turnConfig: workflowSettings.turnConfig,
|
||
modelResourceIds: {
|
||
...(workflowSettings.llm ? { LLM: workflowSettings.llm } : {}),
|
||
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
|
||
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
|
||
},
|
||
graph: workflowGraph as unknown as Record<string, unknown>,
|
||
}),
|
||
);
|
||
}
|
||
|
||
// 当前编辑器表单相对已保存基线是否有改动(决定保存按钮是否可点)
|
||
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)
|
||
: view === "workflow"
|
||
? JSON.stringify({
|
||
name: workflowName,
|
||
graph: workflowGraph,
|
||
settings: workflowSettings,
|
||
})
|
||
: null;
|
||
const dirty =
|
||
activeFormJson !== null &&
|
||
savedSnapshot !== null &&
|
||
activeFormJson !== savedSnapshot;
|
||
|
||
const listItems: AssistantListItem[] = assistants.map((a) => ({
|
||
id: a.id,
|
||
name: a.name,
|
||
type: typeToLabel[a.type],
|
||
updatedAt: formatTimestamp(a.updatedAt),
|
||
updatedAtRaw: a.updatedAt,
|
||
}));
|
||
const filteredAssistants = listItems.filter((assistant) => {
|
||
if (typeFilter !== "全部" && assistant.type !== typeFilter) {
|
||
return false;
|
||
}
|
||
|
||
const keyword = searchQuery.trim().toLowerCase();
|
||
|
||
if (!keyword) {
|
||
return true;
|
||
}
|
||
|
||
return [assistant.name, assistant.id, assistant.type, assistant.updatedAt]
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(keyword);
|
||
});
|
||
|
||
// 按更新时间排序(以原始 ISO 时间戳为准);缺失时间戳视为最早,id 作为稳定的次级排序键
|
||
const timeValue = (iso: string | null | undefined) => {
|
||
const t = iso ? new Date(iso).getTime() : NaN;
|
||
return Number.isNaN(t) ? 0 : t;
|
||
};
|
||
const sortedAssistants = [...filteredAssistants].sort((a, b) => {
|
||
const diff = timeValue(b.updatedAtRaw) - timeValue(a.updatedAtRaw);
|
||
if (diff !== 0) return sortOrder === "newest" ? diff : -diff;
|
||
return a.id.localeCompare(b.id);
|
||
});
|
||
|
||
const pageSize = 5;
|
||
const totalPages = Math.max(1, Math.ceil(sortedAssistants.length / pageSize));
|
||
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||
const pageStart = (safeCurrentPage - 1) * pageSize;
|
||
const pageEnd = pageStart + pageSize;
|
||
const paginatedAssistants = sortedAssistants.slice(pageStart, pageEnd);
|
||
|
||
function handleSearchChange(value: string) {
|
||
setSearchQuery(value);
|
||
setCurrentPage(1);
|
||
}
|
||
|
||
function handleFilterChange(filter: TypeFilter) {
|
||
setTypeFilter(filter);
|
||
setCurrentPage(1);
|
||
}
|
||
|
||
function handleSortChange(order: SortOrder) {
|
||
setSortOrder(order);
|
||
setCurrentPage(1);
|
||
}
|
||
|
||
function toggleSortOrder() {
|
||
handleSortChange(sortOrder === "newest" ? "oldest" : "newest");
|
||
}
|
||
|
||
function updateForm<K extends keyof AssistantForm>(
|
||
key: K,
|
||
value: AssistantForm[K],
|
||
) {
|
||
setForm((prev) => ({
|
||
...prev,
|
||
[key]: value,
|
||
}));
|
||
}
|
||
|
||
function updateFastGptForm<K extends keyof FastGptForm>(
|
||
key: K,
|
||
value: FastGptForm[K],
|
||
) {
|
||
setFastGptForm((prev) => ({
|
||
...prev,
|
||
[key]: value,
|
||
}));
|
||
}
|
||
|
||
function updateDifyForm<K extends keyof DifyForm>(
|
||
key: K,
|
||
value: DifyForm[K],
|
||
) {
|
||
setDifyForm((prev) => ({
|
||
...prev,
|
||
[key]: value,
|
||
}));
|
||
}
|
||
|
||
function updateOpenCodeForm<K extends keyof OpenCodeForm>(
|
||
key: K,
|
||
value: OpenCodeForm[K],
|
||
) {
|
||
setOpenCodeForm((prev) => ({
|
||
...prev,
|
||
[key]: value,
|
||
}));
|
||
}
|
||
if (view === "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>
|
||
);
|
||
}
|
||
|
||
if (view === "list") {
|
||
return (
|
||
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
|
||
<PageHeader
|
||
title="助手列表"
|
||
description="管理已有的视频助手,支持提示词、工作流、Dify 和 FastGPT 类型。"
|
||
action={
|
||
<Button
|
||
size="lg"
|
||
className="w-full gap-2 sm:w-auto"
|
||
onClick={startCreate}
|
||
>
|
||
<Plus size={16} />
|
||
创建助手
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||
<ListToolbar
|
||
filters={
|
||
<FilterPills
|
||
options={typeFilters}
|
||
value={typeFilter}
|
||
onChange={handleFilterChange}
|
||
/>
|
||
}
|
||
search={
|
||
<SearchInput
|
||
value={searchQuery}
|
||
onChange={handleSearchChange}
|
||
placeholder="搜索助手名称、类型或 ID..."
|
||
className="lg:w-[320px]"
|
||
/>
|
||
}
|
||
/>
|
||
|
||
<DataList<AssistantListItem>
|
||
rows={paginatedAssistants}
|
||
rowKey={(assistant) => assistant.id}
|
||
loading={listLoading}
|
||
loadingText="正在加载助手列表…"
|
||
error={listError}
|
||
onRetry={() => void loadAssistants()}
|
||
empty={{
|
||
title: listItems.length === 0 ? "暂无助手" : "未找到匹配的助手",
|
||
description:
|
||
listItems.length === 0
|
||
? "点击右上角「创建助手」开始。"
|
||
: "请调整关键词或筛选条件后再试。",
|
||
}}
|
||
pagination={{
|
||
page: safeCurrentPage,
|
||
totalPages,
|
||
onPageChange: setCurrentPage,
|
||
summary:
|
||
filteredAssistants.length === 0
|
||
? "没有数据"
|
||
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredAssistants.length)} / 共 ${filteredAssistants.length} 个助手`,
|
||
}}
|
||
columns={[
|
||
{
|
||
key: "name",
|
||
header: "助手名称",
|
||
width: "md:w-[360px]",
|
||
cell: (assistant) => (
|
||
<>
|
||
<div className="truncate font-medium text-foreground">
|
||
{assistant.name}
|
||
</div>
|
||
<div className="mt-1 text-xs text-muted-soft">
|
||
{assistant.id}
|
||
</div>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: "type",
|
||
header: "助手类型",
|
||
width: "md:w-[128px]",
|
||
cell: (assistant) => (
|
||
<Badge
|
||
variant="secondary"
|
||
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||
>
|
||
{assistant.type}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
key: "updatedAt",
|
||
width: "md:w-[176px]",
|
||
header: (
|
||
<button
|
||
type="button"
|
||
onClick={toggleSortOrder}
|
||
className="caption-label -mx-2 inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||
aria-label={
|
||
sortOrder === "newest"
|
||
? "当前按最近更新排序,点击切换为最早更新"
|
||
: "当前按最早更新排序,点击切换为最近更新"
|
||
}
|
||
>
|
||
更新时间
|
||
{sortOrder === "newest" ? (
|
||
<ChevronDown size={13} />
|
||
) : (
|
||
<ChevronUp size={13} />
|
||
)}
|
||
</button>
|
||
),
|
||
cellClassName:
|
||
"whitespace-nowrap tabular-nums text-muted-foreground",
|
||
cell: (assistant) => assistant.updatedAt,
|
||
},
|
||
{
|
||
key: "actions",
|
||
header: "操作",
|
||
align: "right",
|
||
cellClassName: "flex justify-end gap-2",
|
||
cell: (assistant) => (
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||
onClick={() => handleEdit(assistant)}
|
||
>
|
||
<Pencil size={14} />
|
||
编辑
|
||
</Button>
|
||
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="icon-sm"
|
||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
aria-label={`${assistant.name} 更多操作`}
|
||
>
|
||
<MoreHorizontal size={15} />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="end"
|
||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||
>
|
||
<DropdownMenuItem
|
||
className="rounded-lg"
|
||
onSelect={() => handleDuplicate(assistant)}
|
||
>
|
||
<Copy size={14} />
|
||
复制
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
className="rounded-lg"
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
void handleDelete(assistant.id);
|
||
}}
|
||
>
|
||
<Trash2 size={14} />
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</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">
|
||
先为助手取个名字,再选择构建方式。确认后将立即创建助手并进入编辑页。
|
||
</p>
|
||
</div>
|
||
|
||
<Button
|
||
variant="outline"
|
||
size="lg"
|
||
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
onClick={() => router.push("/assistants")}
|
||
>
|
||
<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">
|
||
{createError && (
|
||
<span className="text-xs text-destructive">{createError}</span>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
size="lg"
|
||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
disabled={creating}
|
||
onClick={() => router.push("/assistants")}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
size="lg"
|
||
className="gap-2"
|
||
disabled={!draftName.trim() || !draftType || creating}
|
||
onClick={() => void confirmType()}
|
||
>
|
||
{creating ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<Rocket size={16} />
|
||
)}
|
||
创建助手
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (view === "workflow") {
|
||
return (
|
||
<div className="-mt-6 flex h-full flex-col gap-4">
|
||
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<EditorBackButton onClick={() => router.push("/assistants")} />
|
||
<EditableTitle value={workflowName} onChange={setWorkflowName} />
|
||
<AssistantIdentity assistantId={editingId} />
|
||
</div>
|
||
|
||
<div className="flex shrink-0 gap-2">
|
||
{saveError && (
|
||
<span className="self-center text-xs text-destructive">
|
||
{saveError}
|
||
</span>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
||
disabled={!editingId}
|
||
onClick={() => setDebugOpen(true)}
|
||
>
|
||
<Bug size={16} />
|
||
调试
|
||
</Button>
|
||
<Button
|
||
className="gap-2"
|
||
disabled={saving || !dirty || !workflowName.trim()}
|
||
onClick={() => handleSaveWorkflow()}
|
||
>
|
||
{saving ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<Save size={16} />
|
||
)}
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1">
|
||
<WorkflowEditor
|
||
value={workflowGraph}
|
||
onChange={setWorkflowGraph}
|
||
settings={workflowSettings}
|
||
onSettingsChange={setWorkflowSettings}
|
||
activeNodeId={activeNodeId}
|
||
modelOptions={{
|
||
llm: credOptions("LLM"),
|
||
asr: credOptions("ASR"),
|
||
tts: credOptions("TTS"),
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<Sheet
|
||
open={debugOpen}
|
||
onOpenChange={(open) => {
|
||
setDebugOpen(open);
|
||
if (!open) setActiveNodeId(null);
|
||
}}
|
||
modal={false}
|
||
>
|
||
<SheetContent
|
||
side="right"
|
||
showOverlay={false}
|
||
onInteractOutside={(e) => e.preventDefault()}
|
||
className="w-[440px] gap-0 border-l border-hairline bg-card p-0 sm:max-w-[440px]"
|
||
>
|
||
<SheetHeader className="sr-only">
|
||
<SheetTitle>语音调试</SheetTitle>
|
||
<SheetDescription>
|
||
与当前助手进行语音对话调试,画布会高亮正在激活的节点。
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
<DebugDrawer
|
||
assistantId={editingId}
|
||
asSheet
|
||
hasUnsavedChanges={dirty}
|
||
onNodeActive={setActiveNodeId}
|
||
/>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (view === "create-dify") {
|
||
return (
|
||
<div className="-mt-6 flex h-full flex-col gap-4">
|
||
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<EditorBackButton onClick={() => router.push("/assistants")} />
|
||
<EditableTitle
|
||
value={difyForm.name}
|
||
onChange={(value) => updateDifyForm("name", value)}
|
||
/>
|
||
<AssistantIdentity assistantId={editingId} />
|
||
</div>
|
||
|
||
<div className="flex shrink-0 gap-2">
|
||
{saveError && (
|
||
<span className="self-center text-xs text-destructive">
|
||
{saveError}
|
||
</span>
|
||
)}
|
||
<Button
|
||
className="gap-2"
|
||
disabled={
|
||
saving || !dirty || !difyForm.name.trim() || !difyForm.agent
|
||
}
|
||
onClick={() => void handleSaveDify()}
|
||
>
|
||
{saving ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<Save size={16} />
|
||
)}
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-1 gap-6">
|
||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||
<SectionCard
|
||
icon={<Boxes size={18} />}
|
||
title="Dify 应用配置"
|
||
description="从「模型资源」中选择 Dify 应用。开场白、知识库、提示词等对话编排请在 Dify 平台配置,本页不重复设置。"
|
||
>
|
||
<ResourceSelectField
|
||
label="Dify 应用"
|
||
value={difyForm.agent}
|
||
onChange={(value) => updateDifyForm("agent", value)}
|
||
options={agentOptions("dify")}
|
||
noneLabel="请选择"
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Brain size={18} />}
|
||
title="语音配置"
|
||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
||
>
|
||
<ResourceSelectField
|
||
label="语音识别"
|
||
value={difyForm.asr}
|
||
onChange={(value) => updateDifyForm("asr", value)}
|
||
options={credOptions("ASR")}
|
||
noneLabel="无"
|
||
/>
|
||
<ResourceSelectField
|
||
label="语音合成"
|
||
value={difyForm.voice}
|
||
onChange={(value) => updateDifyForm("voice", value)}
|
||
options={credOptions("TTS")}
|
||
noneLabel="无"
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Sparkles size={18} />}
|
||
title="交互策略"
|
||
description="设置实时视频对话时的交互体验"
|
||
>
|
||
<TurnConfigEditor
|
||
enabled={difyForm.enableInterrupt}
|
||
config={difyForm.turnConfig}
|
||
onEnabledChange={(checked) => updateDifyForm("enableInterrupt", checked)}
|
||
onConfigChange={(config) => updateDifyForm("turnConfig", config)}
|
||
/>
|
||
</SectionCard>
|
||
</div>
|
||
|
||
<DebugDrawer assistantId={editingId} hasUnsavedChanges={dirty} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (view === "create-fastgpt") {
|
||
return (
|
||
<div className="-mt-6 flex h-full flex-col gap-4">
|
||
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<EditorBackButton onClick={() => router.push("/assistants")} />
|
||
<EditableTitle
|
||
value={fastGptForm.name}
|
||
onChange={(value) => updateFastGptForm("name", value)}
|
||
/>
|
||
<AssistantIdentity assistantId={editingId} />
|
||
</div>
|
||
|
||
<div className="flex shrink-0 gap-2">
|
||
{saveError && (
|
||
<span className="self-center text-xs text-destructive">
|
||
{saveError}
|
||
</span>
|
||
)}
|
||
<Button
|
||
className="gap-2"
|
||
disabled={
|
||
saving ||
|
||
!dirty ||
|
||
!fastGptForm.name.trim() ||
|
||
!fastGptForm.agent
|
||
}
|
||
onClick={() => void handleSaveFastGpt()}
|
||
>
|
||
{saving ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<Save size={16} />
|
||
)}
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-1 gap-6">
|
||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||
<SectionCard
|
||
icon={<Database size={18} />}
|
||
title="FastGPT 应用配置"
|
||
description="从「模型资源」中选择 FastGPT 应用。开场白、知识库、提示词等对话编排请在 FastGPT 平台配置,本页不重复设置。"
|
||
>
|
||
<ResourceSelectField
|
||
label="FastGPT 应用"
|
||
value={fastGptForm.agent}
|
||
onChange={(value) => updateFastGptForm("agent", value)}
|
||
options={agentOptions("fastgpt")}
|
||
noneLabel="请选择"
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Brain size={18} />}
|
||
title="语音配置"
|
||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
||
>
|
||
<ResourceSelectField
|
||
label="语音识别"
|
||
value={fastGptForm.asr}
|
||
onChange={(value) => updateFastGptForm("asr", value)}
|
||
options={credOptions("ASR")}
|
||
noneLabel="无"
|
||
/>
|
||
<ResourceSelectField
|
||
label="语音合成"
|
||
value={fastGptForm.voice}
|
||
onChange={(value) => updateFastGptForm("voice", value)}
|
||
options={credOptions("TTS")}
|
||
noneLabel="无"
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Sparkles size={18} />}
|
||
title="交互策略"
|
||
description="设置实时视频对话时的交互体验"
|
||
>
|
||
<TurnConfigEditor
|
||
enabled={fastGptForm.enableInterrupt}
|
||
config={fastGptForm.turnConfig}
|
||
onEnabledChange={(checked) => updateFastGptForm("enableInterrupt", checked)}
|
||
onConfigChange={(config) => updateFastGptForm("turnConfig", config)}
|
||
/>
|
||
</SectionCard>
|
||
</div>
|
||
|
||
<DebugDrawer
|
||
assistantId={editingId}
|
||
hasUnsavedChanges={dirty}
|
||
vision={openCodeForm.visionEnabled}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (view === "create-opencode") {
|
||
return (
|
||
<div className="-mt-6 flex h-full flex-col gap-4">
|
||
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<EditorBackButton onClick={() => router.push("/assistants")} />
|
||
<EditableTitle
|
||
value={openCodeForm.name}
|
||
onChange={(value) => updateOpenCodeForm("name", value)}
|
||
/>
|
||
<AssistantIdentity assistantId={editingId} />
|
||
</div>
|
||
|
||
<div className="flex shrink-0 gap-2">
|
||
{saveError && (
|
||
<span className="self-center text-xs text-destructive">
|
||
{saveError}
|
||
</span>
|
||
)}
|
||
<Button
|
||
className="gap-2"
|
||
disabled={
|
||
saving ||
|
||
!dirty ||
|
||
!openCodeForm.name.trim() ||
|
||
!openCodeForm.agent
|
||
}
|
||
onClick={() => void handleSaveOpenCode()}
|
||
>
|
||
{saving ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<Save size={16} />
|
||
)}
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-1 gap-6">
|
||
<div className="scrollbar-subtle min-w-0 flex-1 space-y-5 overflow-y-auto pr-1">
|
||
<SectionCard
|
||
icon={<Terminal size={18} />}
|
||
title="OpenCode 服务配置"
|
||
description="从「模型资源」中选择 OpenCode 服务资源。"
|
||
>
|
||
<ResourceSelectField
|
||
label="OpenCode 服务"
|
||
value={openCodeForm.agent}
|
||
onChange={(value) => updateOpenCodeForm("agent", value)}
|
||
options={agentOptions("opencode")}
|
||
noneLabel="请选择"
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<MessageSquareText size={18} />}
|
||
title="提示词"
|
||
description="描述助手的角色、能力和回答要求"
|
||
>
|
||
<TextAreaField
|
||
value={openCodeForm.prompt}
|
||
onChange={(value) => updateOpenCodeForm("prompt", value)}
|
||
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
|
||
rows={8}
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Brain size={18} />}
|
||
title="模型与语音配置"
|
||
description="配置 OpenCode 使用的大语言模型、语音识别与语音合成资源。"
|
||
>
|
||
<ToggleRow
|
||
title="视觉理解"
|
||
hint="开启后,开始对话时会允许助手按需理解当前视频画面。视觉模型选「模型自己」时,大语言模型本身必须支持图片输入。"
|
||
checked={openCodeForm.visionEnabled}
|
||
onChange={handleOpenCodeVisionEnabledChange}
|
||
/>
|
||
{openCodeForm.visionEnabled && (
|
||
<ResourceSelectField
|
||
label="视觉模型"
|
||
value={openCodeForm.visionModelResourceId}
|
||
onChange={(value) =>
|
||
updateOpenCodeForm("visionModelResourceId", value)
|
||
}
|
||
options={visionModelOptionsFor(openCodeForm.model)}
|
||
noneLabel="模型自己"
|
||
/>
|
||
)}
|
||
<ResourceSelectField
|
||
label="大语言模型"
|
||
value={openCodeForm.model}
|
||
onChange={handleOpenCodeModelChange}
|
||
options={credOptions("LLM")}
|
||
noneLabel="无"
|
||
/>
|
||
<ResourceSelectField
|
||
label="语音识别"
|
||
value={openCodeForm.asr}
|
||
onChange={(value) => updateOpenCodeForm("asr", value)}
|
||
options={credOptions("ASR")}
|
||
noneLabel="无"
|
||
/>
|
||
<ResourceSelectField
|
||
label="语音合成"
|
||
value={openCodeForm.voice}
|
||
onChange={(value) => updateOpenCodeForm("voice", value)}
|
||
options={credOptions("TTS")}
|
||
noneLabel="无"
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Sparkles size={18} />}
|
||
title="交互策略"
|
||
description="设置实时视频对话时的交互体验"
|
||
>
|
||
<TurnConfigEditor
|
||
enabled={openCodeForm.enableInterrupt}
|
||
config={openCodeForm.turnConfig}
|
||
onEnabledChange={(checked) => updateOpenCodeForm("enableInterrupt", checked)}
|
||
onConfigChange={(config) => updateOpenCodeForm("turnConfig", config)}
|
||
/>
|
||
</SectionCard>
|
||
</div>
|
||
|
||
<DebugDrawer assistantId={editingId} hasUnsavedChanges={dirty} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="-mt-6 flex h-full flex-col gap-4">
|
||
<div className="flex shrink-0 items-center justify-between gap-6 border-b border-hairline pb-3 pt-1">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<EditorBackButton onClick={() => router.push("/assistants")} />
|
||
<EditableTitle
|
||
value={form.name}
|
||
onChange={(value) => updateForm("name", value)}
|
||
/>
|
||
<AssistantIdentity assistantId={editingId} />
|
||
</div>
|
||
|
||
<div className="flex shrink-0 gap-2">
|
||
{saveError && (
|
||
<span className="self-center text-xs text-destructive">
|
||
{saveError}
|
||
</span>
|
||
)}
|
||
<Button
|
||
className="gap-2"
|
||
disabled={saving || !dirty || !form.name.trim()}
|
||
onClick={() => void handleSavePrompt()}
|
||
>
|
||
{saving ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<Save size={16} />
|
||
)}
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-1 gap-6">
|
||
<div className="scrollbar-subtle 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="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
||
>
|
||
<ToggleRow
|
||
title="视觉理解"
|
||
hint="开启后,开始对话时会允许助手按需理解当前视频画面。视觉模型选「模型自己」时,大语言模型本身必须支持图片输入。"
|
||
checked={form.visionEnabled}
|
||
onChange={handlePromptVisionEnabledChange}
|
||
/>
|
||
{form.visionEnabled && (
|
||
<ResourceSelectField
|
||
label="视觉模型"
|
||
value={form.visionModelResourceId}
|
||
onChange={(value) =>
|
||
updateForm("visionModelResourceId", value)
|
||
}
|
||
options={visionModelOptionsFor(form.model)}
|
||
noneLabel="模型自己"
|
||
/>
|
||
)}
|
||
<ResourceSelectField
|
||
label="大语言模型"
|
||
value={form.model}
|
||
onChange={handlePromptModelChange}
|
||
options={credOptions("LLM")}
|
||
noneLabel="无"
|
||
/>
|
||
<ResourceSelectField
|
||
label="语音识别"
|
||
value={form.asr}
|
||
onChange={(value) => updateForm("asr", value)}
|
||
options={credOptions("ASR")}
|
||
noneLabel="无"
|
||
/>
|
||
<ResourceSelectField
|
||
label="语音合成"
|
||
value={form.voice}
|
||
onChange={(value) => updateForm("voice", value)}
|
||
options={credOptions("TTS")}
|
||
noneLabel="无"
|
||
/>
|
||
</SectionCard>
|
||
) : (
|
||
<SectionCard
|
||
icon={<Brain size={18} />}
|
||
title="模型配置"
|
||
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
||
>
|
||
<ResourceSelectField
|
||
label="Realtime 模型"
|
||
value={form.realtimeModel}
|
||
onChange={(value) => updateForm("realtimeModel", value)}
|
||
options={credOptions("Realtime")}
|
||
noneLabel="无"
|
||
/>
|
||
</SectionCard>
|
||
)}
|
||
|
||
<SectionCard
|
||
icon={<Bot size={18} />}
|
||
title="开场白"
|
||
description="助手与用户首次对话时的开场语"
|
||
>
|
||
<TextAreaField
|
||
value={form.greeting}
|
||
onChange={(value) => updateForm("greeting", value)}
|
||
placeholder="请输入助手开场白"
|
||
/>
|
||
</SectionCard>
|
||
|
||
{form.runtimeMode === "pipeline" && (
|
||
<SectionCard
|
||
icon={<Database size={18} />}
|
||
title="知识库配置"
|
||
description="选择助手回答时可检索的业务知识来源"
|
||
>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-sm font-medium text-foreground">
|
||
知识库选择
|
||
</span>
|
||
<KnowledgeRetrievalConfigDialog
|
||
disabled={!form.knowledgeBase}
|
||
value={form.knowledgeRetrievalConfig}
|
||
onChange={(config) =>
|
||
updateForm("knowledgeRetrievalConfig", config)
|
||
}
|
||
/>
|
||
</div>
|
||
<ResourceSelectField
|
||
value={form.knowledgeBase}
|
||
onChange={(value) => updateForm("knowledgeBase", value)}
|
||
options={kbOptions}
|
||
noneLabel="无"
|
||
/>
|
||
</SectionCard>
|
||
)}
|
||
|
||
<SectionCard
|
||
icon={<Wrench size={18} />}
|
||
title="工具"
|
||
description="配置该提示词助手可以调用的工具"
|
||
>
|
||
<ToolPicker
|
||
tools={tools.filter((tool) => tool.status === "active")}
|
||
selectedIds={form.toolIds}
|
||
onChange={(toolIds) => updateForm("toolIds", toolIds)}
|
||
/>
|
||
</SectionCard>
|
||
|
||
<SectionCard
|
||
icon={<Sparkles size={18} />}
|
||
title="交互策略"
|
||
description="设置实时视频对话时的交互体验"
|
||
>
|
||
{form.runtimeMode === "pipeline" ? (
|
||
<TurnConfigEditor
|
||
enabled={form.enableInterrupt}
|
||
config={form.turnConfig}
|
||
onEnabledChange={(checked) => updateForm("enableInterrupt", checked)}
|
||
onConfigChange={(config) => updateForm("turnConfig", config)}
|
||
/>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">
|
||
高级抢话与轮次检测当前仅支持 Pipeline 模式。
|
||
</p>
|
||
)}
|
||
</SectionCard>
|
||
</div>
|
||
|
||
<DebugDrawer
|
||
assistantId={editingId}
|
||
hasUnsavedChanges={dirty}
|
||
vision={form.visionEnabled}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type VizStyle = "aura" | "nebula" | "bars" | "wave";
|
||
|
||
// 调试面板顶部主视图:聊天记录 / 视频流
|
||
type DebugView = "chat" | "video";
|
||
type DebugInputMode = "mic" | "text";
|
||
|
||
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} /> },
|
||
];
|
||
|
||
// 中央语音可视化(光环/星云/频谱/波形)暂时隐藏:调试面板固定为
|
||
// 「上聊天记录 + 下波形监控」布局。置 true 可恢复可视化视图与样式切换。
|
||
const SHOW_VOICE_VIZ = false;
|
||
|
||
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,
|
||
asSheet = false,
|
||
hasUnsavedChanges = false,
|
||
onNodeActive,
|
||
vision = false,
|
||
}: {
|
||
assistantId: string | null;
|
||
asSheet?: boolean;
|
||
hasUnsavedChanges?: boolean;
|
||
onNodeActive?: (nodeId: string | null) => void;
|
||
vision?: boolean;
|
||
}) {
|
||
const preview = useVoicePreview(assistantId, onNodeActive);
|
||
const camera = useCameraPreview();
|
||
const [showTranscript, setShowTranscript] = useState(false);
|
||
const [vizStyle, setVizStyle] = useState<VizStyle>("aura");
|
||
const [view, setView] = useState<DebugView>("chat");
|
||
const stopCamera = camera.stop;
|
||
const startCamera = camera.start;
|
||
const recording =
|
||
preview.status === "connecting" || preview.status === "connected";
|
||
const shouldKeepCamera = vision && recording;
|
||
|
||
useEffect(() => {
|
||
if (shouldKeepCamera) {
|
||
void startCamera();
|
||
} else {
|
||
stopCamera();
|
||
}
|
||
}, [shouldKeepCamera, startCamera, stopCamera]);
|
||
|
||
const selectCamera = useCallback(
|
||
async (deviceId: string) => {
|
||
const nextStream = await camera.selectCamera(deviceId);
|
||
await preview.replaceVideoStream(nextStream);
|
||
},
|
||
[camera, preview],
|
||
);
|
||
|
||
const containerClass = asSheet
|
||
? "flex h-full min-w-0 flex-1 flex-col overflow-hidden"
|
||
: "hidden min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border border-hairline bg-card shadow-sm lg:flex";
|
||
|
||
return (
|
||
<aside className={containerClass}>
|
||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-5 py-3">
|
||
<div className="flex min-w-0 items-center gap-2.5">
|
||
<div className="shrink-0 text-sm font-medium text-foreground">
|
||
调试与预览
|
||
</div>
|
||
<NetworkQualityIndicator
|
||
quality={preview.networkQuality}
|
||
status={preview.status}
|
||
/>
|
||
<DebugConnectionStatus
|
||
status={preview.status}
|
||
micWarning={preview.micWarning}
|
||
/>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<CallPreviewLink assistantId={assistantId} />
|
||
{SHOW_VOICE_VIZ && view === "chat" && (
|
||
<>
|
||
{!showTranscript && (
|
||
<SegmentedIconGroup label="可视化样式">
|
||
{VIZ_OPTIONS.map((option) => (
|
||
<SegmentedIconButton
|
||
key={option.style}
|
||
selected={vizStyle === option.style}
|
||
label={`可视化样式:${option.label}`}
|
||
onClick={() => setVizStyle(option.style)}
|
||
>
|
||
{option.icon}
|
||
</SegmentedIconButton>
|
||
))}
|
||
</SegmentedIconGroup>
|
||
)}
|
||
<SegmentedIconGroup label="语音视图">
|
||
<SegmentedIconButton
|
||
selected={!showTranscript}
|
||
label="语音可视化视图"
|
||
onClick={() => setShowTranscript(false)}
|
||
>
|
||
<Mic size={14} />
|
||
</SegmentedIconButton>
|
||
<SegmentedIconButton
|
||
selected={showTranscript}
|
||
label="文字聊天记录视图"
|
||
onClick={() => setShowTranscript(true)}
|
||
>
|
||
<MessageSquareText size={14} />
|
||
</SegmentedIconButton>
|
||
</SegmentedIconGroup>
|
||
</>
|
||
)}
|
||
<SegmentedIconGroup label="预览视图">
|
||
<SegmentedIconButton
|
||
selected={view === "chat"}
|
||
label="聊天记录"
|
||
onClick={() => setView("chat")}
|
||
>
|
||
<MessageSquareText size={14} />
|
||
</SegmentedIconButton>
|
||
<SegmentedIconButton
|
||
selected={view === "video"}
|
||
label="视频流"
|
||
onClick={() => setView("video")}
|
||
>
|
||
<Video size={14} />
|
||
</SegmentedIconButton>
|
||
</SegmentedIconGroup>
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 border-b border-hairline px-5 py-2.5">
|
||
<div className="flex h-10 min-w-0 items-center rounded-[1.4rem] border border-hairline-strong bg-background px-2">
|
||
<CameraDeviceField camera={camera} onSelect={selectCamera} />
|
||
</div>
|
||
</div>
|
||
<DebugVoicePanel
|
||
view={view}
|
||
showTranscript={showTranscript}
|
||
vizStyle={vizStyle}
|
||
assistantId={assistantId}
|
||
preview={preview}
|
||
camera={camera}
|
||
hasUnsavedChanges={hasUnsavedChanges}
|
||
vision={vision}
|
||
/>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function CallPreviewLink({ assistantId }: { assistantId: string | null }) {
|
||
const [callUrl, setCallUrl] = useState("");
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const copyLink = useCallback(async () => {
|
||
if (!callUrl) return;
|
||
await navigator.clipboard.writeText(callUrl);
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1600);
|
||
}, [callUrl]);
|
||
|
||
return (
|
||
<Popover
|
||
onOpenChange={(open) => {
|
||
if (open && assistantId) {
|
||
setCallUrl(
|
||
`${window.location.origin}/call/${encodeURIComponent(assistantId)}`,
|
||
);
|
||
setCopied(false);
|
||
}
|
||
}}
|
||
>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
disabled={!assistantId}
|
||
aria-label="打开手机通话链接"
|
||
title={assistantId ? "手机通话链接" : "请先保存助手"}
|
||
className="flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
<Smartphone size={15} />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="end" side="bottom" className="w-80 space-y-3">
|
||
<div className="space-y-1">
|
||
<div className="text-sm font-medium text-foreground">手机通话预览</div>
|
||
<p className="text-xs leading-5 text-muted-foreground">
|
||
复制链接并在浏览器中打开,即可进入全屏视频通话界面。
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
readOnly
|
||
value={callUrl}
|
||
aria-label="手机通话链接"
|
||
onFocus={(event) => event.currentTarget.select()}
|
||
className="h-9 min-w-0 flex-1 text-xs"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
size="icon"
|
||
variant="secondary"
|
||
className="h-9 w-9"
|
||
onClick={() => void copyLink()}
|
||
aria-label="复制手机通话链接"
|
||
>
|
||
{copied ? <Check size={15} /> : <Copy size={15} />}
|
||
</Button>
|
||
</div>
|
||
{copied && <p className="text-xs text-success">链接已复制</p>}
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
function DeviceSelectField({
|
||
icon,
|
||
ariaLabel,
|
||
placeholder,
|
||
fallbackLabel,
|
||
value,
|
||
devices,
|
||
onSelect,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
ariaLabel: string;
|
||
placeholder: string;
|
||
fallbackLabel: string;
|
||
value: string;
|
||
devices: MediaDeviceInfo[];
|
||
onSelect: (deviceId: string) => void;
|
||
}) {
|
||
return (
|
||
<Select
|
||
value={value || "default"}
|
||
onValueChange={(nextValue) =>
|
||
onSelect(nextValue === "default" ? "" : nextValue)
|
||
}
|
||
>
|
||
<SelectTrigger
|
||
size="sm"
|
||
className="h-10 min-w-0 flex-1 justify-between rounded-full border-transparent bg-transparent px-3 text-sm text-foreground shadow-none hover:bg-surface-strong focus-visible:ring-0 data-[size=sm]:h-10"
|
||
aria-label={ariaLabel}
|
||
>
|
||
<span className="flex min-w-0 items-center gap-2">
|
||
{icon}
|
||
<span className="min-w-0 truncate text-left">
|
||
<SelectValue placeholder={placeholder} />
|
||
</span>
|
||
</span>
|
||
</SelectTrigger>
|
||
<SelectContent
|
||
position="popper"
|
||
side="top"
|
||
align="start"
|
||
className="w-[280px]"
|
||
>
|
||
<SelectItem value="default">{placeholder}</SelectItem>
|
||
{devices.map((device, index) => (
|
||
<SelectItem key={device.deviceId} value={device.deviceId}>
|
||
{device.label || `${fallbackLabel} ${index + 1}`}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
);
|
||
}
|
||
|
||
function MicrophoneDeviceField({ preview }: { preview: VoicePreview }) {
|
||
return (
|
||
<DeviceSelectField
|
||
icon={<Mic size={15} className="shrink-0 text-muted-soft" />}
|
||
ariaLabel="选择麦克风"
|
||
placeholder="默认麦克风"
|
||
fallbackLabel="麦克风"
|
||
value={preview.selectedDeviceId}
|
||
devices={preview.audioInputs}
|
||
onSelect={(deviceId) => preview.selectDevice(deviceId)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function CameraDeviceField({
|
||
camera,
|
||
onSelect,
|
||
}: {
|
||
camera: CameraPreview;
|
||
onSelect?: (deviceId: string) => void | Promise<void>;
|
||
}) {
|
||
return (
|
||
<DeviceSelectField
|
||
icon={<Video size={15} className="shrink-0 text-muted-soft" />}
|
||
ariaLabel="选择摄像头"
|
||
placeholder="默认摄像头"
|
||
fallbackLabel="摄像头"
|
||
value={camera.deviceId}
|
||
devices={camera.devices}
|
||
onSelect={(deviceId) => void (onSelect ?? camera.selectCamera)(deviceId)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function DebugInputModeButton({
|
||
selected,
|
||
label,
|
||
onClick,
|
||
children,
|
||
}: {
|
||
selected: boolean;
|
||
label: string;
|
||
onClick: () => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
aria-label={label}
|
||
aria-pressed={selected}
|
||
title={label}
|
||
onClick={onClick}
|
||
className={[
|
||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors",
|
||
selected
|
||
? "bg-surface-strong text-foreground"
|
||
: "text-muted-soft hover:bg-surface-strong hover:text-foreground",
|
||
].join(" ")}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function DebugVoicePanel({
|
||
view,
|
||
showTranscript,
|
||
vizStyle,
|
||
assistantId,
|
||
preview,
|
||
camera,
|
||
hasUnsavedChanges,
|
||
vision,
|
||
}: {
|
||
view: DebugView;
|
||
showTranscript: boolean;
|
||
vizStyle: VizStyle;
|
||
assistantId: string | null;
|
||
preview: VoicePreview;
|
||
camera: CameraPreview;
|
||
hasUnsavedChanges: boolean;
|
||
vision: boolean;
|
||
}) {
|
||
const {
|
||
status,
|
||
error,
|
||
micWarning,
|
||
localStream,
|
||
remoteStream,
|
||
messages,
|
||
sendText,
|
||
connect,
|
||
disconnect,
|
||
audioRef,
|
||
} = preview;
|
||
const recording = status === "connecting" || status === "connected";
|
||
const [textDraft, setTextDraft] = useState("");
|
||
const [inputMode, setInputMode] = useState<DebugInputMode>("mic");
|
||
const inChatView = view === "chat" && (!SHOW_VOICE_VIZ || showTranscript);
|
||
const idleOrFailed = status === "idle" || status === "failed";
|
||
const showIdleHub =
|
||
idleOrFailed &&
|
||
(view === "video" || (messages.length === 0 && inChatView));
|
||
const startBlockedMessage = !assistantId
|
||
? "请先保存助手,再开始对话。"
|
||
: hasUnsavedChanges
|
||
? "请先保存当前改动,再开始对话。"
|
||
: "";
|
||
const startDisabled = status === "connecting" || Boolean(startBlockedMessage);
|
||
|
||
function handleSendText() {
|
||
if (sendText(textDraft)) {
|
||
setTextDraft("");
|
||
}
|
||
}
|
||
|
||
const startConversation = useCallback(async () => {
|
||
if (!assistantId || hasUnsavedChanges) return;
|
||
const videoStream = vision ? await camera.start() : null;
|
||
await connect({
|
||
visionEnabled: vision,
|
||
videoStream,
|
||
});
|
||
}, [assistantId, camera, connect, hasUnsavedChanges, vision]);
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
{/* 后端 TTS 音频经 WebRTC 媒体流过来,挂这里播放 */}
|
||
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
||
{view === "video" ? (
|
||
showIdleHub ? (
|
||
<DebugIdleHub
|
||
status={status}
|
||
error={error}
|
||
message={startBlockedMessage}
|
||
connect={startConversation}
|
||
/>
|
||
) : (
|
||
<DebugVideoPanel camera={camera} />
|
||
)
|
||
) : !SHOW_VOICE_VIZ || showTranscript ? (
|
||
showIdleHub ? (
|
||
<DebugIdleHub
|
||
status={status}
|
||
error={error}
|
||
message={startBlockedMessage}
|
||
connect={startConversation}
|
||
/>
|
||
) : (
|
||
<DebugTranscriptPanel messages={messages} recording={recording} />
|
||
)
|
||
) : (
|
||
<div className="scrollbar-subtle relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 overflow-y-auto px-6 py-3 text-center">
|
||
<div
|
||
className="pointer-events-none absolute left-1/2 top-2 h-72 w-72 -translate-x-1/2 rounded-full opacity-50 blur-3xl"
|
||
style={{
|
||
background:
|
||
"radial-gradient(circle, color-mix(in srgb, var(--gradient-sky) 42%, transparent), color-mix(in srgb, var(--gradient-lavender) 18%, transparent) 48%, transparent 74%)",
|
||
}}
|
||
/>
|
||
|
||
<Badge
|
||
variant="secondary"
|
||
className="relative gap-1.5 rounded-full border border-hairline bg-canvas-soft px-3 py-1 text-[11px] font-medium text-muted-foreground shadow-none"
|
||
>
|
||
<span
|
||
className={[
|
||
"h-1.5 w-1.5 rounded-full",
|
||
recording ? "animate-pulse bg-success" : "bg-muted-soft",
|
||
].join(" ")}
|
||
/>
|
||
{recording ? "会话进行中" : "准备开始"}
|
||
</Badge>
|
||
|
||
<div className="relative flex h-[200px] w-[240px] shrink-0 items-center justify-center">
|
||
{(() => {
|
||
const shared = {
|
||
active: Boolean(localStream),
|
||
stream: localStream,
|
||
className: "relative shrink-0",
|
||
} as const;
|
||
if (vizStyle === "aura")
|
||
return <AuraVisualizer {...shared} size={200} />;
|
||
if (vizStyle === "nebula")
|
||
return <NebulaVisualizer {...shared} size={200} />;
|
||
if (vizStyle === "bars")
|
||
return <SpectrumVisualizer {...shared} size={200} />;
|
||
return <WaveVisualizer {...shared} size={200} />;
|
||
})()}
|
||
</div>
|
||
|
||
<div className="relative max-w-xs space-y-1.5">
|
||
<div className="font-display display-sm text-foreground">
|
||
{status === "connecting"
|
||
? "连接中…"
|
||
: status === "connected"
|
||
? micWarning
|
||
? "仅收听模式"
|
||
: "我在聆听"
|
||
: "开始一次语音对话"}
|
||
</div>
|
||
<p className="mx-auto text-xs leading-5 text-muted-foreground">
|
||
{status === "failed"
|
||
? error ||
|
||
"连接失败,请确认后端已启动且助手已保存后重试。"
|
||
: !assistantId
|
||
? "请先保存助手,再开始语音预览。"
|
||
: hasUnsavedChanges
|
||
? "请先保存当前改动,再开始语音预览。"
|
||
: micWarning
|
||
? `${micWarning} 可接收助手播报,但无法发送语音。`
|
||
: recording
|
||
? "直接说话即可。助手会在您停顿后自然回应。"
|
||
: "测试语音识别、响应速度与助手的播报效果。"}
|
||
</p>
|
||
</div>
|
||
|
||
<Button
|
||
disabled={recording ? status === "connecting" : startDisabled}
|
||
onClick={() => {
|
||
if (recording) {
|
||
disconnect();
|
||
} else {
|
||
void startConversation();
|
||
}
|
||
}}
|
||
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 ? "结束语音测试" : "开始语音测试"}
|
||
>
|
||
{status === "connecting" ? (
|
||
<Loader2 size={18} className="animate-spin" />
|
||
) : recording ? (
|
||
<PhoneOff size={18} />
|
||
) : (
|
||
<Mic size={18} />
|
||
)}
|
||
{recording ? "结束对话" : "开始对话"}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="shrink-0 border-t border-hairline bg-card p-3">
|
||
<div className="flex items-center gap-2">
|
||
<div
|
||
className={[
|
||
"flex h-10 min-w-0 items-center gap-1 rounded-[1.4rem] border border-hairline-strong bg-background px-2",
|
||
"flex-1",
|
||
].join(" ")}
|
||
>
|
||
<div className="flex shrink-0 items-center gap-0.5 rounded-full bg-canvas-soft p-0.5">
|
||
<DebugInputModeButton
|
||
selected={inputMode === "mic"}
|
||
label="选择麦克风设备"
|
||
onClick={() => setInputMode("mic")}
|
||
>
|
||
<Mic size={15} />
|
||
</DebugInputModeButton>
|
||
<DebugInputModeButton
|
||
selected={inputMode === "text"}
|
||
label="文字输入"
|
||
onClick={() => setInputMode("text")}
|
||
>
|
||
<MessageSquareText size={15} />
|
||
</DebugInputModeButton>
|
||
</div>
|
||
{inputMode === "mic" ? (
|
||
<MicrophoneDeviceField preview={preview} />
|
||
) : (
|
||
<Textarea
|
||
rows={1}
|
||
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"
|
||
? "输入文字发送给助手,将打断当前播报…"
|
||
: "开始对话后可输入文字…"
|
||
}
|
||
className="h-8 min-h-8 flex-1 resize-none overflow-hidden border-transparent bg-transparent px-2 py-1 text-sm leading-6 text-foreground shadow-none outline-none placeholder:text-muted-soft focus-visible:ring-0 disabled:opacity-100"
|
||
/>
|
||
)}
|
||
</div>
|
||
{inputMode === "text" && (
|
||
<Button
|
||
size="icon"
|
||
className="h-10 w-10 shrink-0 rounded-full"
|
||
aria-label="发送调试消息"
|
||
disabled={status !== "connected" || !textDraft.trim()}
|
||
onClick={handleSendText}
|
||
>
|
||
<Send size={16} />
|
||
</Button>
|
||
)}
|
||
{!showIdleHub && (
|
||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||
<Button
|
||
size="sm"
|
||
disabled={recording ? status === "connecting" : startDisabled}
|
||
onClick={() => {
|
||
if (recording) {
|
||
disconnect();
|
||
} else {
|
||
void startConversation();
|
||
}
|
||
}}
|
||
className={[
|
||
"h-10 gap-1.5 rounded-full px-4",
|
||
recording
|
||
? "bg-destructive text-white hover:bg-destructive/90"
|
||
: "",
|
||
].join(" ")}
|
||
aria-label={recording ? "结束语音测试" : "开始语音测试"}
|
||
>
|
||
{status === "connecting" ? (
|
||
<Loader2 size={14} className="animate-spin" />
|
||
) : recording ? (
|
||
<PhoneOff size={14} />
|
||
) : (
|
||
<Mic size={14} />
|
||
)}
|
||
{recording ? "结束对话" : "开始对话"}
|
||
</Button>
|
||
{!recording && startBlockedMessage && (
|
||
<span className="max-w-40 text-right text-[11px] leading-4 text-destructive">
|
||
{startBlockedMessage}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 底部双轨波形:会话连通后默认展开,空闲时收起 */}
|
||
<WaveformTimelinePanel
|
||
key={status === "connected" ? "active" : "idle"}
|
||
defaultOpen={false}
|
||
userStream={localStream}
|
||
agentStream={remoteStream}
|
||
active={status === "connected"}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 空闲态只保留一个明确入口,避免中间区域显得拥挤。
|
||
function DebugIdleHub({
|
||
status,
|
||
error,
|
||
message,
|
||
connect,
|
||
}: {
|
||
status: VoicePreviewStatus;
|
||
error: string | null;
|
||
message: string;
|
||
connect: () => Promise<void>;
|
||
}) {
|
||
const helperText =
|
||
message ||
|
||
(status === "failed"
|
||
? error || "连接失败,请确认后端已启动且助手已保存后重试。"
|
||
: "");
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 px-6 py-8 text-center">
|
||
<Button
|
||
disabled={Boolean(message)}
|
||
onClick={() => void connect()}
|
||
className="h-11 gap-2 rounded-full px-6 text-sm font-medium shadow-sm"
|
||
aria-label={status === "failed" ? "重新连接" : "开始语音测试"}
|
||
>
|
||
<Mic size={18} />
|
||
{status === "failed" ? "重新连接" : "开始对话"}
|
||
</Button>
|
||
{helperText && (
|
||
<p
|
||
className={[
|
||
"max-w-xs text-xs leading-5",
|
||
message ? "text-destructive" : "text-muted-foreground",
|
||
].join(" ")}
|
||
>
|
||
{helperText}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DebugVideoPanel({ camera }: { camera: CameraPreview }) {
|
||
const { stream, error, starting, active } = camera;
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (videoRef.current) videoRef.current.srcObject = stream;
|
||
}, [stream]);
|
||
|
||
return (
|
||
<div
|
||
className={[
|
||
"relative flex min-h-0 flex-1 items-center justify-center overflow-hidden",
|
||
active ? "bg-black" : "bg-canvas-soft",
|
||
].join(" ")}
|
||
>
|
||
<video
|
||
ref={videoRef}
|
||
autoPlay
|
||
playsInline
|
||
muted
|
||
className={[
|
||
"h-full w-full -scale-x-100 object-contain",
|
||
active ? "" : "hidden",
|
||
].join(" ")}
|
||
/>
|
||
{active ? (
|
||
<Badge
|
||
variant="secondary"
|
||
className="absolute left-3 top-3 gap-1.5 rounded-full border border-hairline bg-card/80 px-2.5 py-1 text-[11px] font-medium text-muted-foreground shadow-none backdrop-blur"
|
||
>
|
||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-success" />
|
||
预览中
|
||
</Badge>
|
||
) : (
|
||
<div className="flex max-w-xs flex-col items-center gap-2 px-6 text-center">
|
||
<Video size={28} className="text-muted-soft" />
|
||
<div className="text-sm font-medium text-foreground">
|
||
{starting ? "正在开启摄像头…" : "摄像头不可用"}
|
||
</div>
|
||
{error && (
|
||
<p className="text-xs leading-5 text-muted-foreground">{error}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DebugConnectionStatus({
|
||
status,
|
||
micWarning,
|
||
}: {
|
||
status: VoicePreviewStatus;
|
||
micWarning: string | null;
|
||
}) {
|
||
const recording = status === "connecting" || status === "connected";
|
||
const label =
|
||
status === "connecting"
|
||
? "连接中…"
|
||
: status === "connected"
|
||
? micWarning
|
||
? "仅收听"
|
||
: "进行中"
|
||
: status === "failed"
|
||
? "连接失败"
|
||
: "准备开始";
|
||
|
||
return (
|
||
<span className="flex min-w-0 items-center gap-1.5 truncate text-xs text-muted-foreground">
|
||
<span
|
||
className={[
|
||
"h-1.5 w-1.5 shrink-0 rounded-full",
|
||
recording
|
||
? "animate-pulse bg-success"
|
||
: status === "failed"
|
||
? "bg-destructive"
|
||
: "bg-muted-soft",
|
||
].join(" ")}
|
||
/>
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// 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 = false,
|
||
}: {
|
||
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={[
|
||
"scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-3",
|
||
recording ? "items-center justify-center" : "pt-5",
|
||
].join(" ")}
|
||
>
|
||
<div className="text-center">
|
||
<MessageSquareText size={22} className="mx-auto text-muted-soft" />
|
||
<div className="mt-2 text-sm font-medium text-foreground">
|
||
{recording ? "暂无聊天记录" : "尚未开始对话"}
|
||
</div>
|
||
<p className="mx-auto mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
|
||
{recording
|
||
? "开口说话或在下方输入文字,对话内容会实时显示在这里。"
|
||
: "点击「开始对话」后,语音与文字消息会实时显示在这里。"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
ref={scrollRef}
|
||
className="scrollbar-subtle flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-4"
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
{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>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EditorBackButton({ onClick }: { onClick: () => void }) {
|
||
return (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||
onClick={onClick}
|
||
aria-label="返回助手列表"
|
||
>
|
||
<ChevronLeft size={20} />
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
function AssistantIdentity({ assistantId }: { assistantId: string | null }) {
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
async function copyId() {
|
||
if (!assistantId) return;
|
||
await navigator.clipboard.writeText(assistantId);
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1600);
|
||
}
|
||
|
||
return (
|
||
<div className="ml-1 flex shrink-0 items-center rounded-full border border-hairline bg-canvas-soft pl-2.5 text-[11px] text-muted-foreground">
|
||
<span className="font-mono">
|
||
{assistantId ? `ID · ${assistantId}` : "ID · 保存后生成"}
|
||
</span>
|
||
{assistantId && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void copyId()}
|
||
className="ml-1 flex h-7 w-7 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||
aria-label={copied ? "助手 ID 已复制" : "复制助手 ID"}
|
||
title={copied ? "已复制" : "复制 ID"}
|
||
>
|
||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||
</button>
|
||
)}
|
||
{!assistantId && <span className="h-7 w-2" aria-hidden />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EditableTitle({
|
||
value,
|
||
onChange,
|
||
}: {
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [draft, setDraft] = useState(value);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (editing) {
|
||
inputRef.current?.focus();
|
||
inputRef.current?.select();
|
||
}
|
||
}, [editing]);
|
||
|
||
function startEdit() {
|
||
setDraft(value);
|
||
setEditing(true);
|
||
}
|
||
|
||
function commit() {
|
||
const next = draft.trim();
|
||
if (next) {
|
||
onChange(next);
|
||
}
|
||
setEditing(false);
|
||
}
|
||
|
||
if (editing) {
|
||
return (
|
||
<input
|
||
ref={inputRef}
|
||
value={draft}
|
||
onChange={(event) => setDraft(event.target.value)}
|
||
onBlur={commit}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
commit();
|
||
} else if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
setEditing(false);
|
||
}
|
||
}}
|
||
className="font-display display-sm w-[min(60vw,420px)] border-b border-primary bg-transparent text-ink outline-none"
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={startEdit}
|
||
title="点击修改助手名称"
|
||
className="group -mx-2 flex min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-strong"
|
||
>
|
||
<span className="font-display display-sm truncate text-ink">
|
||
{value || "未命名助手"}
|
||
</span>
|
||
<Pencil
|
||
size={16}
|
||
className="shrink-0 text-muted-soft opacity-0 transition-opacity group-hover:opacity-100"
|
||
/>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function HelpHint({ text }: { text: string }) {
|
||
return (
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<button
|
||
type="button"
|
||
aria-label="查看说明"
|
||
onClick={(event) => event.stopPropagation()}
|
||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||
>
|
||
<HelpCircle size={14} />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
align="start"
|
||
className="w-72 text-sm leading-6 text-muted-foreground"
|
||
>
|
||
{text}
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
function SectionCard({
|
||
icon,
|
||
title,
|
||
description,
|
||
children,
|
||
}: {
|
||
icon?: React.ReactNode;
|
||
title?: string;
|
||
description?: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
const hasHeader = Boolean(title);
|
||
|
||
return (
|
||
<Card className="rounded-2xl border-hairline bg-card text-card-foreground shadow-sm">
|
||
{hasHeader && (
|
||
<CardHeader>
|
||
<div className="flex items-center gap-3">
|
||
{icon && (
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||
{icon}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-1.5">
|
||
<CardTitle className="text-base font-medium">{title}</CardTitle>
|
||
{description && <HelpHint text={description} />}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
)}
|
||
|
||
<CardContent className={hasHeader ? "space-y-4" : undefined}>
|
||
{children}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function 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}
|
||
// Override ui/textarea's field-sizing-content so `rows` sets a real height
|
||
// instead of collapsing to min-h-16 when the value is short.
|
||
className="field-sizing-fixed min-h-32 resize-y border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
// Radix Select 不允许空字符串 value,用哨兵表示"未选/无"
|
||
const NONE_VALUE = "__none__";
|
||
|
||
function ResourceSelectField({
|
||
label,
|
||
value,
|
||
options,
|
||
noneLabel,
|
||
onChange,
|
||
}: {
|
||
label?: string;
|
||
value: string;
|
||
options: { value: string; label: string }[];
|
||
/** 提供则在顶部加一个"无/默认"选项,选中映射为空串 */
|
||
noneLabel?: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="block">
|
||
{label && (
|
||
<div className="mb-2 text-sm font-medium text-foreground">{label}</div>
|
||
)}
|
||
|
||
<Select
|
||
value={value || NONE_VALUE}
|
||
onValueChange={(v) => onChange(v === NONE_VALUE ? "" : v)}
|
||
>
|
||
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
|
||
<SelectValue placeholder={label ? `请选择${label}` : "请选择"} />
|
||
</SelectTrigger>
|
||
|
||
<SelectContent className="border-hairline bg-popover text-popover-foreground">
|
||
{noneLabel && (
|
||
<SelectItem value={NONE_VALUE}>{noneLabel}</SelectItem>
|
||
)}
|
||
{options.map((item) => (
|
||
<SelectItem key={item.value} value={item.value}>
|
||
{item.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function KnowledgeRetrievalConfigDialog({
|
||
disabled,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
disabled: boolean;
|
||
value: KnowledgeRetrievalConfig;
|
||
onChange: (config: KnowledgeRetrievalConfig) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [draft, setDraft] = useState(value);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
function openDialog() {
|
||
setDraft(value);
|
||
setError(null);
|
||
setOpen(true);
|
||
}
|
||
|
||
function saveDraft() {
|
||
if (draft.topN === 0 || draft.topN < -1 || !Number.isInteger(draft.topN)) {
|
||
setError("Top N 必须为 -1 或大于 0 的整数");
|
||
return;
|
||
}
|
||
if (draft.scoreThreshold < 0 || draft.scoreThreshold > 1) {
|
||
setError("最低相关度必须在 0 到 1 之间");
|
||
return;
|
||
}
|
||
onChange(draft);
|
||
setOpen(false);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={openDialog}
|
||
aria-label="打开知识库高级配置"
|
||
title={
|
||
disabled
|
||
? "请先选择知识库"
|
||
: `${value.mode === "automatic" ? "自动检索" : "模型主动检索"} · Top N ${value.topN === -1 ? "不限" : value.topN} · 最低相关度 ${value.scoreThreshold}`
|
||
}
|
||
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
<Settings2 size={14} />
|
||
</button>
|
||
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogContent className="sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>知识库高级配置</DialogTitle>
|
||
<DialogDescription>
|
||
设置检索触发方式、返回数量和相关度过滤条件。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-5 py-2">
|
||
<div className="space-y-2">
|
||
<div className="text-sm font-medium text-foreground">检索方式</div>
|
||
<Select
|
||
value={draft.mode}
|
||
onValueChange={(mode: "automatic" | "on_demand") =>
|
||
setDraft({ ...draft, mode })
|
||
}
|
||
>
|
||
<SelectTrigger className="w-full border-hairline-strong bg-background">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="automatic">自动检索</SelectItem>
|
||
<SelectItem value="on_demand">模型主动检索</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<p className="text-xs text-muted-foreground">
|
||
{draft.mode === "automatic"
|
||
? "每轮用户提问后自动检索,响应行为更稳定。"
|
||
: "由大模型判断是否调用知识库,依赖模型的工具调用能力。"}
|
||
</p>
|
||
</div>
|
||
|
||
<label className="block">
|
||
<span className="mb-2 block text-sm font-medium text-foreground">
|
||
最多返回片段数
|
||
</span>
|
||
<Input
|
||
type="number"
|
||
step="1"
|
||
min="-1"
|
||
value={draft.topN}
|
||
onChange={(event) =>
|
||
setDraft({ ...draft, topN: Number(event.target.value) })
|
||
}
|
||
/>
|
||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||
填写 -1 时保留所有达到阈值的结果。
|
||
</span>
|
||
</label>
|
||
|
||
<label className="block">
|
||
<span className="mb-2 block text-sm font-medium text-foreground">
|
||
最低相关度
|
||
</span>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
min="0"
|
||
max="1"
|
||
value={draft.scoreThreshold}
|
||
onChange={(event) =>
|
||
setDraft({
|
||
...draft,
|
||
scoreThreshold: Number(event.target.value),
|
||
})
|
||
}
|
||
/>
|
||
<span className="mt-1.5 block text-xs text-muted-foreground">
|
||
仅保留相关度达到该值的片段,范围 0–1。
|
||
</span>
|
||
</label>
|
||
|
||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button type="button" onClick={saveDraft}>
|
||
保存配置
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ToolPicker({
|
||
tools,
|
||
selectedIds,
|
||
onChange,
|
||
}: {
|
||
tools: Tool[];
|
||
selectedIds: string[];
|
||
onChange: (ids: string[]) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const [draftIds, setDraftIds] = useState<string[]>(selectedIds);
|
||
const selectedTools = selectedIds
|
||
.map((id) => tools.find((tool) => tool.id === id))
|
||
.filter((tool): tool is Tool => Boolean(tool));
|
||
|
||
function openPicker() {
|
||
setDraftIds(selectedIds);
|
||
setOpen(true);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="flex min-h-9 flex-wrap items-center gap-2">
|
||
{selectedTools.map((tool) => (
|
||
<div
|
||
key={tool.id}
|
||
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
|
||
>
|
||
{tool.type === "end_call" ? <PhoneOff size={14} /> : <Wrench size={14} />}
|
||
<span className="max-w-48 truncate">{tool.name}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(selectedIds.filter((id) => id !== tool.id))}
|
||
className="text-muted-soft transition-colors hover:text-foreground"
|
||
aria-label={`移除工具 ${tool.name}`}
|
||
>
|
||
<X size={13} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="icon-sm"
|
||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
onClick={openPicker}
|
||
aria-label="添加工具"
|
||
title="添加工具"
|
||
>
|
||
<Plus size={15} />
|
||
</Button>
|
||
</div>
|
||
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogContent className="sm:max-w-xl">
|
||
<DialogHeader>
|
||
<DialogTitle>选择工具</DialogTitle>
|
||
<DialogDescription>选择已经在工具资源中启用的工具。</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{tools.length === 0 ? (
|
||
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
|
||
暂无可用工具
|
||
</div>
|
||
) : (
|
||
<div className="max-h-80 divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
|
||
{tools.map((tool) => {
|
||
const checked = draftIds.includes(tool.id);
|
||
return (
|
||
<label
|
||
key={tool.id}
|
||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-strong/40"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={() =>
|
||
setDraftIds((current) =>
|
||
checked
|
||
? current.filter((id) => id !== tool.id)
|
||
: [...current, tool.id],
|
||
)
|
||
}
|
||
className="size-4 accent-primary"
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate font-medium text-foreground">
|
||
{tool.name}
|
||
</span>
|
||
<Badge variant="secondary">
|
||
{tool.type === "end_call" ? "End Call" : "HTTP"}
|
||
</Badge>
|
||
</div>
|
||
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||
{tool.functionName}
|
||
</div>
|
||
</div>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
onClick={() => {
|
||
onChange(draftIds);
|
||
setOpen(false);
|
||
}}
|
||
>
|
||
确认
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ToggleRow({
|
||
icon,
|
||
title,
|
||
description,
|
||
hint,
|
||
checked,
|
||
onChange,
|
||
}: {
|
||
icon?: React.ReactNode;
|
||
title: string;
|
||
description?: string;
|
||
hint?: string;
|
||
checked: boolean;
|
||
onChange: (checked: boolean) => void;
|
||
}) {
|
||
const hasIcon = Boolean(icon);
|
||
|
||
return (
|
||
<div
|
||
className={[
|
||
"flex items-center justify-between border border-hairline bg-canvas-soft",
|
||
hasIcon ? "rounded-2xl p-5" : "rounded-xl p-4",
|
||
].join(" ")}
|
||
>
|
||
<div>
|
||
<div
|
||
className={[
|
||
"flex items-center font-medium text-foreground",
|
||
hasIcon ? "gap-3" : "gap-1.5",
|
||
].join(" ")}
|
||
>
|
||
{icon && (
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||
{icon}
|
||
</span>
|
||
)}
|
||
<span className="flex items-center gap-1.5">
|
||
<span>{title}</span>
|
||
{hint && <HelpHint text={hint} />}
|
||
</span>
|
||
</div>
|
||
{description && (
|
||
<div className="mt-1 text-sm text-muted-foreground">
|
||
{description}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Switch checked={checked} onCheckedChange={onChange} />
|
||
</div>
|
||
);
|
||
}
|