Refactor frontend routing and component structure for improved navigation
- Update CLAUDE.md to reflect changes in the navigation model, emphasizing the use of App Router routes for sidebar sections. - Refactor layout.tsx to wrap children in AppShell, enhancing the overall layout structure. - Replace AppShell usage in page.tsx with HomePage component for better separation of concerns. - Introduce new pages for assistants, components, dashboard, history, profile, and test, each rendering their respective components. - Revise Sidebar component to utilize Next.js Link for navigation and improve active state handling based on the current pathname. - Update AssistantPage to support routing-driven modes (list, choose, edit) and streamline form handling for assistant creation and editing.
This commit is contained in:
@@ -14,13 +14,24 @@ npm run lint # ESLint (no test suite exists yet)
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a single-page admin console for managing AI video assistants. It is a Next.js 16 app using the App Router, React 19, Tailwind CSS v4, and shadcn components backed by Radix UI primitives.
|
||||
This is an admin console for managing AI video assistants. It is a Next.js 16 app using the App Router, React 19, Tailwind CSS v4, and shadcn components backed by Radix UI primitives.
|
||||
|
||||
**Navigation model** — the app has no Next.js routes beyond `/`. All "pages" are React components in `src/components/pages/` that are conditionally rendered by `AppShell` based on a `NavKey` state value. `AppShell` owns the active page and sidebar-collapsed state and threads them down as props.
|
||||
**Navigation model** — each sidebar section is a real App Router route, so refresh/deep-link lands on the same page. Route files in `src/app/` are thin server components that render the page components from `src/components/pages/`. The route map:
|
||||
|
||||
| Route | Page |
|
||||
| --- | --- |
|
||||
| `/` | `HomePage` |
|
||||
| `/assistants` | `AssistantPage mode="list"` (助手列表) |
|
||||
| `/assistants/new` | `AssistantPage mode="choose"` (引导:取名+选构建方式,确认即 POST 建库并跳转编辑页) |
|
||||
| `/assistants/[id]` | `AssistantPage mode="edit"` (按 id 拉取并按类型回填编辑器) |
|
||||
| `/components/{models,knowledge,tools}` | 组件库三页 |
|
||||
| `/history`, `/dashboard`, `/test`, `/profile` | 其余侧栏页 |
|
||||
|
||||
`AssistantPage` is one client component driven by a discriminated-union `mode` prop; all in-page transitions (创建/编辑/返回) navigate via `router.push` instead of local view state. There is no separate create form: the 引导 page creates the assistant immediately (blank fields + chosen name/type) and the editor always works against an existing id. 保存 stays on the editor page — the save button is enabled only when the form differs from the last-saved snapshot (`savedSnapshot` JSON diff), and the header back button returns to the list.
|
||||
|
||||
**Component layers:**
|
||||
- `src/app/` — Next.js entry: `layout.tsx` (fonts, theme-flash script, metadata) and `page.tsx` (renders `<AppShell />`)
|
||||
- `src/components/layout/` — `AppShell` (page-switching shell), `Sidebar` (collapsible nav, 252 → 76px), `Topbar` (theme toggle, notifications), `ThemeToggle`
|
||||
- `src/app/` — Next.js entry: `layout.tsx` (fonts, theme-flash script, metadata, wraps everything in `AppShell`) plus one thin `page.tsx` per route
|
||||
- `src/components/layout/` — `AppShell` (sidebar+topbar shell around route children, owns sidebar-collapsed state), `Sidebar` (collapsible nav, 252 → 76px; `Link`-based, active state from `usePathname`), `Topbar` (theme toggle, notifications), `ThemeToggle`
|
||||
- `src/components/pages/` — one component per nav section; `PlaceholderPage` is a shared editorial header for unimplemented pages
|
||||
- `src/components/ui/` — shadcn primitives (button, card, badge, dialog, etc.)
|
||||
- `src/hooks/` — `use-mobile.ts`
|
||||
|
||||
10
frontend/src/app/assistants/[id]/page.tsx
Normal file
10
frontend/src/app/assistants/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AssistantPage } from "@/components/pages/AssistantPage";
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <AssistantPage mode="edit" assistantId={id} />;
|
||||
}
|
||||
5
frontend/src/app/assistants/new/page.tsx
Normal file
5
frontend/src/app/assistants/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AssistantPage } from "@/components/pages/AssistantPage";
|
||||
|
||||
export default function Page() {
|
||||
return <AssistantPage mode="choose" />;
|
||||
}
|
||||
5
frontend/src/app/assistants/page.tsx
Normal file
5
frontend/src/app/assistants/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AssistantPage } from "@/components/pages/AssistantPage";
|
||||
|
||||
export default function Page() {
|
||||
return <AssistantPage mode="list" />;
|
||||
}
|
||||
5
frontend/src/app/components/knowledge/page.tsx
Normal file
5
frontend/src/app/components/knowledge/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ComponentsKnowledgePage } from "@/components/pages/ComponentsKnowledgePage";
|
||||
|
||||
export default function Page() {
|
||||
return <ComponentsKnowledgePage />;
|
||||
}
|
||||
5
frontend/src/app/components/models/page.tsx
Normal file
5
frontend/src/app/components/models/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ComponentsModelsPage } from "@/components/pages/ComponentsModelsPage";
|
||||
|
||||
export default function Page() {
|
||||
return <ComponentsModelsPage />;
|
||||
}
|
||||
5
frontend/src/app/components/tools/page.tsx
Normal file
5
frontend/src/app/components/tools/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ComponentsToolsPage } from "@/components/pages/ComponentsToolsPage";
|
||||
|
||||
export default function Page() {
|
||||
return <ComponentsToolsPage />;
|
||||
}
|
||||
5
frontend/src/app/dashboard/page.tsx
Normal file
5
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DashboardPage } from "@/components/pages/DashboardPage";
|
||||
|
||||
export default function Page() {
|
||||
return <DashboardPage />;
|
||||
}
|
||||
5
frontend/src/app/history/page.tsx
Normal file
5
frontend/src/app/history/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { HistoryPage } from "@/components/pages/HistoryPage";
|
||||
|
||||
export default function Page() {
|
||||
return <HistoryPage />;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Geist_Mono, Inter, Cormorant_Garamond } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
|
||||
@@ -48,7 +49,9 @@ export default function RootLayout({
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<AppShell>{children}</AppShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
import { HomePage } from "@/components/pages/HomePage";
|
||||
|
||||
export default function Home() {
|
||||
return <AppShell />;
|
||||
}
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
5
frontend/src/app/profile/page.tsx
Normal file
5
frontend/src/app/profile/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ProfilePage } from "@/components/pages/ProfilePage";
|
||||
|
||||
export default function Page() {
|
||||
return <ProfilePage />;
|
||||
}
|
||||
5
frontend/src/app/test/page.tsx
Normal file
5
frontend/src/app/test/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TestPage } from "@/components/pages/TestPage";
|
||||
|
||||
export default function Page() {
|
||||
return <TestPage />;
|
||||
}
|
||||
@@ -4,56 +4,18 @@ import { useState } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
import { HomePage } from "@/components/pages/HomePage";
|
||||
import { AssistantPage } from "@/components/pages/AssistantPage";
|
||||
import { ComponentsModelsPage } from "@/components/pages/ComponentsModelsPage";
|
||||
import { ComponentsKnowledgePage } from "@/components/pages/ComponentsKnowledgePage";
|
||||
import { ComponentsToolsPage } from "@/components/pages/ComponentsToolsPage";
|
||||
import { HistoryPage } from "@/components/pages/HistoryPage";
|
||||
import { DashboardPage } from "@/components/pages/DashboardPage";
|
||||
import { TestPage } from "@/components/pages/TestPage";
|
||||
import { ProfilePage } from "@/components/pages/ProfilePage";
|
||||
|
||||
export type NavKey =
|
||||
| "home"
|
||||
| "assistants"
|
||||
| "components-models"
|
||||
| "components-knowledge"
|
||||
| "components-tools"
|
||||
| "history"
|
||||
| "dashboard"
|
||||
| "test"
|
||||
| "profile";
|
||||
|
||||
|
||||
export function AppShell() {
|
||||
const [active, setActive] = useState<NavKey>("home");
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background text-foreground">
|
||||
<Sidebar
|
||||
active={active}
|
||||
collapsed={collapsed}
|
||||
onNavigate={setActive}
|
||||
onToggle={() => setCollapsed((v) => !v)}
|
||||
/>
|
||||
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Topbar />
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-8 py-10">
|
||||
{active === "home" && <HomePage onNavigate={setActive} />}
|
||||
{active === "assistants" && <AssistantPage />}
|
||||
{active === "components-models" && <ComponentsModelsPage />}
|
||||
{active === "components-knowledge" && <ComponentsKnowledgePage />}
|
||||
{active === "components-tools" && <ComponentsToolsPage />}
|
||||
{active === "history" && <HistoryPage />}
|
||||
{active === "dashboard" && <DashboardPage />}
|
||||
{active === "test" && <TestPage />}
|
||||
{active === "profile" && <ProfilePage />}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-8 py-10">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Bot,
|
||||
Boxes,
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Database,
|
||||
Wrench,
|
||||
@@ -13,55 +14,38 @@ import {
|
||||
PlayCircle,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import type { NavKey } from "./AppShell";
|
||||
|
||||
type SidebarProps = {
|
||||
active: NavKey;
|
||||
collapsed: boolean;
|
||||
onNavigate: (key: NavKey) => void;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
const mainItems: Array<{
|
||||
key: NavKey;
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
}> = [
|
||||
{ key: "home", label: "首页", icon: Home },
|
||||
{ key: "test", label: "测试助手", icon: PlayCircle },
|
||||
};
|
||||
|
||||
const componentSubItems: NavItem[] = [
|
||||
{ href: "/components/models", label: "模型资源", icon: Brain },
|
||||
{ href: "/components/knowledge", label: "知识库", icon: Database },
|
||||
{ href: "/components/tools", label: "工具资源", icon: Wrench },
|
||||
];
|
||||
|
||||
const componentSubItems: Array<{
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
}> = [
|
||||
{ key: "components-models", label: "模型资源", icon: Brain },
|
||||
{ key: "components-knowledge", label: "知识库", icon: Database },
|
||||
{ key: "components-tools", label: "工具资源", icon: Wrench },
|
||||
const monitorSubItems: NavItem[] = [
|
||||
{ href: "/history", label: "历史记录", icon: Clock3 },
|
||||
{ href: "/dashboard", label: "数据看板", icon: Database },
|
||||
];
|
||||
|
||||
const monitorSubItems: Array<{
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
}> = [
|
||||
{ key: "history", label: "历史记录", icon: Clock3 },
|
||||
{ key: "dashboard", label: "数据看板", icon: Database },
|
||||
];
|
||||
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
export function Sidebar({
|
||||
active,
|
||||
collapsed,
|
||||
onNavigate,
|
||||
onToggle,
|
||||
}: SidebarProps) {
|
||||
const assistantActive = active === "assistants";
|
||||
// 精确匹配或前缀匹配(带 / 边界),让 /assistants/xxx 也高亮"创建助手"
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
const componentActive =
|
||||
active === "components-models" || active === "components-knowledge" || active === "components-tools";
|
||||
|
||||
const monitorActive = monitorSubItems.some((item) => item.key === active);
|
||||
const componentActive = componentSubItems.some((item) => isActive(item.href));
|
||||
const monitorActive = monitorSubItems.some((item) => isActive(item.href));
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -97,19 +81,19 @@ export function Sidebar({
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-5 pr-2 [scrollbar-width:thin] [scrollbar-color:var(--hairline-strong)_transparent]">
|
||||
<div className="space-y-1">
|
||||
<NavButton
|
||||
active={active === "home"}
|
||||
active={pathname === "/"}
|
||||
collapsed={collapsed}
|
||||
icon={Home}
|
||||
label="首页"
|
||||
onClick={() => onNavigate("home")}
|
||||
href="/"
|
||||
/>
|
||||
<div className="pt-2">
|
||||
<NavButton
|
||||
active={assistantActive}
|
||||
active={isActive("/assistants")}
|
||||
collapsed={collapsed}
|
||||
icon={Bot}
|
||||
label="创建助手"
|
||||
onClick={() => onNavigate("assistants")}
|
||||
href="/assistants"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -142,12 +126,12 @@ export function Sidebar({
|
||||
>
|
||||
{componentSubItems.map((item) => (
|
||||
<NavButton
|
||||
key={item.key}
|
||||
active={active === item.key}
|
||||
key={item.href}
|
||||
active={isActive(item.href)}
|
||||
collapsed={collapsed}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
onClick={() => onNavigate(item.key)}
|
||||
href={item.href}
|
||||
small
|
||||
/>
|
||||
))}
|
||||
@@ -184,28 +168,25 @@ export function Sidebar({
|
||||
>
|
||||
{monitorSubItems.map((item) => (
|
||||
<NavButton
|
||||
key={item.key}
|
||||
active={active === item.key}
|
||||
key={item.href}
|
||||
active={isActive(item.href)}
|
||||
collapsed={collapsed}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
onClick={() => onNavigate(item.key)}
|
||||
href={item.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
{mainItems.slice(1).map((item) => (
|
||||
<NavButton
|
||||
key={item.key}
|
||||
active={active === item.key}
|
||||
collapsed={collapsed}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
onClick={() => onNavigate(item.key)}
|
||||
/>
|
||||
))}
|
||||
<NavButton
|
||||
active={isActive("/test")}
|
||||
collapsed={collapsed}
|
||||
icon={PlayCircle}
|
||||
label="测试助手"
|
||||
href="/test"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -213,12 +194,12 @@ export function Sidebar({
|
||||
|
||||
<div className="shrink-0 space-y-2 border-t border-sidebar-border bg-sidebar p-3 shadow-[0_-12px_24px_rgba(0,0,0,0.12)]">
|
||||
{/* 个人中心 */}
|
||||
<button
|
||||
onClick={() => onNavigate("profile")}
|
||||
<Link
|
||||
href="/profile"
|
||||
title={collapsed ? "个人中心 · 管理员" : undefined}
|
||||
className={[
|
||||
"group relative flex w-full items-center overflow-hidden rounded-2xl border py-2 text-left transition-[background-color,color,border-color,box-shadow,transform] duration-200 active:scale-[0.98]",
|
||||
active === "profile"
|
||||
isActive("/profile")
|
||||
? "border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
||||
: "border-transparent text-muted-foreground hover:border-sidebar-border hover:bg-sidebar-accent/60 hover:text-foreground hover:shadow-sm",
|
||||
collapsed ? "justify-center gap-0 px-0" : "gap-3 px-2.5",
|
||||
@@ -251,7 +232,7 @@ export function Sidebar({
|
||||
管理员
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{/* 收起 / 展开侧栏 */}
|
||||
<button
|
||||
@@ -288,19 +269,19 @@ function NavButton({
|
||||
collapsed,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
href,
|
||||
small = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
collapsed: boolean;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
href: string;
|
||||
small?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
<Link
|
||||
href={href}
|
||||
title={collapsed ? label : undefined}
|
||||
className={[
|
||||
"group mt-1 flex w-full items-center overflow-hidden rounded-full text-sm transition-[background-color,color,transform] duration-200 active:scale-[0.98]",
|
||||
@@ -323,6 +304,6 @@ function NavButton({
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
assistantsApi,
|
||||
credentialsApi,
|
||||
@@ -142,6 +143,83 @@ const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
||||
fastgpt: "FastGPT",
|
||||
opencode: "OpenCode",
|
||||
};
|
||||
const typeFromLabel: Record<AssistantType, ApiAssistantType> = {
|
||||
提示词: "prompt",
|
||||
工作流: "workflow",
|
||||
Dify: "dify",
|
||||
FastGPT: "fastgpt",
|
||||
OpenCode: "opencode",
|
||||
};
|
||||
|
||||
// 后端 type → 编辑器视图(工作流暂为占位页)
|
||||
const typeToView = {
|
||||
prompt: "create",
|
||||
dify: "create-dify",
|
||||
fastgpt: "create-fastgpt",
|
||||
opencode: "create-opencode",
|
||||
workflow: "placeholder",
|
||||
} as const;
|
||||
|
||||
type View = "list" | "choose" | "loading" | (typeof typeToView)[ApiAssistantType];
|
||||
|
||||
// 路由驱动的页面模式:
|
||||
// /assistants → list | /assistants/new → choose(引导,确认即建库) | /assistants/[id] → edit
|
||||
export type AssistantPageProps =
|
||||
| { mode: "list" }
|
||||
| { mode: "choose" }
|
||||
| { mode: "edit"; assistantId: string };
|
||||
|
||||
// 各类型的空白表单模板(新建用)
|
||||
function blankPromptForm(name: string): AssistantForm {
|
||||
return {
|
||||
name,
|
||||
greeting: "",
|
||||
prompt: "",
|
||||
runtimeMode: "pipeline",
|
||||
realtimeModel: "",
|
||||
model: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
knowledgeBase: "",
|
||||
enableInterrupt: true,
|
||||
};
|
||||
}
|
||||
|
||||
function blankFastGptForm(name: string): FastGptForm {
|
||||
return {
|
||||
name,
|
||||
appId: "",
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
};
|
||||
}
|
||||
|
||||
function blankDifyForm(name: string): DifyForm {
|
||||
return {
|
||||
name,
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
};
|
||||
}
|
||||
|
||||
function blankOpenCodeForm(name: string): OpenCodeForm {
|
||||
return {
|
||||
name,
|
||||
prompt: "",
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
};
|
||||
}
|
||||
function formatTimestamp(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
@@ -210,73 +288,49 @@ type TypeFilter = "全部" | AssistantType;
|
||||
|
||||
const typeFilters: TypeFilter[] = ["全部", ...assistantTypes];
|
||||
|
||||
export function AssistantPage() {
|
||||
const [form, setForm] = useState<AssistantForm>({
|
||||
name: "政务视频咨询助手",
|
||||
greeting: "您好,我是 AI 视频助手,请问有什么可以帮您?",
|
||||
prompt:
|
||||
"你是一名专业的政务视频咨询助手,负责为市民提供政策解读、办事指南和常见问题解答。\n\n请遵循以下原则:\n1. 回答准确、简洁,使用通俗易懂的语言\n2. 不确定的信息应明确告知,不编造政策内容\n3. 涉及具体办事流程时,引导用户前往官方渠道核实",
|
||||
runtimeMode: "pipeline",
|
||||
realtimeModel: "",
|
||||
model: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
knowledgeBase: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
const [fastGptForm, setFastGptForm] = useState<FastGptForm>({
|
||||
name: "FastGPT 售后咨询助手",
|
||||
appId: "",
|
||||
apiUrl: "https://api.fastgpt.in/api/v1/chat/completions",
|
||||
apiKey: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
const [difyForm, setDifyForm] = useState<DifyForm>({
|
||||
name: "Dify 知识库问答助手",
|
||||
apiUrl: "https://api.dify.ai/v1/chat-messages",
|
||||
apiKey: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
const [openCodeForm, setOpenCodeForm] = useState<OpenCodeForm>({
|
||||
name: "OpenCode 代码助手",
|
||||
prompt:
|
||||
"你是一个代码助手的语音交互界面,请用简洁、口语化的方式回答用户关于代码与工程的问题。",
|
||||
apiUrl: "http://localhost:4096",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
export function AssistantPage(props: AssistantPageProps) {
|
||||
const router = useRouter();
|
||||
// 编辑中的助手 id(来自路由)
|
||||
const editingId = props.mode === "edit" ? props.assistantId : null;
|
||||
|
||||
const [form, setForm] = useState<AssistantForm>(() => blankPromptForm(""));
|
||||
const [fastGptForm, setFastGptForm] = useState<FastGptForm>(() =>
|
||||
blankFastGptForm(""),
|
||||
);
|
||||
const [difyForm, setDifyForm] = useState<DifyForm>(() => blankDifyForm(""));
|
||||
const [openCodeForm, setOpenCodeForm] = useState<OpenCodeForm>(() =>
|
||||
blankOpenCodeForm(""),
|
||||
);
|
||||
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
// 编辑中的助手 id;null = 新建
|
||||
const [editingId, setEditingId] = 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 [storedApiKeyMask, setStoredApiKeyMask] = useState("");
|
||||
// 下拉数据源:模型凭证 + 知识库
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [view, setView] = useState<
|
||||
| "list"
|
||||
| "choose"
|
||||
| "create"
|
||||
| "create-dify"
|
||||
| "create-fastgpt"
|
||||
| "create-opencode"
|
||||
| "placeholder"
|
||||
>("list");
|
||||
// 视图由路由模式决定;仅编辑模式需要先 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 [currentPage, setCurrentPage] = useState(1);
|
||||
// choose 步骤的草稿:名称与已选类型,确认后才决定进入哪个构建页
|
||||
// choose 步骤的草稿:名称与已选类型,确认后直接建库并进入编辑页
|
||||
// (工作流占位页也用它展示名称与类型)
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [draftType, setDraftType] = useState<AssistantType | null>(null);
|
||||
// 引导页:创建请求进行中 / 创建失败
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
||||
|
||||
const loadAssistants = useCallback(async () => {
|
||||
setListLoading(true);
|
||||
@@ -291,10 +345,11 @@ export function AssistantPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 挂载时拉取助手列表(与后端同步)
|
||||
// 列表页挂载时拉取助手列表(与后端同步)
|
||||
if (props.mode !== "list") return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadAssistants();
|
||||
}, [loadAssistants]);
|
||||
}, [props.mode, loadAssistants]);
|
||||
|
||||
// 进入创建/编辑前加载下拉数据源(模型凭证 + 知识库)
|
||||
const loadResources = useCallback(async () => {
|
||||
@@ -310,6 +365,13 @@ export function AssistantPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 编辑页挂载时加载下拉数据源
|
||||
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: Credential["type"]) =>
|
||||
credentials
|
||||
@@ -318,30 +380,13 @@ export function AssistantPage() {
|
||||
const kbOptions = knowledgeBases.map((k) => ({ value: k.id, label: k.name }));
|
||||
|
||||
function startCreate() {
|
||||
setDraftName("");
|
||||
setDraftType(null);
|
||||
setView("choose");
|
||||
}
|
||||
|
||||
// 提示词类型的空白模板(新建用)
|
||||
function blankPromptForm(name: string): AssistantForm {
|
||||
return {
|
||||
name,
|
||||
greeting: "",
|
||||
prompt: "",
|
||||
runtimeMode: "pipeline",
|
||||
realtimeModel: "",
|
||||
model: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
knowledgeBase: "",
|
||||
enableInterrupt: true,
|
||||
};
|
||||
router.push("/assistants/new");
|
||||
}
|
||||
|
||||
// 把后端 Assistant 回填进提示词表单(注意:model/asr/voice 等存的是凭证 id)
|
||||
function fillPromptForm(a: Assistant) {
|
||||
setForm({
|
||||
// 返回回填后的表单,供调用方记录"已保存基线"
|
||||
function fillPromptForm(a: Assistant): AssistantForm {
|
||||
const next: AssistantForm = {
|
||||
name: a.name,
|
||||
greeting: a.greeting,
|
||||
prompt: a.prompt,
|
||||
@@ -352,119 +397,35 @@ export function AssistantPage() {
|
||||
voice: a.ttsCredentialId ?? "",
|
||||
knowledgeBase: a.knowledgeBaseId ?? "",
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
});
|
||||
};
|
||||
setForm(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
// 编辑:根据助手类型进入对应的构建/编辑页
|
||||
async function handleEdit(assistant: AssistantListItem) {
|
||||
if (assistant.type === "提示词") {
|
||||
void loadResources();
|
||||
setSaveError(null);
|
||||
setEditingId(assistant.id);
|
||||
try {
|
||||
fillPromptForm(await assistantsApi.get(assistant.id));
|
||||
setView("create");
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : "加载助手失败");
|
||||
}
|
||||
} else if (assistant.type === "FastGPT") {
|
||||
void loadResources();
|
||||
setSaveError(null);
|
||||
setEditingId(assistant.id);
|
||||
try {
|
||||
fillFastGptForm(await assistantsApi.get(assistant.id));
|
||||
setView("create-fastgpt");
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : "加载助手失败");
|
||||
}
|
||||
} else if (assistant.type === "Dify") {
|
||||
void loadResources();
|
||||
setSaveError(null);
|
||||
setEditingId(assistant.id);
|
||||
try {
|
||||
fillDifyForm(await assistantsApi.get(assistant.id));
|
||||
setView("create-dify");
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : "加载助手失败");
|
||||
}
|
||||
} else if (assistant.type === "OpenCode") {
|
||||
void loadResources();
|
||||
setSaveError(null);
|
||||
setEditingId(assistant.id);
|
||||
try {
|
||||
fillOpenCodeForm(await assistantsApi.get(assistant.id));
|
||||
setView("create-opencode");
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : "加载助手失败");
|
||||
}
|
||||
} else {
|
||||
// 工作流:暂时显示占位页
|
||||
setDraftName(assistant.name);
|
||||
setDraftType(assistant.type);
|
||||
setView("placeholder");
|
||||
}
|
||||
// 编辑:跳转到 /assistants/[id],由编辑页按助手类型回填表单
|
||||
function handleEdit(assistant: AssistantListItem) {
|
||||
router.push(`/assistants/${assistant.id}`);
|
||||
}
|
||||
|
||||
function confirmType() {
|
||||
if (!draftName.trim() || !draftType) {
|
||||
// 引导页确认:直接创建到数据库拿到 id,再进入该助手的编辑页
|
||||
async function confirmType() {
|
||||
if (!draftName.trim() || !draftType || creating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draftType === "提示词") {
|
||||
// 提示词类型:新建,空白模板 + 带入名称
|
||||
void loadResources();
|
||||
setEditingId(null);
|
||||
setSaveError(null);
|
||||
setForm(blankPromptForm(draftName.trim()));
|
||||
setView("create");
|
||||
} else if (draftType === "FastGPT") {
|
||||
// FastGPT 类型:新建,清空表单 + 带入名称
|
||||
void loadResources();
|
||||
setEditingId(null);
|
||||
setSaveError(null);
|
||||
setFastGptForm({
|
||||
name: draftName.trim(),
|
||||
appId: "",
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
setView("create-fastgpt");
|
||||
} else if (draftType === "Dify") {
|
||||
// Dify 类型:新建,清空表单 + 带入名称
|
||||
void loadResources();
|
||||
setEditingId(null);
|
||||
setSaveError(null);
|
||||
setDifyForm({
|
||||
name: draftName.trim(),
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
setView("create-dify");
|
||||
} else if (draftType === "OpenCode") {
|
||||
// OpenCode 类型:新建,清空表单 + 带入名称
|
||||
void loadResources();
|
||||
setEditingId(null);
|
||||
setSaveError(null);
|
||||
setOpenCodeForm({
|
||||
name: draftName.trim(),
|
||||
prompt: "",
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
setView("create-opencode");
|
||||
} else {
|
||||
// 工作流:暂时显示占位页
|
||||
setView("placeholder");
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const created = await assistantsApi.create(
|
||||
baseUpsert({
|
||||
name: draftName.trim(),
|
||||
type: typeFromLabel[draftType],
|
||||
}),
|
||||
);
|
||||
router.push(`/assistants/${created.id}`);
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : "创建失败");
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,15 +471,30 @@ export function AssistantPage() {
|
||||
};
|
||||
}
|
||||
|
||||
// 统一的保存:新建 POST / 编辑 PUT
|
||||
// 保存:停留在编辑页,刷新密钥掩码并把当前表单记为已保存基线(按钮随之置灰)
|
||||
async function save(payload: AssistantUpsert) {
|
||||
if (!editingId) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
if (editingId) await assistantsApi.update(editingId, payload);
|
||||
else await assistantsApi.create(payload);
|
||||
await loadAssistants();
|
||||
setView("list");
|
||||
const updated = await assistantsApi.update(editingId, payload);
|
||||
setStoredApiKeyMask(updated.apiKey ?? "");
|
||||
// 密钥输入框清空(空值=保留已存密钥),并以清空后的表单为新基线
|
||||
if (view === "create") {
|
||||
setSavedSnapshot(JSON.stringify(form));
|
||||
} else if (view === "create-dify") {
|
||||
const next = { ...difyForm, apiKey: "" };
|
||||
setDifyForm(next);
|
||||
setSavedSnapshot(JSON.stringify(next));
|
||||
} else if (view === "create-fastgpt") {
|
||||
const next = { ...fastGptForm, apiKey: "" };
|
||||
setFastGptForm(next);
|
||||
setSavedSnapshot(JSON.stringify(next));
|
||||
} else if (view === "create-opencode") {
|
||||
const next = { ...openCodeForm, apiKey: "" };
|
||||
setOpenCodeForm(next);
|
||||
setSavedSnapshot(JSON.stringify(next));
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
@@ -545,8 +521,8 @@ export function AssistantPage() {
|
||||
}
|
||||
|
||||
// ---- Dify ----
|
||||
function fillDifyForm(a: Assistant) {
|
||||
setDifyForm({
|
||||
function fillDifyForm(a: Assistant): DifyForm {
|
||||
const next: DifyForm = {
|
||||
name: a.name,
|
||||
apiUrl: a.apiUrl,
|
||||
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
||||
@@ -554,7 +530,9 @@ export function AssistantPage() {
|
||||
asr: a.asrCredentialId ?? "",
|
||||
voice: a.ttsCredentialId ?? "",
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
});
|
||||
};
|
||||
setDifyForm(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function handleSaveDify() {
|
||||
@@ -572,8 +550,8 @@ export function AssistantPage() {
|
||||
}
|
||||
|
||||
// ---- FastGPT ----
|
||||
function fillFastGptForm(a: Assistant) {
|
||||
setFastGptForm({
|
||||
function fillFastGptForm(a: Assistant): FastGptForm {
|
||||
const next: FastGptForm = {
|
||||
name: a.name,
|
||||
appId: a.appId,
|
||||
apiUrl: a.apiUrl,
|
||||
@@ -582,7 +560,9 @@ export function AssistantPage() {
|
||||
asr: a.asrCredentialId ?? "",
|
||||
voice: a.ttsCredentialId ?? "",
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
});
|
||||
};
|
||||
setFastGptForm(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function handleSaveFastGpt() {
|
||||
@@ -601,8 +581,8 @@ export function AssistantPage() {
|
||||
}
|
||||
|
||||
// ---- OpenCode ----
|
||||
function fillOpenCodeForm(a: Assistant) {
|
||||
setOpenCodeForm({
|
||||
function fillOpenCodeForm(a: Assistant): OpenCodeForm {
|
||||
const next: OpenCodeForm = {
|
||||
name: a.name,
|
||||
prompt: a.prompt,
|
||||
apiUrl: a.apiUrl,
|
||||
@@ -612,9 +592,49 @@ export function AssistantPage() {
|
||||
asr: a.asrCredentialId ?? "",
|
||||
voice: a.ttsCredentialId ?? "",
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
});
|
||||
};
|
||||
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;
|
||||
setStoredApiKeyMask(assistant.apiKey ?? "");
|
||||
if (assistant.type === "prompt") {
|
||||
setSavedSnapshot(JSON.stringify(fillPromptForm(assistant)));
|
||||
} else if (assistant.type === "fastgpt") {
|
||||
setSavedSnapshot(JSON.stringify(fillFastGptForm(assistant)));
|
||||
} else if (assistant.type === "dify") {
|
||||
setSavedSnapshot(JSON.stringify(fillDifyForm(assistant)));
|
||||
} else if (assistant.type === "opencode") {
|
||||
setSavedSnapshot(JSON.stringify(fillOpenCodeForm(assistant)));
|
||||
} else {
|
||||
// 工作流:暂时显示占位页
|
||||
setDraftName(assistant.name);
|
||||
setDraftType(typeToLabel[assistant.type]);
|
||||
}
|
||||
setView(typeToView[assistant.type]);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoadError(
|
||||
error instanceof Error ? error.message : "加载助手失败",
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props.mode, editingId]);
|
||||
|
||||
function handleSaveOpenCode() {
|
||||
void save(
|
||||
baseUpsert({
|
||||
@@ -631,17 +651,28 @@ export function AssistantPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// 当前编辑器表单相对已保存基线是否有改动(决定保存按钮是否可点)
|
||||
const activeFormJson =
|
||||
view === "create"
|
||||
? JSON.stringify(form)
|
||||
: view === "create-dify"
|
||||
? JSON.stringify(difyForm)
|
||||
: view === "create-fastgpt"
|
||||
? JSON.stringify(fastGptForm)
|
||||
: view === "create-opencode"
|
||||
? JSON.stringify(openCodeForm)
|
||||
: 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),
|
||||
}));
|
||||
const storedApiKeyMask =
|
||||
(editingId &&
|
||||
assistants.find((assistant) => assistant.id === editingId)?.apiKey) ||
|
||||
"";
|
||||
|
||||
const filteredAssistants = listItems.filter((assistant) => {
|
||||
if (typeFilter !== "全部" && assistant.type !== typeFilter) {
|
||||
return false;
|
||||
@@ -715,6 +746,34 @@ export function AssistantPage() {
|
||||
[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">
|
||||
@@ -963,17 +1022,13 @@ export function AssistantPage() {
|
||||
}
|
||||
|
||||
if (view === "choose") {
|
||||
const selectedOption = assistantTypeOptions.find(
|
||||
(option) => option.type === draftType,
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
@@ -981,7 +1036,7 @@ export function AssistantPage() {
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setView("list")}
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
返回列表
|
||||
@@ -1057,24 +1112,30 @@ export function AssistantPage() {
|
||||
</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"
|
||||
onClick={() => setView("list")}
|
||||
disabled={creating}
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
disabled={!draftName.trim() || !draftType}
|
||||
onClick={confirmType}
|
||||
disabled={!draftName.trim() || !draftType || creating}
|
||||
onClick={() => void confirmType()}
|
||||
>
|
||||
<Rocket size={16} />
|
||||
{selectedOption && !selectedOption.available
|
||||
? "下一步"
|
||||
: "开始构建"}
|
||||
{creating ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Rocket size={16} />
|
||||
)}
|
||||
创建助手
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1098,7 +1159,7 @@ export function AssistantPage() {
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setView("list")}
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
返回列表
|
||||
@@ -1143,7 +1204,7 @@ export function AssistantPage() {
|
||||
<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={() => setView("list")} />
|
||||
<EditorBackButton onClick={() => router.push("/assistants")} />
|
||||
<EditableTitle
|
||||
value={difyForm.name}
|
||||
onChange={(value) => updateDifyForm("name", value)}
|
||||
@@ -1159,7 +1220,7 @@ export function AssistantPage() {
|
||||
)}
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={saving || !difyForm.name.trim()}
|
||||
disabled={saving || !dirty || !difyForm.name.trim()}
|
||||
onClick={() => void handleSaveDify()}
|
||||
>
|
||||
{saving ? (
|
||||
@@ -1242,7 +1303,7 @@ export function AssistantPage() {
|
||||
<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={() => setView("list")} />
|
||||
<EditorBackButton onClick={() => router.push("/assistants")} />
|
||||
<EditableTitle
|
||||
value={fastGptForm.name}
|
||||
onChange={(value) => updateFastGptForm("name", value)}
|
||||
@@ -1258,7 +1319,7 @@ export function AssistantPage() {
|
||||
)}
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={saving || !fastGptForm.name.trim()}
|
||||
disabled={saving || !dirty || !fastGptForm.name.trim()}
|
||||
onClick={() => void handleSaveFastGpt()}
|
||||
>
|
||||
{saving ? (
|
||||
@@ -1347,7 +1408,7 @@ export function AssistantPage() {
|
||||
<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={() => setView("list")} />
|
||||
<EditorBackButton onClick={() => router.push("/assistants")} />
|
||||
<EditableTitle
|
||||
value={openCodeForm.name}
|
||||
onChange={(value) => updateOpenCodeForm("name", value)}
|
||||
@@ -1363,7 +1424,7 @@ export function AssistantPage() {
|
||||
)}
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={saving || !openCodeForm.name.trim()}
|
||||
disabled={saving || !dirty || !openCodeForm.name.trim()}
|
||||
onClick={() => void handleSaveOpenCode()}
|
||||
>
|
||||
{saving ? (
|
||||
@@ -1465,7 +1526,7 @@ export function AssistantPage() {
|
||||
<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={() => setView("list")} />
|
||||
<EditorBackButton onClick={() => router.push("/assistants")} />
|
||||
<EditableTitle
|
||||
value={form.name}
|
||||
onChange={(value) => updateForm("name", value)}
|
||||
@@ -1481,7 +1542,7 @@ export function AssistantPage() {
|
||||
)}
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={saving || !form.name.trim()}
|
||||
disabled={saving || !dirty || !form.name.trim()}
|
||||
onClick={() => void handleSavePrompt()}
|
||||
>
|
||||
{saving ? (
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import { Boxes, Plus } from "lucide-react";
|
||||
import type { NavKey } from "@/components/layout/AppShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type HomePageProps = {
|
||||
onNavigate: (key: NavKey) => void;
|
||||
};
|
||||
|
||||
export function HomePage({ onNavigate }: HomePageProps) {
|
||||
export function HomePage() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1180px] flex-col gap-12 pt-[3vh]">
|
||||
{/* Hero band — atmospheric gradient orbs behind editorial display copy */}
|
||||
@@ -39,23 +35,23 @@ export function HomePage({ onNavigate }: HomePageProps) {
|
||||
</p>
|
||||
|
||||
<div className="mt-9 flex gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
className="gap-2 px-5"
|
||||
onClick={() => onNavigate("assistants")}
|
||||
>
|
||||
<Plus size={17} />
|
||||
创建助手
|
||||
<Button size="lg" className="gap-2 px-5" asChild>
|
||||
<Link href="/assistants">
|
||||
<Plus size={17} />
|
||||
创建助手
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="gap-2 border-hairline-strong text-foreground hover:bg-surface-strong"
|
||||
onClick={() => onNavigate("components-models")}
|
||||
asChild
|
||||
>
|
||||
<Boxes size={17} />
|
||||
配置模型
|
||||
<Link href="/components/models">
|
||||
<Boxes size={17} />
|
||||
配置模型
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user