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:
Xin Wang
2026-08-06 23:15:06 +08:00
parent 723ee84925
commit b51b768c29
8 changed files with 1077 additions and 213 deletions

View File

@@ -1,5 +1,5 @@
import { AssistantPage } from "@/components/pages/AssistantPage";
import { CreateAssistantPage } from "@/components/pages/CreateAssistantPage";
export default function Page() {
return <AssistantPage mode="choose" />;
return <CreateAssistantPage />;
}

View File

@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function Page({ params }: PageProps) {
const { id } = await params;
redirect(`/assistants/templates?template=${encodeURIComponent(id)}`);
}

View File

@@ -0,0 +1,10 @@
import { TemplateLibraryPage } from "@/components/pages/TemplateLibraryPage";
type PageProps = {
searchParams: Promise<{ template?: string }>;
};
export default async function Page({ searchParams }: PageProps) {
const { template } = await searchParams;
return <TemplateLibraryPage initialTemplateId={template} />;
}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,299 @@
export type TemplateBuildMethod = "提示词" | "工作流";
export type TemplatePreviewMessage = {
role: "user" | "assistant";
content: string;
};
export type AssistantTemplate = {
id: string;
name: string;
category: string;
description: string;
tags: string[];
buildMethod: TemplateBuildMethod;
greeting: string;
prompt: string;
highlights: string[];
previewMessages: TemplatePreviewMessage[];
};
export const assistantTemplates: AssistantTemplate[] = [
{
id: "tpl-customer-service",
name: "电商售后客服",
category: "客服",
description:
"处理退换货、物流查询与订单问题,语气耐心专业,适合接入商品知识库。",
tags: ["退换货", "订单查询", "Pipeline"],
buildMethod: "提示词",
greeting: "您好,我是您的专属售后助手,请问需要查询订单还是办理退换货?",
prompt:
"你是一名电商平台的售后客服助手。优先确认用户订单号,再依据平台政策解答退换货、物流与发票问题。回答简洁、步骤清晰,无法处理时引导用户转人工。",
highlights: [
"支持订单号引导与常见问题分流",
"默认 Pipeline 模式,可挂载商品知识库",
"开场白可直接询问订单或退换货意图",
],
previewMessages: [
{ role: "assistant", content: "您好,我是您的专属售后助手,请问需要查询订单还是办理退换货?" },
{ role: "user", content: "我昨天下的单还没发货,订单号是 20240806-8831。" },
{
role: "assistant",
content:
"已查到订单 20240806-8831当前状态为「仓库拣货中」预计今晚 22:00 前发出。需要我帮您修改收货地址吗?",
},
{ role: "user", content: "不用改地址,能加急吗?" },
{
role: "assistant",
content:
"可以为您标记加急出库,成功后物流单号会通过短信通知。还有其他问题我可以帮您处理吗?",
},
],
},
{
id: "tpl-sales-consultant",
name: "B2B 销售顾问",
category: "销售",
description:
"面向企业客户的方案咨询助手,擅长需求挖掘、产品亮点介绍与预约演示。",
tags: ["需求挖掘", "方案介绍", "预约演示"],
buildMethod: "提示词",
greeting: "您好,我是产品顾问小云。可以先简单说说您团队目前遇到的业务场景吗?",
prompt:
"你是 B2B SaaS 产品的销售顾问。通过 2-3 个开放式问题了解客户规模、现有流程与痛点,再匹配产品能力。避免过度承诺,适时邀请预约产品演示。",
highlights: [
"结构化需求提问,避免一上来报价",
"适合 Realtime 或 Pipeline 语音对话",
"可扩展 CRM 工具调用Mock",
],
previewMessages: [
{ role: "assistant", content: "您好,我是产品顾问小云。可以先简单说说您团队目前遇到的业务场景吗?" },
{ role: "user", content: "我们客服团队大概 40 人,想降低重复咨询的处理时间。" },
{
role: "assistant",
content:
"了解。请问重复咨询主要集中在哪些主题?例如物流、账号还是产品使用?另外目前是否有知识库或标准话术?",
},
{ role: "user", content: "主要是产品使用和售后政策,知识库比较零散。" },
{
role: "assistant",
content:
"这类场景很适合用 AI 助手先做首轮解答并检索知识库。我可以安排 30 分钟演示,带您看同类客户的落地方式,您看本周四下午是否方便?",
},
],
},
{
id: "tpl-interview-coach",
name: "面试模拟教练",
category: "培训",
description:
"模拟技术/产品面试场景,按 STAR 法则追问并给出反馈,适合培训与练习。",
tags: ["STAR 追问", "反馈点评", "角色扮演"],
buildMethod: "工作流",
greeting: "欢迎参加模拟面试。请先告诉我目标岗位,我们从自我介绍开始。",
prompt:
"你是一名严谨的面试官教练。每次只提一个问题,等待候选人回答后再追问细节。回答结束后给出 1 条优点和 1 条改进建议,保持鼓励但不空泛。",
highlights: [
"工作流编排:开场 → 提问 → 点评 → 下一题",
"适合视频通话中的连续追问",
"可按岗位切换题库Mock",
],
previewMessages: [
{ role: "assistant", content: "欢迎参加模拟面试。请先告诉我目标岗位,我们从自我介绍开始。" },
{ role: "user", content: "前端工程师,偏 React 和实时通信。" },
{
role: "assistant",
content:
"好的。请用 90 秒介绍一个你负责过的实时通信项目,重点说明你的职责与遇到的最大技术挑战。",
},
{ role: "user", content: "做过 WebRTC 客服系统,我负责信令层和重连策略……" },
{
role: "assistant",
content:
"回答结构清晰。建议补充量化结果,例如延迟或掉线率改善数据。下一个问题:如果 ICE 连接频繁失败,你会如何排查?",
},
],
},
{
id: "tpl-health-triage",
name: "健康咨询分诊",
category: "医疗",
description:
"收集症状与基本信息,给出就医建议与注意事项,不替代医生诊断。",
tags: ["分诊引导", "合规提示", "预约转诊"],
buildMethod: "工作流",
greeting: "您好,我是健康咨询助手。我会帮您梳理症状,但无法替代医生诊断。请问哪里不舒服?",
prompt:
"你是医疗机构的预问诊分诊助手。依次了解主诉、持续时间、严重程度与既往史。给出就医 urgency 建议与注意事项,遇到危急描述立即建议拨打 120 或前往急诊。",
highlights: [
"内置合规免责声明",
"工作流分支:轻症建议 / 建议门诊 / 紧急提醒",
"可对接预约 APIMock",
],
previewMessages: [
{
role: "assistant",
content: "您好,我是健康咨询助手。我会帮您梳理症状,但无法替代医生诊断。请问哪里不舒服?",
},
{ role: "user", content: "头痛两天了,还有点低烧。" },
{
role: "assistant",
content:
"请问体温大概多少?头痛是持续性的还是阵发性的?有没有颈部僵硬、呕吐或意识模糊?",
},
{ role: "user", content: "37.8 度左右,一阵一阵的,没有其他那些症状。" },
{
role: "assistant",
content:
"目前看不属于紧急危象。建议今日到社区门诊或互联网医院进一步评估,期间多休息、补水,若体温超过 38.5℃ 或症状加重请及时就医。",
},
],
},
{
id: "tpl-hotel-concierge",
name: "酒店礼宾助手",
category: "通用",
description:
"解答入住、设施、周边推荐与客房服务请求,语气温暖,适合 hospitality 场景。",
tags: ["入住咨询", "客房服务", "周边推荐"],
buildMethod: "提示词",
greeting: "欢迎光临。我是礼宾助手,可以帮您查询入住信息、设施开放时间与周边推荐。",
prompt:
"你是高端酒店的礼宾助手。优先确认客人姓名或房间号(若涉及隐私可跳过),再回答设施、餐饮、交通与本地推荐。语气礼貌、简洁,主动提供下一步帮助。",
highlights: [
"适合视频大堂或移动端呼叫",
"可挂载酒店 FAQ 知识库",
"支持中英文切换Mock",
],
previewMessages: [
{ role: "assistant", content: "欢迎光临。我是礼宾助手,可以帮您查询入住信息、设施开放时间与周边推荐。" },
{ role: "user", content: "健身房几点开?早餐到几点?" },
{
role: "assistant",
content:
"健身房每日 6:0022:00 开放,位于 3 层。自助早餐 6:3010:30在一层「云间」餐厅。需要我帮您预约私教或打包外带早餐吗",
},
],
},
{
id: "tpl-onboarding-guide",
name: "新员工入职向导",
category: "培训",
description:
"解答公司制度、IT 账号、办公地点与首日流程,降低 HR 重复答疑压力。",
tags: ["入职流程", "IT 支持", "制度问答"],
buildMethod: "提示词",
greeting: "欢迎加入团队!我是入职向导,可以帮您了解首日安排、账号开通和常用制度。",
prompt:
"你是企业新员工入职向导。根据员工部门与入职日期介绍报到流程、IT 账号、考勤与福利政策。不确定的信息引导联系 HR 或 IT 服务台,不要编造政策细节。",
highlights: [
"可绑定员工手册知识库",
"支持动态变量 {{department}}、{{start_date}}",
"适合 Pipeline + 知识检索",
],
previewMessages: [
{ role: "assistant", content: "欢迎加入团队!我是入职向导,可以帮您了解首日安排、账号开通和常用制度。" },
{ role: "user", content: "明天第一天上班,我需要带什么?几点到?" },
{
role: "assistant",
content:
"请携带身份证原件与学历复印件9:30 到总部 A 座 18 层 HR 前台报到。IT 会在报到后 1 小时内开通邮箱与 VPN建议提前下载企业通讯 App。",
},
],
},
{
id: "tpl-ticket-routing",
name: "工单智能分流",
category: "客服",
description:
"按问题类型、优先级与 SLA 将用户诉求路由到不同处理分支,适合复杂售后场景。",
tags: ["条件分支", "工单创建", "SLA"],
buildMethod: "工作流",
greeting: "您好,我是工单助手。请简单描述您遇到的问题,我会帮您归类并创建工单。",
prompt:
"你是工单分流助手。先识别问题类别(账号、支付、物流、产品故障),再判断紧急程度,引导用户补充必要信息并说明预计处理时效。",
highlights: [
"多分支路由:咨询 / 投诉 / 紧急故障",
"可对接工单系统创建 APIMock",
"支持转人工节点",
],
previewMessages: [],
},
{
id: "tpl-insurance-claim",
name: "保险理赔引导",
category: "通用",
description:
"分步骤收集事故信息、材料清单与理赔进度查询,减少重复说明。",
tags: ["材料清单", "进度查询", "分步表单"],
buildMethod: "工作流",
greeting: "您好,我是理赔引导助手。请问您需要报案、补充材料,还是查询进度?",
prompt:
"你是保险理赔引导助手。根据用户意图进入不同流程:报案需收集时间地点与损失描述;补材料需核对清单;查进度需验证保单号后四位。",
highlights: [
"报案 / 补材 / 查进度三条主路径",
"节点化收集结构化字段",
"合规话术与隐私字段脱敏",
],
previewMessages: [],
},
{
id: "tpl-course-sales",
name: "课程咨询转化",
category: "销售",
description:
"从课程介绍、试听预约到优惠说明的多节点销售流程,适合教培机构。",
tags: ["试听预约", "优惠说明", "线索留资"],
buildMethod: "工作流",
greeting: "你好,我是课程顾问。想了解哪门课程?我可以介绍大纲并帮你预约试听。",
prompt:
"你是教培机构课程顾问。先确认目标课程与用户基础,再介绍班型与价格区间,适时引导留资或预约试听,避免硬性推销。",
highlights: [
"课程匹配 → 试听预约 → 优惠说明",
"支持留资节点写入 CRMMock",
"可插入固定话术节点",
],
previewMessages: [],
},
{
id: "tpl-device-repair",
name: "设备故障报修",
category: "客服",
description:
"引导用户描述设备型号与故障现象,自动判断远程指导或上门维修路径。",
tags: ["故障诊断", "上门预约", "远程指导"],
buildMethod: "工作流",
greeting: "您好,我是设备报修助手。请告诉我设备型号和出现的故障现象。",
prompt:
"你是设备售后报修助手。收集型号、购买日期、故障代码或现象,判断可否远程排查;需上门时收集地址与可预约时间段。",
highlights: [
"远程指导 / 上门维修双路径",
"故障现象结构化采集",
"可对接预约系统Mock",
],
previewMessages: [],
},
{
id: "tpl-compliance-review",
name: "合规话术审查",
category: "培训",
description:
"按合规检查清单逐条核对销售话术,输出风险点与改写建议。",
tags: ["合规检查", "话术改写", "审核流程"],
buildMethod: "工作流",
greeting: "请提交待审查的话术或录音转写内容,我会按合规清单逐条检查。",
prompt:
"你是金融销售合规审查助手。按禁止承诺收益、风险提示、适当性匹配等维度检查输入内容,逐条标注风险并给出合规改写建议。",
highlights: [
"清单式多节点审查",
"输出风险等级与改写建议",
"适合质检与培训场景",
],
previewMessages: [],
},
];
export function getAssistantTemplate(id: string): AssistantTemplate | undefined {
return assistantTemplates.find((template) => template.id === id);
}