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

1639 lines
55 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,
Check,
Copy,
MessageSquareText,
MoreHorizontal,
Pencil,
Plus,
Rocket,
Sparkles,
Trash2,
Workflow,
ChevronLeft,
ChevronUp,
ChevronDown,
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 { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 { 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 { 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";
type BuildMethod = "提示词" | "工作流" | "智能体平台" | "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",
};
const typeFromBuildMethod: Record<
Exclude<BuildMethod, "智能体平台">,
ApiAssistantType
> = {
: "prompt",
: "workflow",
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" | "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: "",
dynamicVariableDefinitions: {},
runtimeMode: "pipeline",
realtimeModel: "",
model: "",
asr: "",
voice: "",
knowledgeBase: "",
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
enableInterrupt: true,
turnConfig: defaultTurnConfig(),
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 AssistantTypeOption = {
type: BuildMethod;
label: string;
description: string;
icon: React.ReactNode;
/** 提示词、工作流、智能体平台已落地OpenCode 暂时显示即将上线 */
available: boolean;
};
const assistantTypeOptions: AssistantTypeOption[] = [
{
type: "提示词",
label: "使用提示词构建",
description: "通过提示词、模型与语音快速搭建对话助手,适合大多数场景。",
icon: <MessageSquareText size={20} />,
available: true,
},
{
type: "工作流",
label: "使用工作流构建",
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
icon: <Workflow size={20} />,
available: true,
},
{
type: "智能体平台",
label: "从智能体平台创建",
description:
"接入 Dify、FastGPT 等智能体平台,复用平台中的提示词、知识库和工作流配置。",
icon: <Boxes size={20} />,
available: true,
},
{
type: "OpenCode",
label: "使用 OpenCode 构建",
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
icon: <Terminal size={20} />,
available: false,
},
];
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 === "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<BuildMethod | null>(null);
const [draftAgentPlatform, setDraftAgentPlatform] =
useState<AgentPlatform | null>(null);
// 引导页:创建请求进行中 / 创建失败
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
// 已保存基线(当前类型表单的 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 }));
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 ?? {},
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),
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 ||
(draftType === "智能体平台" && !draftAgentPlatform) ||
creating
) {
return;
}
setCreating(true);
setCreateError(null);
try {
let assistantType: ApiAssistantType;
if (draftType === "智能体平台") {
if (!draftAgentPlatform) return;
assistantType = draftAgentPlatform;
} else {
assistantType = typeFromBuildMethod[draftType];
}
const created = await assistantsApi.create(
baseUpsert({
name: draftName.trim(),
type: assistantType,
}),
);
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: "",
dynamicVariableDefinitions: {},
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,
settings: workflowSettings,
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,
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,
}),
);
}
// ---- 外部智能体平台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 = {
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,
),
};
setWorkflowName(assistant.name);
setWorkflowGraph(graph);
setWorkflowSettings(wfSettings);
const dynamicVariableDefinitions =
activeWorkflowDynamicVariableDefinitions(
graph,
assistant.dynamicVariableDefinitions ?? {},
);
setWorkflowDynamicVariableDefinitions(
dynamicVariableDefinitions,
);
setSavedSnapshot(
JSON.stringify({
name: assistant.name,
graph,
settings: wfSettings,
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",
enableInterrupt: workflowSettings.allowInterrupt,
turnConfig: workflowSettings.turnConfig,
visionEnabled: workflowUsesVision(workflowGraph),
visionModelResourceId: null,
modelResourceIds: {
...(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,
settings: workflowSettings,
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 (
<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-4">
{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>
{draftType === "智能体平台" && (
<div className="rounded-2xl border border-hairline bg-card p-5">
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
</p>
<div className="mt-4 flex flex-wrap gap-3">
{(["dify", "fastgpt"] as const).map((platform) => {
const selected = draftAgentPlatform === platform;
const label = platform === "dify" ? "Dify" : "FastGPT";
return (
<Button
key={platform}
type="button"
variant={selected ? "default" : "outline"}
className={
selected
? "gap-2"
: "gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
}
onClick={() => setDraftAgentPlatform(platform)}
>
{selected && <Check size={14} />}
{label}
</Button>
);
})}
</div>
</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 ||
(draftType === "智能体平台" && !draftAgentPlatform) ||
creating
}
onClick={() => void confirmType()}
>
{creating ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Rocket size={16} />
)}
</Button>
</div>
</div>
);
}
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={{
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 }))}
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} 平台配置,本页不重复设置。`}
>
<div>
<div className="mb-2 text-sm font-medium text-foreground">
</div>
<div className="flex flex-wrap gap-2">
{(["dify", "fastgpt"] as const).map((platform) => {
const selected = agentPlatformForm.platform === platform;
const label = platform === "dify" ? "Dify" : "FastGPT";
return (
<Button
key={platform}
type="button"
size="sm"
variant={selected ? "default" : "outline"}
className={
selected
? "gap-2"
: "gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
}
onClick={() => handleAgentPlatformChange(platform)}
>
{selected && <Check size={14} />}
{label}
</Button>
);
})}
</div>
</div>
<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}
dynamicVariablesOpen={dynamicVariablesOpen}
dynamicVariableDefinitions={effectiveDynamicVariableDefinitions}
saving={saving}
dirty={dirty}
saveError={saveError}
llmOptions={credOptions("LLM")}
asrOptions={credOptions("ASR")}
ttsOptions={credOptions("TTS")}
realtimeOptions={credOptions("Realtime")}
visionModelOptions={visionModelOptionsFor(form.model)}
knowledgeOptions={kbOptions}
tools={tools}
onBack={() => router.push("/assistants")}
onSave={() => void handleSavePrompt()}
setDynamicVariablesOpen={setDynamicVariablesOpen}
updateForm={updateForm}
handlePromptVisionEnabledChange={handlePromptVisionEnabledChange}
handlePromptModelChange={handlePromptModelChange}
/>
);
}