Files
ai-video-fullstack/frontend/src/components/pages/AssistantPage.tsx
2026-08-07 10:59:47 +08:00

1459 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
Boxes,
Brain,
Copy,
MessageSquareText,
MoreHorizontal,
Pencil,
Plus,
Sparkles,
Trash2,
ChevronUp,
ChevronDown,
LayoutTemplate,
Save,
Terminal,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { TurnConfigEditor } from "@/components/turn-config-editor";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DataList } from "@/components/ui/data-list";
import {
ListPageLayout,
ListPageSection,
} from "@/components/layout/list-page-layout";
import { FilterPills } from "@/components/ui/filter-pills";
import { SearchInput } from "@/components/ui/search-input";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { useCallback, useEffect, 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 type { WorkflowSettings } from "@/components/workflow/types";
import { workflowGraphWithSettings } from "@/components/workflow/graph-settings";
import { SectionCard } from "@/components/editor/section-card";
import {
defaultGraph,
workflowUsesVision,
type WorkflowGraph,
} from "@/components/workflow/specs";
import {
defaultTurnConfig,
normalizeTurnConfig,
} from "@/lib/turn-config";
import {
activeDynamicVariableDefinitions,
activeWorkflowDynamicVariableDefinitions,
} from "@/components/assistant-editor/dynamic-variables";
import { useWorkflowEditorState } from "@/components/assistant-editor/use-workflow-editor-state";
import { DebugDrawer } from "@/components/assistant-editor/debug-preview";
import {
AssistantIdentity,
EditableTitle,
EditorBackButton,
ResourceSelectField,
TextAreaField,
ToggleRow,
} from "@/components/assistant-editor/editor-controls";
import type { AssistantForm } from "@/components/assistant-editor/types";
import { PromptEditor } from "@/components/assistant-editor/prompt-editor";
import { WorkflowPage } from "@/components/assistant-editor/workflow-page";
type AgentPlatform = "dify" | "fastgpt";
type AgentPlatformForm = {
name: string;
platform: AgentPlatform;
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 defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
return {
mode: "automatic",
topN: 5,
scoreThreshold: 0,
};
}
// 后端 type(英文) ↔ 列表展示标签(中文)
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
prompt: "提示词",
workflow: "工作流",
dify: "Dify",
fastgpt: "FastGPT",
opencode: "OpenCode",
};
// 后端 type → 编辑器视图(工作流暂为占位页)
const typeToView = {
prompt: "create",
dify: "create-agent-platform",
fastgpt: "create-agent-platform",
opencode: "create-opencode",
workflow: "workflow",
} as const;
type View = "list" | "loading" | (typeof typeToView)[ApiAssistantType];
// 路由驱动的页面模式:
// /assistants → list | /assistants/[id] → edit
export type AssistantPageProps =
| { mode: "list" }
| { mode: "edit"; assistantId: string };
// 各类型的空白表单模板(新建用)
function blankPromptForm(name: string): AssistantForm {
return {
name,
greeting: "",
prompt: "",
dynamicVariableDefinitions: {},
analysisConfig: {
enabled: false,
modelResourceId: "",
fields: [],
},
runtimeMode: "pipeline",
realtimeModel: "",
model: "",
asr: "",
voice: "",
knowledgeBase: "",
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
enableInterrupt: true,
turnConfig: defaultTurnConfig(),
startup: {
executionMode: "sequential",
openingMode: "interruptible",
entryMode: "wait_user",
actions: [],
openingMessage: null,
},
visionEnabled: false,
visionModelResourceId: "",
toolIds: [],
};
}
function blankAgentPlatformForm(name: string): AgentPlatformForm {
return {
name,
platform: "dify",
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 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 [agentPlatformForm, setAgentPlatformForm] =
useState<AgentPlatformForm>(() =>
blankAgentPlatformForm(""),
);
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 === "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);
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
const {
workflowName,
setWorkflowName,
workflowGraph,
setWorkflowGraph,
workflowSettings,
setWorkflowSettings,
workflowDynamicVariableDefinitions,
setWorkflowDynamicVariableDefinitions,
workflowDebugOpen,
setWorkflowDebugOpen,
workflowSettingsOpen,
setWorkflowSettingsOpen,
workflowEditingNodeId,
setWorkflowEditingNodeId,
workflowEditingEdgeId,
setWorkflowEditingEdgeId,
activeNodeId,
setActiveNodeId,
dynamicVariablesOpen,
setDynamicVariablesOpen,
} = useWorkflowEditorState();
const effectiveDynamicVariableDefinitions = activeDynamicVariableDefinitions(
[form.prompt, form.greeting],
form.dynamicVariableDefinitions,
);
const effectiveWorkflowDynamicVariableDefinitions =
activeWorkflowDynamicVariableDefinitions(
workflowGraph,
workflowDynamicVariableDefinitions,
);
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,
supportImageInput: c.supportImageInput,
}));
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,
dynamicVariableDefinitions: a.dynamicVariableDefinitions ?? {},
analysisConfig: a.analysisConfig ?? {
enabled: false,
modelResourceId: "",
fields: [],
},
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: normalizeTurnConfig(a.turnConfig),
startup: {
executionMode: "sequential",
openingMode: a.startup?.openingMode ?? "interruptible",
entryMode: a.startup?.entryMode ?? "wait_user",
actions: a.startup?.actions ?? [],
openingMessage: a.startup?.openingMessage ?? null,
},
visionEnabled: a.visionEnabled,
visionModelResourceId: a.visionModelResourceId ?? "",
toolIds: a.toolIds ?? [],
};
setForm(next);
return next;
}
// 编辑:跳转到 /assistants/[id],由编辑页按助手类型回填表单
function handleEdit(assistant: AssistantListItem) {
router.push(`/assistants/${assistant.id}`);
}
// 复制助手:服务端整行复制(含真 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(assistant: AssistantListItem) {
if (!window.confirm(`确认删除助手“${assistant.name}”?`)) return;
try {
await assistantsApi.remove(assistant.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(),
startup: {
executionMode: "sequential",
openingMode: "interruptible",
entryMode: "wait_user",
actions: [],
openingMessage: null,
},
visionEnabled: false,
visionModelResourceId: null,
modelResourceIds: {},
knowledgeBaseId: null,
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
toolIds: [],
prompt: "",
dynamicVariableDefinitions: {},
analysisConfig: {
enabled: false,
modelResourceId: "",
fields: [],
},
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-agent-platform") {
const next = { ...agentPlatformForm };
setAgentPlatformForm(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,
dynamicVariableDefinitions: effectiveWorkflowDynamicVariableDefinitions,
}),
);
}
} 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,
startup: form.startup,
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,
dynamicVariableDefinitions: effectiveDynamicVariableDefinitions,
analysisConfig: form.analysisConfig,
}),
);
}
async function handleDeleteCurrentAssistant() {
if (!editingId) return;
const assistantName = form.name.trim() || "未命名助手";
if (!window.confirm(`确认删除助手“${assistantName}”?`)) return;
setSaveError(null);
try {
await assistantsApi.remove(editingId);
router.push("/assistants");
} catch (error) {
setSaveError(error instanceof Error ? error.message : "删除失败");
}
}
// ---- 外部智能体平台Dify / FastGPT----
function fillAgentPlatformForm(a: Assistant): AgentPlatformForm {
const next: AgentPlatformForm = {
name: a.name,
platform: a.type === "fastgpt" ? "fastgpt" : "dify",
agent: a.modelResourceIds.Agent ?? "",
asr: a.modelResourceIds.ASR ?? "",
voice: a.modelResourceIds.TTS ?? "",
enableInterrupt: a.enableInterrupt,
turnConfig: normalizeTurnConfig(a.turnConfig),
};
setAgentPlatformForm(next);
return next;
}
function handleSaveAgentPlatform() {
void save(
baseUpsert({
name: agentPlatformForm.name.trim(),
type: agentPlatformForm.platform,
enableInterrupt: agentPlatformForm.enableInterrupt,
turnConfig: agentPlatformForm.turnConfig,
modelResourceIds: {
...(agentPlatformForm.agent
? { Agent: agentPlatformForm.agent }
: {}),
...(agentPlatformForm.asr ? { ASR: agentPlatformForm.asr } : {}),
...(agentPlatformForm.voice
? { TTS: agentPlatformForm.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: normalizeTurnConfig(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" ||
assistant.type === "dify"
) {
setSavedSnapshot(JSON.stringify(fillAgentPlatformForm(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 = {
runtimeMode:
graph.settings?.runtimeMode ?? assistant.runtimeMode ?? "pipeline",
llmRoutingMode:
graph.settings?.llmRoutingMode ?? "llm_router",
realtime:
graph.settings?.defaultRealtimeResourceId ||
assistant.modelResourceIds.Realtime,
llm:
graph.settings?.defaultLlmResourceId ||
assistant.modelResourceIds.LLM,
asr: graph.settings?.defaultAsrResourceId || assistant.modelResourceIds.ASR,
tts: graph.settings?.defaultTtsResourceId || assistant.modelResourceIds.TTS,
visionEnabled:
graph.settings?.visionEnabled ?? assistant.visionEnabled,
visionModelResourceId:
graph.settings?.visionModelResourceId ??
assistant.visionModelResourceId ??
"",
toolIds: graph.settings?.toolIds ?? [],
knowledgeBaseId:
graph.settings?.knowledgeBaseId || assistant.knowledgeBaseId || "",
knowledgeRetrievalConfig: {
mode:
graph.settings?.knowledgeMode ||
assistant.knowledgeRetrievalConfig.mode,
topN:
graph.settings?.knowledgeTopN ??
assistant.knowledgeRetrievalConfig.topN,
scoreThreshold:
graph.settings?.knowledgeScoreThreshold ??
assistant.knowledgeRetrievalConfig.scoreThreshold,
},
globalPrompt: graph.settings?.globalPrompt ?? "",
allowInterrupt:
graph.settings?.enableInterrupt ?? assistant.enableInterrupt,
turnConfig: normalizeTurnConfig(
graph.settings?.turnConfig ?? assistant.turnConfig,
),
};
// 编辑器会把兼容字段和默认值写回 graph。状态与保存基线必须使用
// 同一个规范化结果,否则页面刚打开就会被误判为有未保存改动。
const normalizedGraph = workflowGraphWithSettings(graph, wfSettings);
setWorkflowName(assistant.name);
setWorkflowGraph(normalizedGraph);
const dynamicVariableDefinitions =
activeWorkflowDynamicVariableDefinitions(
normalizedGraph,
assistant.dynamicVariableDefinitions ?? {},
);
setWorkflowDynamicVariableDefinitions(
dynamicVariableDefinitions,
);
setSavedSnapshot(
JSON.stringify({
name: assistant.name,
graph: normalizedGraph,
dynamicVariableDefinitions,
}),
);
}
setView(typeToView[assistant.type]);
} catch (error) {
if (!cancelled) {
setLoadError(
error instanceof Error ? error.message : "加载助手失败",
);
}
}
})();
return () => {
cancelled = true;
};
}, [
props.mode,
editingId,
setWorkflowDynamicVariableDefinitions,
setWorkflowGraph,
setWorkflowName,
setWorkflowSettings,
]);
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",
runtimeMode: workflowSettings.runtimeMode,
enableInterrupt: workflowSettings.allowInterrupt,
turnConfig: workflowSettings.turnConfig,
visionEnabled: workflowUsesVision(workflowGraph),
visionModelResourceId: null,
modelResourceIds: {
...(workflowSettings.realtime
? { Realtime: workflowSettings.realtime }
: {}),
...(workflowSettings.llm ? { LLM: workflowSettings.llm } : {}),
...(workflowSettings.asr ? { ASR: workflowSettings.asr } : {}),
...(workflowSettings.tts ? { TTS: workflowSettings.tts } : {}),
},
knowledgeBaseId: workflowSettings.knowledgeBaseId || null,
knowledgeRetrievalConfig: workflowSettings.knowledgeRetrievalConfig,
toolIds: workflowSettings.toolIds,
graph: workflowGraph as unknown as Record<string, unknown>,
dynamicVariableDefinitions: effectiveWorkflowDynamicVariableDefinitions,
}),
);
}
// 当前编辑器表单相对已保存基线是否有改动(决定保存按钮是否可点)
const activeFormJson =
view === "create"
? JSON.stringify(form)
: view === "create-agent-platform"
? JSON.stringify(agentPlatformForm)
: view === "create-opencode"
? JSON.stringify(openCodeForm)
: view === "workflow"
? JSON.stringify({
name: workflowName,
graph: workflowGraph,
dynamicVariableDefinitions:
effectiveWorkflowDynamicVariableDefinitions,
})
: 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 updateAgentPlatformForm<K extends keyof AgentPlatformForm>(
key: K,
value: AgentPlatformForm[K],
) {
setAgentPlatformForm((prev) => ({
...prev,
[key]: value,
}));
}
function handleAgentPlatformChange(platform: AgentPlatform) {
setAgentPlatformForm((prev) => ({
...prev,
platform,
agent: prev.platform === platform ? prev.agent : "",
}));
}
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 (
<ListPageLayout
title="助手列表"
description="管理已有的视频助手支持提示词、工作流、Dify 和 FastGPT 类型。"
action={
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-auto sm:flex-row">
<Button
variant="outline"
className="w-full min-w-[7.5rem] gap-2 border-hairline-strong px-4 text-foreground hover:bg-surface-strong sm:w-auto"
onClick={() => router.push("/assistants/templates")}
>
<LayoutTemplate size={16} />
</Button>
<Button
className="w-full min-w-[7.5rem] shrink-0 gap-2 px-4 sm:w-auto"
onClick={startCreate}
>
<Plus size={16} />
</Button>
</div>
}
>
<ListPageSection>
<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()}
onRowClick={handleEdit}
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) => (
<div
className="flex justify-end gap-2"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
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} 更多操作`}
onClick={(event) => event.stopPropagation()}
>
<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();
window.setTimeout(
() => void handleDelete(assistant),
0,
);
}}
>
<Trash2 size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
]}
/>
</ListPageSection>
</ListPageLayout>
);
}
if (view === "workflow") {
return (
<WorkflowPage
assistantId={editingId}
workflowName={workflowName}
workflowGraph={workflowGraph}
workflowSettings={workflowSettings}
dynamicVariableDefinitions={
effectiveWorkflowDynamicVariableDefinitions
}
workflowDebugOpen={workflowDebugOpen}
workflowSettingsOpen={workflowSettingsOpen}
workflowEditingNodeId={workflowEditingNodeId}
workflowEditingEdgeId={workflowEditingEdgeId}
activeNodeId={activeNodeId}
dynamicVariablesOpen={dynamicVariablesOpen}
saving={saving}
dirty={dirty}
saveError={saveError}
modelOptions={{
realtime: credOptions("Realtime"),
llm: credOptions("LLM"),
asr: credOptions("ASR"),
tts: credOptions("TTS"),
vision: visionModelOptionsFor(""),
}}
toolOptions={tools
.filter((tool) => tool.status === "active")
.map((tool) => ({
value: tool.id,
label: tool.name,
toolType: tool.type,
systemKind:
tool.definition.type === "system"
? tool.definition.config.kind
: undefined,
}))}
knowledgeOptions={kbOptions}
onBack={() => router.push("/assistants")}
onSave={() => void handleSaveWorkflow()}
setWorkflowName={setWorkflowName}
setWorkflowGraph={setWorkflowGraph}
setWorkflowSettings={setWorkflowSettings}
setWorkflowDynamicVariableDefinitions={
setWorkflowDynamicVariableDefinitions
}
setWorkflowDebugOpen={setWorkflowDebugOpen}
setWorkflowSettingsOpen={setWorkflowSettingsOpen}
setWorkflowEditingNodeId={setWorkflowEditingNodeId}
setWorkflowEditingEdgeId={setWorkflowEditingEdgeId}
setActiveNodeId={setActiveNodeId}
setDynamicVariablesOpen={setDynamicVariablesOpen}
/>
);
}
if (view === "create-agent-platform") {
const platformLabel =
agentPlatformForm.platform === "dify" ? "Dify" : "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={agentPlatformForm.name}
onChange={(value) => updateAgentPlatformForm("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 ||
!agentPlatformForm.name.trim() ||
!agentPlatformForm.agent
}
onClick={() => void handleSaveAgentPlatform()}
>
{saving ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Save size={16} />
)}
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 gap-4">
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
<SectionCard
icon={<Boxes size={15} />}
title="智能体平台配置"
description={`选择智能体平台及对应应用。开场白、知识库、提示词等对话编排请在 ${platformLabel} 平台配置,本页不重复设置。`}
>
<ResourceSelectField
label="智能体平台"
value={agentPlatformForm.platform}
onChange={(value) =>
handleAgentPlatformChange(value as AgentPlatform)
}
options={[
{ value: "dify", label: "Dify" },
{ value: "fastgpt", label: "FastGPT" },
]}
/>
<ResourceSelectField
label={`${platformLabel} 应用`}
value={agentPlatformForm.agent}
onChange={(value) => updateAgentPlatformForm("agent", value)}
options={agentOptions(agentPlatformForm.platform)}
noneLabel="请选择"
/>
</SectionCard>
<SectionCard
icon={<Brain size={15} />}
title="语音配置"
description={`从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 ${platformLabel} 应用提供,请前往 ${platformLabel} 平台配置。`}
>
<ResourceSelectField
label="语音识别"
value={agentPlatformForm.asr}
onChange={(value) => updateAgentPlatformForm("asr", value)}
options={credOptions("ASR")}
noneLabel="无"
/>
<ResourceSelectField
label="语音合成"
value={agentPlatformForm.voice}
onChange={(value) => updateAgentPlatformForm("voice", value)}
options={credOptions("TTS")}
noneLabel="无"
/>
</SectionCard>
<SectionCard
icon={<Sparkles size={15} />}
title="交互策略"
description="设置实时视频对话时的交互体验"
>
<TurnConfigEditor
enabled={agentPlatformForm.enableInterrupt}
config={agentPlatformForm.turnConfig}
onEnabledChange={(checked) =>
updateAgentPlatformForm("enableInterrupt", checked)
}
onConfigChange={(config) =>
updateAgentPlatformForm("turnConfig", config)
}
/>
</SectionCard>
</div>
<DebugDrawer assistantId={editingId} hasUnsavedChanges={dirty} />
</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-4">
<div className="scrollbar-subtle min-w-0 flex-1 space-y-3 overflow-y-auto pr-1">
<SectionCard
icon={<Terminal size={15} />}
title="OpenCode 服务配置"
description="从「模型资源」中选择 OpenCode 服务资源。"
>
<ResourceSelectField
label="OpenCode 服务"
value={openCodeForm.agent}
onChange={(value) => updateOpenCodeForm("agent", value)}
options={agentOptions("opencode")}
noneLabel="请选择"
/>
</SectionCard>
<SectionCard
icon={<MessageSquareText size={15} />}
title="提示词"
description="描述助手的角色、能力和回答要求"
>
<TextAreaField
value={openCodeForm.prompt}
onChange={(value) => updateOpenCodeForm("prompt", value)}
placeholder="请输入提示词,描述助手的角色、能力和回答要求"
rows={8}
/>
</SectionCard>
<SectionCard
icon={<Brain size={15} />}
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={15} />}
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 (
<PromptEditor
assistantId={editingId}
form={form}
dynamicVariableDefinitions={effectiveDynamicVariableDefinitions}
saving={saving}
dirty={dirty}
saveError={saveError}
llmOptions={credOptions("LLM")}
asrOptions={credOptions("ASR")}
ttsOptions={credOptions("TTS")}
realtimeOptions={credOptions("Realtime")}
visionModelOptions={visionModelOptionsFor()}
knowledgeOptions={kbOptions}
tools={tools}
onBack={() => router.push("/assistants")}
onSave={() => void handleSavePrompt()}
onDelete={() => void handleDeleteCurrentAssistant()}
updateForm={updateForm}
handlePromptVisionEnabledChange={handlePromptVisionEnabledChange}
handlePromptModelChange={handlePromptModelChange}
/>
);
}