feat(frontend): add template library and extract create-assistant page
Introduce a split-view template library for creating assistants from presets, share selection cards between create and template flows, and move the choose step out of AssistantPage into CreateAssistantPage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,19 +3,16 @@
|
||||
import {
|
||||
Boxes,
|
||||
Brain,
|
||||
Check,
|
||||
Copy,
|
||||
MessageSquareText,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Workflow,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
LayoutTemplate,
|
||||
Save,
|
||||
Terminal,
|
||||
Loader2,
|
||||
@@ -24,7 +21,6 @@ import {
|
||||
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,
|
||||
@@ -111,7 +107,6 @@ type OpenCodeForm = {
|
||||
};
|
||||
|
||||
type AssistantType = "提示词" | "工作流" | "Dify" | "FastGPT" | "OpenCode";
|
||||
type BuildMethod = "提示词" | "工作流" | "智能体平台" | "OpenCode";
|
||||
|
||||
const assistantTypes: AssistantType[] = [
|
||||
"提示词",
|
||||
@@ -137,14 +132,6 @@ const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
||||
fastgpt: "FastGPT",
|
||||
opencode: "OpenCode",
|
||||
};
|
||||
const typeFromBuildMethod: Record<
|
||||
Exclude<BuildMethod, "智能体平台">,
|
||||
ApiAssistantType
|
||||
> = {
|
||||
提示词: "prompt",
|
||||
工作流: "workflow",
|
||||
OpenCode: "opencode",
|
||||
};
|
||||
|
||||
// 后端 type → 编辑器视图(工作流暂为占位页)
|
||||
const typeToView = {
|
||||
@@ -155,13 +142,12 @@ const typeToView = {
|
||||
workflow: "workflow",
|
||||
} as const;
|
||||
|
||||
type View = "list" | "choose" | "loading" | (typeof typeToView)[ApiAssistantType];
|
||||
type View = "list" | "loading" | (typeof typeToView)[ApiAssistantType];
|
||||
|
||||
// 路由驱动的页面模式:
|
||||
// /assistants → list | /assistants/new → choose(引导,确认即建库) | /assistants/[id] → edit
|
||||
// /assistants → list | /assistants/[id] → edit
|
||||
export type AssistantPageProps =
|
||||
| { mode: "list" }
|
||||
| { mode: "choose" }
|
||||
| { mode: "edit"; assistantId: string };
|
||||
|
||||
// 各类型的空白表单模板(新建用)
|
||||
@@ -229,47 +215,6 @@ function formatTimestamp(iso?: string | null): string {
|
||||
)}:${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;
|
||||
@@ -313,7 +258,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
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";
|
||||
});
|
||||
@@ -321,13 +265,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
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 [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
||||
|
||||
@@ -499,33 +436,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
router.push(`/assistants/${assistant.id}`);
|
||||
}
|
||||
|
||||
// 引导页确认:直接创建到数据库拿到 id,再进入该助手的编辑页
|
||||
// 智能体平台默认先建 dify,具体平台在编辑页再选
|
||||
async function confirmType() {
|
||||
if (!draftName.trim() || !draftType || creating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const assistantType: ApiAssistantType =
|
||||
draftType === "智能体平台"
|
||||
? "dify"
|
||||
: 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 {
|
||||
@@ -1014,10 +924,20 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
title="助手列表"
|
||||
description="管理已有的视频助手,支持提示词、工作流、Dify 和 FastGPT 类型。"
|
||||
action={
|
||||
<Button className="w-full shrink-0 gap-2 sm:w-auto" onClick={startCreate}>
|
||||
<Plus size={16} />
|
||||
创建助手
|
||||
</Button>
|
||||
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-hairline-strong text-foreground hover:bg-surface-strong sm:w-auto"
|
||||
onClick={() => router.push("/assistants/templates")}
|
||||
>
|
||||
<LayoutTemplate size={16} />
|
||||
模版库
|
||||
</Button>
|
||||
<Button className="w-full shrink-0 gap-2 sm:w-auto" onClick={startCreate}>
|
||||
<Plus size={16} />
|
||||
创建助手
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ListPageSection>
|
||||
@@ -1191,120 +1111,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "choose") {
|
||||
return (
|
||||
<ListPageLayout
|
||||
title="创建助手"
|
||||
className="max-w-[1180px]"
|
||||
description="先为助手取个名字,再选择构建方式。确认后将立即创建助手并进入编辑页。"
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground sm:w-auto"
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
返回列表
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListPageSection>
|
||||
<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>
|
||||
</ListPageSection>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{createError && (
|
||||
<span className="text-xs text-destructive">{createError}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={creating}
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!draftName.trim() || !draftType || creating}
|
||||
onClick={() => void confirmType()}
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Rocket size={16} />
|
||||
)}
|
||||
创建助手
|
||||
</Button>
|
||||
</div>
|
||||
</ListPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "workflow") {
|
||||
return (
|
||||
<WorkflowPage
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Boxes,
|
||||
ChevronLeft,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Rocket,
|
||||
Terminal,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
ListPageLayout,
|
||||
ListPageSection,
|
||||
} from "@/components/layout/list-page-layout";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
SelectionOptionCard,
|
||||
selectionOptionGridClassName,
|
||||
} from "@/components/ui/selection-option-card";
|
||||
import {
|
||||
assistantsApi,
|
||||
type AssistantType as ApiAssistantType,
|
||||
type AssistantUpsert,
|
||||
type KnowledgeRetrievalConfig,
|
||||
} from "@/lib/api";
|
||||
import { defaultTurnConfig } from "@/lib/turn-config";
|
||||
|
||||
type BuildMethod = "提示词" | "工作流" | "智能体平台" | "OpenCode";
|
||||
|
||||
const typeFromBuildMethod: Record<
|
||||
Exclude<BuildMethod, "智能体平台">,
|
||||
ApiAssistantType
|
||||
> = {
|
||||
提示词: "prompt",
|
||||
工作流: "workflow",
|
||||
OpenCode: "opencode",
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
|
||||
return {
|
||||
mode: "automatic",
|
||||
topN: 5,
|
||||
scoreThreshold: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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: {},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
appId: "",
|
||||
graph: {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
export function CreateAssistantPage() {
|
||||
const router = useRouter();
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [draftType, setDraftType] = useState<BuildMethod | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!draftName.trim() || !draftType || creating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const assistantType: ApiAssistantType =
|
||||
draftType === "智能体平台"
|
||||
? "dify"
|
||||
: 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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ListPageLayout
|
||||
title="创建助手"
|
||||
className="max-w-[1180px]"
|
||||
description="先为助手取个名字,再选择构建方式。确认后将立即创建助手并进入编辑页。"
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground sm:w-auto"
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
返回列表
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListPageSection>
|
||||
<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>
|
||||
</ListPageSection>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="text-sm font-medium text-foreground">构建方式</div>
|
||||
|
||||
<div className={selectionOptionGridClassName}>
|
||||
{assistantTypeOptions.map((option) => (
|
||||
<SelectionOptionCard
|
||||
key={option.type}
|
||||
selected={draftType === option.type}
|
||||
icon={option.icon}
|
||||
title={option.label}
|
||||
description={option.description}
|
||||
onClick={() => setDraftType(option.type)}
|
||||
unselectedTrailing={
|
||||
!option.available ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 bg-surface-strong px-3 text-xs text-muted-foreground"
|
||||
>
|
||||
即将上线
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{createError && (
|
||||
<span className="text-xs text-destructive">{createError}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={creating}
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!draftName.trim() || !draftType || creating}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Rocket size={16} />
|
||||
)}
|
||||
创建助手
|
||||
</Button>
|
||||
</div>
|
||||
</ListPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Bot,
|
||||
ChevronLeft,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
import { SectionCard } from "@/components/editor/section-card";
|
||||
import { ListPageSection } from "@/components/layout/list-page-layout";
|
||||
import { TopbarPortal } from "@/components/layout/topbar-portal";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FilterPills } from "@/components/ui/filter-pills";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import {
|
||||
SelectionOptionCard,
|
||||
selectionOptionGridThreeColumnClassName,
|
||||
} from "@/components/ui/selection-option-card";
|
||||
import {
|
||||
assistantTemplates,
|
||||
type AssistantTemplate,
|
||||
type TemplateBuildMethod,
|
||||
} from "@/data/assistant-templates";
|
||||
import {
|
||||
assistantsApi,
|
||||
type AssistantType as ApiAssistantType,
|
||||
type AssistantUpsert,
|
||||
type KnowledgeRetrievalConfig,
|
||||
} from "@/lib/api";
|
||||
import { defaultTurnConfig } from "@/lib/turn-config";
|
||||
|
||||
function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
|
||||
return {
|
||||
mode: "automatic",
|
||||
topN: 5,
|
||||
scoreThreshold: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const TEMPLATE_TYPE_FILTERS = ["全部", "Prompt", "Workflow"] as const;
|
||||
|
||||
type TemplateTypeFilter = (typeof TEMPLATE_TYPE_FILTERS)[number];
|
||||
|
||||
function templateTypeLabel(buildMethod: TemplateBuildMethod): "Prompt" | "Workflow" {
|
||||
return buildMethod === "工作流" ? "Workflow" : "Prompt";
|
||||
}
|
||||
|
||||
function matchesTypeFilter(
|
||||
buildMethod: TemplateBuildMethod,
|
||||
filter: TemplateTypeFilter,
|
||||
): boolean {
|
||||
if (filter === "全部") return true;
|
||||
return templateTypeLabel(buildMethod) === filter;
|
||||
}
|
||||
|
||||
const buildMethodIcon: Record<
|
||||
TemplateBuildMethod,
|
||||
React.ComponentType<{ size?: number; className?: string }>
|
||||
> = {
|
||||
提示词: MessageSquareText,
|
||||
工作流: Workflow,
|
||||
};
|
||||
|
||||
function templateAssistantType(
|
||||
buildMethod: TemplateBuildMethod,
|
||||
): ApiAssistantType {
|
||||
return buildMethod === "工作流" ? "workflow" : "prompt";
|
||||
}
|
||||
|
||||
function baseUpsertFromTemplate(
|
||||
name: string,
|
||||
template: AssistantTemplate,
|
||||
): AssistantUpsert {
|
||||
return {
|
||||
name: name.trim(),
|
||||
type: templateAssistantType(template.buildMethod),
|
||||
runtimeMode: "pipeline",
|
||||
greeting: template.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: template.prompt,
|
||||
dynamicVariableDefinitions: {},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
appId: "",
|
||||
graph: {},
|
||||
};
|
||||
}
|
||||
|
||||
function TemplatePromptPreview({ template }: { template: AssistantTemplate }) {
|
||||
const BuildIcon = buildMethodIcon[template.buildMethod];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="提示词"
|
||||
description="描述助手的角色、能力和回答要求"
|
||||
>
|
||||
<p className="whitespace-pre-wrap rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3 text-sm leading-6 text-muted-foreground">
|
||||
{template.prompt}
|
||||
</p>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Bot size={15} />}
|
||||
title="开场白"
|
||||
description="助手与用户首次对话时的开场语"
|
||||
>
|
||||
<p className="rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3 text-sm leading-6 text-foreground">
|
||||
{template.greeting}
|
||||
</p>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={15} />}
|
||||
title="模版说明"
|
||||
description="构建方式、标签与能力亮点"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="h-6 gap-1.5 bg-surface-strong px-3 text-muted-foreground">
|
||||
<BuildIcon size={13} />
|
||||
{templateTypeLabel(template.buildMethod)}
|
||||
</Badge>
|
||||
{template.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-canvas-soft px-2.5 py-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
<ul className="space-y-2 border-t border-hairline pt-3">
|
||||
{template.highlights.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex gap-2 text-sm leading-6 text-foreground"
|
||||
>
|
||||
<span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-muted-foreground" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateWorkflowPreview() {
|
||||
return (
|
||||
<div
|
||||
className="min-h-0 flex-1 w-full bg-canvas-soft"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, color-mix(in srgb, var(--hairline) 80%, transparent) 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatePreviewHeader({
|
||||
selectedTemplate,
|
||||
}: {
|
||||
selectedTemplate: AssistantTemplate | null;
|
||||
}) {
|
||||
const previewHint = selectedTemplate
|
||||
? selectedTemplate.buildMethod === "工作流"
|
||||
? "工作流模版的节点编排预览(占位画布)。"
|
||||
: "查看模版预置的提示词、开场白与说明。"
|
||||
: "从左侧选择模版后,在此预览预置配置。";
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-hairline px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="text-sm font-medium text-foreground">配置预览</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{previewHint}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateEmptyPanel() {
|
||||
return (
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-6 py-16 text-center">
|
||||
<LayoutTemplate size={32} className="text-muted-soft" />
|
||||
<div className="mt-4 text-sm font-medium text-foreground">
|
||||
请从左侧选择一个模版
|
||||
</div>
|
||||
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground">
|
||||
选中后,右侧将展示该模版的配置预览。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type TemplateLibraryPageProps = {
|
||||
initialTemplateId?: string;
|
||||
};
|
||||
|
||||
export function TemplateLibraryPage({
|
||||
initialTemplateId,
|
||||
}: TemplateLibraryPageProps = {}) {
|
||||
const router = useRouter();
|
||||
const [assistantName, setAssistantName] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<TemplateTypeFilter>("全部");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
initialTemplateId ?? null,
|
||||
);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const keyword = searchQuery.trim().toLowerCase();
|
||||
return assistantTemplates.filter((template) => {
|
||||
const matchesType = matchesTypeFilter(template.buildMethod, typeFilter);
|
||||
const matchesSearch =
|
||||
keyword === "" ||
|
||||
template.name.toLowerCase().includes(keyword) ||
|
||||
template.description.toLowerCase().includes(keyword) ||
|
||||
template.tags.some((tag) => tag.toLowerCase().includes(keyword));
|
||||
return matchesType && matchesSearch;
|
||||
});
|
||||
}, [typeFilter, searchQuery]);
|
||||
|
||||
const selectedTemplate = useMemo(
|
||||
() => assistantTemplates.find((template) => template.id === selectedId) ?? null,
|
||||
[selectedId],
|
||||
);
|
||||
|
||||
const canCreate = Boolean(
|
||||
assistantName.trim() && selectedTemplate && !creating,
|
||||
);
|
||||
|
||||
async function handleCreateFromTemplate() {
|
||||
if (!canCreate || !selectedTemplate) return;
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const created = await assistantsApi.create(
|
||||
baseUpsertFromTemplate(assistantName, selectedTemplate),
|
||||
);
|
||||
router.push(`/assistants/${created.id}`);
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : "创建失败");
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopbarPortal>
|
||||
<div className="flex h-full min-w-0 flex-1 items-center sm:-ml-2 lg:-ml-4">
|
||||
<h1 className="font-display text-2xl text-ink">模版库</h1>
|
||||
</div>
|
||||
</TopbarPortal>
|
||||
|
||||
<div
|
||||
data-app-content="full-bleed"
|
||||
className="flex h-full min-h-0 overflow-hidden bg-background"
|
||||
>
|
||||
<aside className="flex min-w-0 w-1/2 shrink-0 flex-col border-r border-hairline bg-background">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-6 py-5 sm:px-8 sm:py-6">
|
||||
<div className="flex shrink-0 items-center justify-between gap-4">
|
||||
<p className="min-w-0 text-sm leading-6 whitespace-nowrap text-muted-foreground">
|
||||
先为助手取个名字,再选择模版。确认后将使用模版创建并进入编辑页。
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
返回列表
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ListPageSection>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-foreground">
|
||||
助手名称
|
||||
</div>
|
||||
<Input
|
||||
value={assistantName}
|
||||
autoFocus
|
||||
onChange={(event) => setAssistantName(event.target.value)}
|
||||
placeholder="请输入助手名称"
|
||||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</label>
|
||||
</ListPageSection>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div className="text-sm font-medium text-foreground">选择模版</div>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="搜索模版..."
|
||||
/>
|
||||
<FilterPills
|
||||
options={TEMPLATE_TYPE_FILTERS}
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
/>
|
||||
|
||||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-12 text-center">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
未找到模版
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
请调整类型或关键词。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={selectionOptionGridThreeColumnClassName}>
|
||||
{filteredTemplates.map((template) => {
|
||||
const BuildIcon = buildMethodIcon[template.buildMethod];
|
||||
|
||||
return (
|
||||
<SelectionOptionCard
|
||||
key={template.id}
|
||||
selected={template.id === selectedId}
|
||||
icon={<BuildIcon size={20} />}
|
||||
title={template.name}
|
||||
description={template.description}
|
||||
descriptionClassName="line-clamp-2"
|
||||
onClick={() => setSelectedId(template.id)}
|
||||
unselectedTrailing={
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 bg-surface-strong px-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{templateTypeLabel(template.buildMethod)}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-3">
|
||||
{createError && (
|
||||
<span className="mr-auto text-xs text-destructive">
|
||||
{createError}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={creating}
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!canCreate}
|
||||
onClick={() => void handleCreateFromTemplate()}
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Rocket size={16} />
|
||||
)}
|
||||
使用此模版
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-0 min-w-0 w-1/2 flex-col overflow-hidden bg-background">
|
||||
<TemplatePreviewHeader selectedTemplate={selectedTemplate} />
|
||||
{selectedTemplate?.buildMethod === "工作流" ? (
|
||||
<TemplateWorkflowPreview />
|
||||
) : (
|
||||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto overscroll-none px-4 pb-6 pt-3 sm:px-6 sm:pb-8 lg:px-8 lg:pb-10">
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
{selectedTemplate ? (
|
||||
<TemplatePromptPreview template={selectedTemplate} />
|
||||
) : (
|
||||
<TemplateEmptyPanel />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Create-assistant page: 4 columns within max-w-[1180px] content */
|
||||
export const selectionOptionGridClassName =
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4";
|
||||
|
||||
/** Template library left panel: 3 columns within a half-width split */
|
||||
export const selectionOptionGridThreeColumnClassName =
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3";
|
||||
|
||||
export type SelectionOptionCardProps = {
|
||||
selected: boolean;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
/** Top-right content when not selected. Omit to show nothing. */
|
||||
unselectedTrailing?: React.ReactNode;
|
||||
descriptionClassName?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SelectionOptionCard({
|
||||
selected,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
unselectedTrailing,
|
||||
descriptionClassName,
|
||||
className,
|
||||
}: SelectionOptionCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group relative flex w-full 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",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
{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>
|
||||
) : (
|
||||
unselectedTrailing ?? null
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-base font-medium text-foreground">{title}</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-sm leading-6 text-muted-foreground",
|
||||
descriptionClassName,
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user