From 7f0199da59e99df449e420e0337dd0f1e2a98da9 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Tue, 16 Jun 2026 12:56:49 +0800 Subject: [PATCH] feat: transition to a fully frontend architecture by removing backend dependencies and updating configuration for local deployment --- CHANGELOG.md | 1 + docs/content/docs/overview/features.mdx | 6 +- docs/content/docs/overview/quick-start.mdx | 2 +- docs/content/docs/progress/pending-test.mdx | 2 +- web/src/app/(admin)/admin/assets/page.tsx | 271 ---------- .../(admin)/admin/assets/use-admin-assets.ts | 88 --- .../app/(admin)/admin/credit-logs/page.tsx | 5 - web/src/app/(admin)/admin/layout.tsx | 74 --- web/src/app/(admin)/admin/page.tsx | 5 - web/src/app/(admin)/admin/prompts/page.tsx | 347 ------------ .../admin/prompts/use-admin-prompts.ts | 128 ----- web/src/app/(admin)/admin/settings/page.tsx | 48 -- web/src/app/(admin)/admin/users/page.tsx | 5 - .../(user)/canvas/[id]/canvas-client-page.tsx | 20 +- .../components/canvas-agent-chat-ui.tsx | 6 +- .../components/canvas-config-node-panel.tsx | 3 +- .../components/canvas-node-prompt-panel.tsx | 3 +- web/src/app/(user)/image/page.tsx | 4 +- web/src/app/(user)/login/page.tsx | 36 -- web/src/app/(user)/video/page.tsx | 4 +- web/src/app/not-found.tsx | 9 +- .../components/layout/app-config-modal.tsx | 499 +++++++++++------- .../components/layout/client-root-init.tsx | 26 +- .../components/layout/user-status-actions.tsx | 44 +- web/src/components/model-picker.tsx | 18 +- web/src/components/video-settings-panel.tsx | 4 +- web/src/constant/credits.tsx | 2 +- web/src/lib/app-theme.ts | 9 - web/src/lib/seedance-video.ts | 7 +- web/src/services/api/admin.ts | 294 ----------- web/src/services/api/audio.ts | 11 +- web/src/services/api/auth.ts | 37 -- web/src/services/api/image.ts | 33 +- web/src/services/api/video.ts | 24 +- web/src/stores/use-config-store.ts | 210 ++++++-- web/src/stores/use-user-store.ts | 59 +-- 36 files changed, 571 insertions(+), 1773 deletions(-) delete mode 100644 web/src/app/(admin)/admin/assets/page.tsx delete mode 100644 web/src/app/(admin)/admin/assets/use-admin-assets.ts delete mode 100644 web/src/app/(admin)/admin/credit-logs/page.tsx delete mode 100644 web/src/app/(admin)/admin/layout.tsx delete mode 100644 web/src/app/(admin)/admin/page.tsx delete mode 100644 web/src/app/(admin)/admin/prompts/page.tsx delete mode 100644 web/src/app/(admin)/admin/prompts/use-admin-prompts.ts delete mode 100644 web/src/app/(admin)/admin/settings/page.tsx delete mode 100644 web/src/app/(admin)/admin/users/page.tsx delete mode 100644 web/src/app/(user)/login/page.tsx delete mode 100644 web/src/services/api/admin.ts delete mode 100644 web/src/services/api/auth.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a13a281..b25a289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased + [新增] 新增网页版Agent Loop模式。 ++ [调整] 移除后端,项目定位为个人画布工具。 ## v0.3.0 - 2026-06-15 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index f7fda01..1a2cdf8 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -144,10 +144,10 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不 “素材库”入口目前保留为空的公共素材列表,主要数据仍建议放在“我的素材”中。 -## 账号和后台 +## 配置和同步 -- 当前版本不需要账号登录,登录页仅提示本地模式和配置入口。 -- `/admin/settings` 只保留前台配置、提示词缓存和本地数据说明。 +- 当前版本不需要账号登录,也不再提供后台管理页面。 +- 配置与用户偏好弹窗支持多个 OpenAI 兼容渠道、默认模型、生成偏好和 WebDAV 同步设置。 - 第三方提示词和 WebDAV 可使用少量 Next.js route,AI 接口不经过项目后端代理。 ## 当前限制 diff --git a/docs/content/docs/overview/quick-start.mdx b/docs/content/docs/overview/quick-start.mdx index 9359331..93e976d 100644 --- a/docs/content/docs/overview/quick-start.mdx +++ b/docs/content/docs/overview/quick-start.mdx @@ -39,7 +39,7 @@ docker run --rm -p 3000:3000 infinite-canvas ## 首次使用建议 - 先打开右上角配置弹窗,填入自己的 `Base URL`、`API Key` 和模型名。 -- 如果需要提示词仓库内容,打开 `/prompts` 或 `/admin/prompts` 会通过 Next.js route 拉取并缓存在内存中。 +- 如果需要提示词仓库内容,打开 `/prompts` 会通过 Next.js route 拉取并缓存在内存中。 - 如果需要跨设备同步画布、素材和生成记录,可在配置弹窗中填写 WebDAV。 ## 说明 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 99ab037..81569ad 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,7 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 -- 前端已改为基本纯前端部署形态:删除 Next `/api/*` 到旧服务端的 catch-all 代理,并移除仓库内旧服务端源码和模块文件;Docker 运行镜像只启动 Next.js,不再构建或启动旧服务端;配置弹窗只保留前台直连 Base URL/API Key/模型列表和 WebDAV,登录页改为本地模式说明,顶部不再显示登录入口、算力点和后台账号菜单;AI 生图、编辑、文本问答、音频、视频接口都由浏览器直接请求用户配置的 OpenAI 兼容地址,第三方提示词改为 Next.js route 拉取 GitHub raw 并缓存在实例内存中,公共素材库暂为空列表;需要验证 Vercel 以 `web/` 为根部署后首页、配置导入参数、提示词库、画布 Agent、图片/音频/视频生成、WebDAV 直连和 Next.js 转发都不再依赖旧服务端。 +- 前端已改为基本纯前端部署形态:删除 Next `/api/*` 到旧服务端的 catch-all 代理,并移除仓库内旧服务端源码、模块文件、后台管理页面、登录页和账号菜单;Docker 运行镜像只启动 Next.js,不再构建或启动旧服务端;配置与用户偏好弹窗改为“渠道 / 模型 / 生成偏好 / WebDAV”tab,支持用户本地配置多个 OpenAI 兼容渠道,每个渠道可单独填写 Base URL/API Key、手动维护或拉取模型列表,模型下拉显示为“模型名(渠道名)”;AI 生图、编辑、文本问答、音频、视频接口都由浏览器按选中的模型渠道直接请求用户配置的 OpenAI 兼容地址,第三方提示词改为 Next.js route 拉取 GitHub raw 并缓存在实例内存中,公共素材库暂为空列表;需要验证 Vercel 以 `web/` 为根部署后首页、配置导入参数、多渠道模型选择、提示词库、画布 Agent、图片/音频/视频生成、WebDAV 直连和 Next.js 转发都不再依赖旧服务端。 - Agent 对话里的用户消息正文改为左对齐显示,长段中文提示词、编号列表和多段文本仍保持消息整体靠右但内容按正常阅读方向换行;需要验证本机 Agent 和网站 Agent 对话中长提示词不再出现排版散乱。 - 画布右上角只保留一个 `Agent` 入口,公共顶部固定为 Agent 标题、“网站 / 本机”切换、工具确认和统一收起按钮,切换时只替换面板内部 tab 区且不改变面板宽度:网站 Agent 使用当前文本模型生成画布 ops,不依赖本地 Codex/Claude Code 或 Canvas Agent;本机 Agent 继续连接本地 Codex。两侧对话复用同一套消息、`working...`、工具卡、二级 tab 和底部输入框 UI;网站 Agent 去掉对话/生图/操作模式切换,内部 tab 改为“连接配置 / 对话 / 历史”,对话里去掉重试和插入画布按钮,历史列表改为与本机模式一致的卡片风格,请求时发送压缩后的当前画布节点、连线、选区和视口,模型返回 JSON 后复用现有画布操作协议新增、更新、删除、连接节点、调整视口、选择节点或触发生成,生图需求应通过创建提示词节点、生成配置节点并触发画布生成工具完成;需要验证单入口打开/关闭、网站/本机切换、模型配置缺失提示、网站 Agent 流式回复、工具卡展示、在线操作结果落到画布、图片生成能走画布节点工具、历史记录恢复和错误提示。 - Agent 执行画布操作时会忽略空 ops、无 type 操作和缺少必要字段的选择/连线/视口/生成操作,避免在线模型返回不完整 JSON 时触发 `filter` 读取错误;需要验证网站 Agent 重新整理画布布局时不会再因异常 ops 崩溃。 diff --git a/web/src/app/(admin)/admin/assets/page.tsx b/web/src/app/(admin)/admin/assets/page.tsx deleted file mode 100644 index ae9a7ba..0000000 --- a/web/src/app/(admin)/admin/assets/page.tsx +++ /dev/null @@ -1,271 +0,0 @@ -"use client"; - -import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; -import { ProTable, type ProColumns } from "@ant-design/pro-components"; -import { Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd"; -import { useEffect, useState } from "react"; - -import { useCopyText } from "@/hooks/use-copy-text"; -import type { AdminAsset } from "@/services/api/admin"; -import { useAdminAssets } from "./use-admin-assets"; - -type AssetFormValues = Partial & { tagText?: string }; - -const typeOptions = [ - { label: "全部类型", value: "" }, - { label: "文本", value: "text" }, - { label: "图片", value: "image" }, -]; - -const editTypeOptions = typeOptions.slice(1); - -export default function AdminAssetsPage() { - const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets(); - const copyText = useCopyText(); - const [form] = Form.useForm(); - const [keywordText, setKeywordText] = useState(keyword); - const [editingAsset, setEditingAsset] = useState | null>(null); - const [detailAsset, setDetailAsset] = useState(null); - const [deletingAsset, setDeletingAsset] = useState(null); - const formType = Form.useWatch("type", form) || editingAsset?.type || "text"; - const tagOptions = tags.map((item) => ({ label: item, value: item })); - - useEffect(() => { - if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" }); - }, [editingAsset, form]); - - useEffect(() => setKeywordText(keyword), [keyword]); - - const saveAsset = async () => { - const value = await form.validateFields(); - const nextType = value.type || "text"; - await saveAdminAsset({ - ...editingAsset, - ...value, - type: nextType, - coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""), - tags: (value.tagText || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean), - }); - setEditingAsset(null); - }; - - const columns: ProColumns[] = [ - { - title: "封面", - dataIndex: "coverUrl", - width: 88, - render: (_, item) => {item.title}, - }, - { - title: "标题", - dataIndex: "title", - width: 260, - render: (_, item) => ( - setDetailAsset(item)}> - {item.title} - - ), - }, - { - title: "类型", - dataIndex: "type", - width: 84, - render: (_, item) => {item.type === "image" ? "图片" : "文本"}, - }, - { - title: "标签", - dataIndex: "tags", - width: 180, - render: (_, item) => ( - - {(item.tags || []).slice(0, 3).map((tag) => ( - {tag} - ))} - - ), - }, - { - title: "分类", - dataIndex: "category", - width: 120, - render: (_, item) => {item.category || "未标注"}, - }, - { - title: "操作", - key: "actions", - width: 112, - align: "right", - render: (_, item) => ( - - - - - - - - - - - - rowKey="id" - columns={columns} - dataSource={assets} - loading={isLoading} - search={false} - defaultSize="middle" - tableLayout="fixed" - cardProps={{ variant: "borderless" }} - headerTitle={ - - 素材列表 - {total} 条 - - } - options={{ density: true, setting: true, reload: () => void refreshAssets() }} - toolBarRender={() => [ - , - ]} - pagination={{ - current: page, - pageSize, - total, - showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], - showTotal: (value) => `共 ${value} 条`, - onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)), - }} - /> - - - setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden> -
- - - - - - - - - - - - - - - - {formType === "image" ? ( - - - - ) : ( - - - - )} -
-
- - setDetailAsset(null)} footer={}> - {detailAsset ? ( - - - {detailAsset.title} - - - {detailAsset.title} - - - {detailAsset.type === "image" ? "图片" : "文本"} - {detailAsset.category ? {detailAsset.category} : null} - {(detailAsset.tags || []).map((tag) => ( - {tag} - ))} - - - - {detailAsset.description ? ( - - {detailAsset.description} - - ) : null} - - - - ) : null} - - - setDeletingAsset(null)} - onOk={async () => { - if (!deletingAsset) return; - await deleteAsset(deletingAsset.id); - setDeletingAsset(null); - }} - okText="删除" - okButtonProps={{ danger: true }} - cancelText="取消" - > - 确定删除「{deletingAsset?.title}」吗?删除后会从服务器素材库中移除。 - - - ); -} diff --git a/web/src/app/(admin)/admin/assets/use-admin-assets.ts b/web/src/app/(admin)/admin/assets/use-admin-assets.ts deleted file mode 100644 index c72061b..0000000 --- a/web/src/app/(admin)/admin/assets/use-admin-assets.ts +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { App } from "antd"; - -import { deleteAdminAsset, fetchAdminAssets, saveAdminAsset, type AdminAsset } from "@/services/api/admin"; - -const defaultPageSize = 10; -const localToken = ""; - -export function useAdminAssets() { - const { message } = App.useApp(); - const queryClient = useQueryClient(); - const [keyword, setKeyword] = useState(""); - const [type, setType] = useState(""); - const [tag, setTag] = useState([]); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(defaultPageSize); - - const query = useQuery({ - queryKey: ["admin", "assets", keyword, type, tag, page, pageSize], - queryFn: () => fetchAdminAssets(localToken, { keyword, type, tag, page, pageSize }), - retry: false, - }); - - const saveMutation = useMutation({ - mutationFn: (asset: Partial) => saveAdminAsset(localToken, asset), - onSuccess: async (_, asset) => { - await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] }); - message.success(asset.id ? "素材已保存" : "素材已新增"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "保存失败"); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteAdminAsset(localToken, id), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] }); - message.success("素材已删除"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "删除失败"); - }, - }); - - useEffect(() => { - if (query.isError) { - const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败"; - message.error(errorMessage); - } - }, [message, query.error, query.isError]); - - const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => { - const queryState = { keyword, type, tag, page, pageSize, ...next }; - if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1; - setKeyword(queryState.keyword); - setType(queryState.type); - setTag(queryState.tag); - setPage(queryState.page); - setPageSize(queryState.pageSize); - }; - - const data = query.data; - - return { - assets: data?.items || [], - tags: data?.tags || [], - keyword, - kind: type, - tag, - page, - pageSize, - total: data?.total || 0, - isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending, - searchAssets: (value = keyword) => updateFilters({ keyword: value }), - changeKind: (value: string) => updateFilters({ type: value, tag: [] }), - changeTag: (value: string[]) => updateFilters({ tag: value }), - changePage: (value: number) => updateFilters({ page: value }), - changePageSize: (value: number) => updateFilters({ pageSize: value }), - resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }), - refreshAssets: () => query.refetch(), - saveAsset: (asset: Partial) => saveMutation.mutateAsync(asset), - deleteAsset: (id: string) => deleteMutation.mutateAsync(id), - }; -} diff --git a/web/src/app/(admin)/admin/credit-logs/page.tsx b/web/src/app/(admin)/admin/credit-logs/page.tsx deleted file mode 100644 index a2d73d3..0000000 --- a/web/src/app/(admin)/admin/credit-logs/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function AdminCreditLogsPage() { - redirect("/admin/settings"); -} diff --git a/web/src/app/(admin)/admin/layout.tsx b/web/src/app/(admin)/admin/layout.tsx deleted file mode 100644 index cdbfddf..0000000 --- a/web/src/app/(admin)/admin/layout.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import { FileTextOutlined, HomeOutlined, PictureOutlined, SettingOutlined } from "@ant-design/icons"; -import { Button, Flex, Layout, Menu, Typography, theme } from "antd"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import type { ReactNode } from "react"; - -import { UserStatusActions } from "@/components/layout/user-status-actions"; -import { adminLayoutStyle } from "@/lib/app-theme"; - -const adminMenus = [ - { key: "/admin/prompts", icon: , label: "提示词管理" }, - { key: "/admin/assets", icon: , label: "素材库" }, - { key: "/admin/settings", icon: , label: "系统设置" }, -]; - -export default function AdminLayout({ children }: { children: ReactNode }) { - const { token: antToken } = theme.useToken(); - const pathname = usePathname(); - const activeKey = pathname.startsWith("/admin/settings") - ? "/admin/settings" - : pathname.startsWith("/admin/assets") - ? "/admin/assets" - : pathname.startsWith("/admin/prompts") - ? "/admin/prompts" - : ""; - const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理"; - - return ( - - - - - - 无限画布 - - - ({ - ...item, - label: ( - - {item.label} - - ), - style: adminLayoutStyle.menuItem, - }))} - /> - - - - - - - - {pageTitle} - - - - - - {children} - - - ); -} diff --git a/web/src/app/(admin)/admin/page.tsx b/web/src/app/(admin)/admin/page.tsx deleted file mode 100644 index cf5e9e8..0000000 --- a/web/src/app/(admin)/admin/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function AdminPage() { - redirect("/admin/settings"); -} diff --git a/web/src/app/(admin)/admin/prompts/page.tsx b/web/src/app/(admin)/admin/prompts/page.tsx deleted file mode 100644 index 6d9baa7..0000000 --- a/web/src/app/(admin)/admin/prompts/page.tsx +++ /dev/null @@ -1,347 +0,0 @@ -"use client"; - -import { CopyOutlined, DeleteOutlined, EditOutlined, ExportOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from "@ant-design/icons"; -import { ProTable, type ProColumns } from "@ant-design/pro-components"; -import { Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd"; -import { useEffect, useState } from "react"; - -import { useCopyText } from "@/hooks/use-copy-text"; -import type { Prompt } from "@/services/api/prompts"; -import { useAdminPrompts } from "./use-admin-prompts"; - -export default function AdminPromptsPage() { - const { - categories, - prompts, - tags, - keyword, - category, - tag, - page, - pageSize, - total, - isLoading, - isSyncing, - searchPrompts, - changeCategory, - changeTag, - changePage, - changePageSize, - resetFilters, - refreshPrompts, - syncCategory, - savePrompt: saveAdminPrompt, - deletePrompt, - deletePrompts, - } = useAdminPrompts(); - const copyText = useCopyText(); - const [form] = Form.useForm & { tagText?: string }>(); - const [keywordText, setKeywordText] = useState(keyword); - const [editingPrompt, setEditingPrompt] = useState | null>(null); - const [detailPrompt, setDetailPrompt] = useState(null); - const [deletingPrompt, setDeletingPrompt] = useState(null); - const [selectedPromptIds, setSelectedPromptIds] = useState([]); - const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false); - const [isSyncOpen, setIsSyncOpen] = useState(false); - const defaultCategory = categories[0]?.category || ""; - const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category; - const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))]; - const tagOptions = tags.map((item) => ({ label: item, value: item })); - - useEffect(() => { - if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" }); - }, [editingPrompt, form]); - - useEffect(() => setKeywordText(keyword), [keyword]); - - const savePrompt = async () => { - const value = await form.validateFields(); - await saveAdminPrompt({ - ...editingPrompt, - ...value, - category: value.category || defaultCategory, - tags: (value.tagText || "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean), - }); - setEditingPrompt(null); - }; - - const batchDeletePrompts = async () => { - await deletePrompts(selectedPromptIds); - setSelectedPromptIds([]); - setIsBatchDeleteOpen(false); - }; - - const columns: ProColumns[] = [ - { - title: "封面", - dataIndex: "coverUrl", - width: 88, - render: (_, item) => {item.title}, - }, - { - title: "标题", - dataIndex: "title", - width: 260, - render: (_, item) => ( - setDetailPrompt(item)}> - {item.title} - - ), - }, - { - title: "分类", - dataIndex: "category", - width: 150, - render: (_, item) => {categoryName(item.category)}, - }, - { - title: "标签", - dataIndex: "tags", - width: 180, - render: (_, item) => ( - - {(item.tags || []).slice(0, 3).map((tag) => ( - {tag} - ))} - - ), - }, - { - title: "操作", - key: "actions", - width: 112, - align: "right", - render: (_, item) => ( - - - - - - - - - - - - rowKey="id" - columns={columns} - dataSource={prompts} - loading={isLoading} - search={false} - defaultSize="middle" - tableLayout="fixed" - cardProps={{ variant: "borderless" }} - headerTitle={ - - 提示词列表 - {total} 条 - - } - options={{ density: true, setting: true, reload: () => void refreshPrompts() }} - rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }} - toolBarRender={() => [ - , - , - , - ]} - pagination={{ - current: page, - pageSize, - total, - showSizeChanger: true, - pageSizeOptions: [10, 20, 50, 100], - showTotal: (value) => `共 ${value} 条`, - onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)), - }} - /> - - - setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden> -
- - - - - - - - - - - - -
-
- - setDetailPrompt(null)} footer={}> - {detailPrompt ? ( - - - {detailPrompt.title} - - - {detailPrompt.title} - - - {categoryName(detailPrompt.category)} - {(detailPrompt.tags || []).map((tag) => ( - {tag} - ))} - - - - {detailPrompt.preview ? ( - - {detailPrompt.preview} - - ) : null} - - - - {detailPrompt.githubUrl ? ( - - ) : null} - - - ) : null} - - - !isSyncing && setIsSyncOpen(false)} - mask={{ closable: !isSyncing }} - footer={ - - } - > - item.remote)} - pagination={false} - columns={[ - { - title: "远程源", - dataIndex: "name", - render: (_, item) => ( - - {item.name} - {item.githubUrl ? ( - - - - ) : null} - - ), - }, - { - title: "", - key: "sync", - width: 96, - align: "right", - render: (_, item) => ( - - ), - }, - ]} - /> - - - setDeletingPrompt(null)} - onOk={async () => { - if (!deletingPrompt) return; - await deletePrompt(deletingPrompt.id); - setDeletingPrompt(null); - }} - okText="删除" - okButtonProps={{ danger: true }} - cancelText="取消" - > - 确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。 - - - setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> - 确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。 - - - ); -} diff --git a/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts b/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts deleted file mode 100644 index b8b4840..0000000 --- a/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { App } from "antd"; - -import { deleteAdminPrompt, deleteAdminPrompts, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin"; -import type { Prompt } from "@/services/api/prompts"; - -const defaultPageSize = 10; -const localToken = ""; - -export function useAdminPrompts() { - const { message } = App.useApp(); - const queryClient = useQueryClient(); - const [keyword, setKeyword] = useState(""); - const [category, setCategory] = useState(""); - const [tag, setTag] = useState([]); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(defaultPageSize); - - const categoriesQuery = useQuery({ - queryKey: ["admin", "prompt-categories"], - queryFn: () => fetchAdminPromptCategories(localToken), - retry: false, - }); - - const promptsQuery = useQuery({ - queryKey: ["admin", "prompts", keyword, category, tag, page, pageSize], - queryFn: () => fetchAdminPrompts(localToken, { keyword, category, tag, page, pageSize }), - retry: false, - }); - - const syncMutation = useMutation({ - mutationFn: (category: string) => syncAdminPromptCategory(localToken, category), - onSuccess: async (categories) => { - queryClient.setQueryData(["admin", "prompt-categories"], categories); - await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); - message.success("远程提示词源已同步"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "同步失败"); - }, - }); - - const saveMutation = useMutation({ - mutationFn: (prompt: Partial) => saveAdminPrompt(localToken, prompt), - onSuccess: async (_, prompt) => { - await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); - await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); - message.success(prompt.id ? "提示词已保存" : "提示词已新增"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "保存失败"); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteAdminPrompt(localToken, id), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); - await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); - message.success("提示词已删除"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "删除失败"); - }, - }); - - const batchDeleteMutation = useMutation({ - mutationFn: (ids: string[]) => deleteAdminPrompts(localToken, ids), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] }); - await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] }); - message.success("提示词已批量删除"); - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : "批量删除失败"); - }, - }); - - useEffect(() => { - const error = categoriesQuery.error || promptsQuery.error; - if (!error) return; - const errorMessage = error instanceof Error ? error.message : "读取提示词失败"; - message.error(errorMessage); - }, [categoriesQuery.error, message, promptsQuery.error]); - - const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => { - const queryState = { keyword, category, tag, page, pageSize, ...next }; - if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1; - setKeyword(queryState.keyword); - setCategory(queryState.category); - setTag(queryState.tag); - setPage(queryState.page); - setPageSize(queryState.pageSize); - }; - - const data = promptsQuery.data; - - return { - categories: categoriesQuery.data || [], - prompts: data?.items || [], - tags: data?.tags || [], - keyword, - category, - tag, - page, - pageSize, - total: data?.total || 0, - isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending, - isSyncing: syncMutation.isPending, - syncCategory: (category: string) => syncMutation.mutateAsync(category), - searchPrompts: (value = keyword) => updateFilters({ keyword: value }), - changeCategory: (value: string) => updateFilters({ category: value, tag: [] }), - changeTag: (value: string[]) => updateFilters({ tag: value }), - changePage: (value: number) => updateFilters({ page: value }), - changePageSize: (value: number) => updateFilters({ pageSize: value }), - resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }), - refreshPrompts: async () => { - await categoriesQuery.refetch(); - await promptsQuery.refetch(); - }, - savePrompt: (prompt: Partial) => saveMutation.mutateAsync(prompt), - deletePrompt: (id: string) => deleteMutation.mutateAsync(id), - deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids), - }; -} diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx deleted file mode 100644 index e18d4c2..0000000 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { Button, Card, Typography } from "antd"; -import { Database, Settings2, Sparkles } from "lucide-react"; -import Link from "next/link"; - -import { useConfigStore } from "@/stores/use-config-store"; - -export default function AdminSettingsPage() { - const openConfigDialog = useConfigStore((state) => state.openConfigDialog); - - return ( -
-
- - - - 前台直连配置 - - 模型 Base URL、API Key、默认模型和 WebDAV 同步都保存在浏览器本地,不再通过后台渠道转发。 - - - - - - 第三方提示词 - - 提示词由 Next.js route 从开源仓库拉取,并缓存在当前运行实例的内存里;页面和管理列表都读取同一份缓存。 - - - - - - - - 本地数据 - - 画布项目、我的素材、生成记录和 AI Key 默认留在浏览器本地;需要跨设备时请在配置弹窗里使用 WebDAV 同步。 - - - - -
-
- ); -} diff --git a/web/src/app/(admin)/admin/users/page.tsx b/web/src/app/(admin)/admin/users/page.tsx deleted file mode 100644 index 21bfc69..0000000 --- a/web/src/app/(admin)/admin/users/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function AdminUsersPage() { - redirect("/admin/settings"); -} diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 7226307..4891816 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -2721,9 +2721,7 @@ function CanvasTopBar({ const colorTheme = useThemeStore((state) => state.theme); const theme = canvasThemes[colorTheme]; const titleRef = useRef(null); - const accountRef = useRef(null); const [shortcutsOpen, setShortcutsOpen] = useState(false); - const [accountOpen, setAccountOpen] = useState(false); useEffect(() => { if (!isTitleEditing) return; @@ -2734,15 +2732,6 @@ function CanvasTopBar({ return () => document.removeEventListener("pointerdown", close, true); }, [isTitleEditing, onFinishTitleEditing]); - useEffect(() => { - if (!accountOpen) return; - const close = (event: PointerEvent) => { - if (!accountRef.current?.contains(event.target as Node)) setAccountOpen(false); - }; - document.addEventListener("pointerdown", close, true); - return () => document.removeEventListener("pointerdown", close, true); - }, [accountOpen]); - return ( <>
@@ -2800,14 +2789,7 @@ function CanvasTopBar({
node.parentElement || document.body} - onOpenShortcuts={() => { - setShortcutsOpen(true); - setAccountOpen(false); - }} + onOpenShortcuts={() => setShortcutsOpen(true)} /> - - - -
- - - ); -} diff --git a/web/src/app/(user)/video/page.tsx b/web/src/app/(user)/video/page.tsx index cafbfb6..1a07bb0 100644 --- a/web/src/app/(user)/video/page.tsx +++ b/web/src/app/(user)/video/page.tsx @@ -18,7 +18,7 @@ import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/ import { resolveImageUrl, uploadImage } from "@/services/image-storage"; import { createVideoGenerationTask, pollVideoGenerationTask, storeGeneratedVideo, type VideoGenerationTask } from "@/services/api/video"; import { useAssetStore } from "@/stores/use-asset-store"; -import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; +import { modelOptionLabel, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; @@ -460,7 +460,7 @@ export default function VideoPage() {
- {model} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s + {modelOptionLabel(effectiveConfig, model)} · {normalizeResolution(effectiveConfig.vquality)}p · {videoSizeLabel(effectiveConfig.size)} · {normalizeVideoSeconds(effectiveConfig.videoSeconds)}s
diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index bacee23..4af65e0 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -1,15 +1,15 @@ "use client"; -import { App, Button, Form, Input, Modal, Progress, Segmented, Select } from "antd"; -import { Cloud, RefreshCw, Wifi } from "lucide-react"; +import { App, Button, Form, Input, Modal, Progress, Segmented, Select, Tabs } from "antd"; +import { Cloud, Plus, RefreshCw, Trash2, Wifi } from "lucide-react"; import { useState } from "react"; import { ModelPicker } from "@/components/model-picker"; -import { fetchImageModels } from "@/services/api/image"; +import { fetchChannelModels } from "@/services/api/image"; import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync"; import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync"; import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation"; -import { filterModelsByCapability, useConfigStore, type ModelCapability } from "@/stores/use-config-store"; +import { createModelChannel, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ModelCapability, type ModelChannel } from "@/stores/use-config-store"; type ModelGroup = { capability: ModelCapability; @@ -54,7 +54,8 @@ function createWebdavDomainProgress(): Record state.shouldPromptContinue); const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen); const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue); - const modelOptions = config.models.map((model) => ({ label: model, value: model })); + const modelOptions = config.models.map((model) => ({ label: modelOptionLabel(config, model), value: model })); const webdavReady = Boolean(webdav.url.trim()); + const saveConfig = (nextConfig: AiConfig) => { + (Object.keys(nextConfig) as Array).forEach((key) => updateConfig(key, nextConfig[key])); + }; + const finishConfig = () => { + const ready = config.channels.some((channel) => channel.baseUrl.trim() && channel.apiKey.trim() && channel.models.length); setConfigDialogOpen(false); - if (!config.baseUrl.trim() || !config.apiKey.trim()) return; - if (!config.imageModel.trim() || !config.videoModel.trim() || !config.textModel.trim()) return; - if (config.channelMode !== "local") updateConfig("channelMode", "local"); + if (!ready) return; message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存"); clearPromptContinue(); }; - const refreshModels = async () => { - if (!config.baseUrl.trim() || !config.apiKey.trim()) { - message.error("请先填写 Base URL 和 API Key"); + const updateChannels = (channels: ModelChannel[]) => { + const nextConfig = withChannels(config, channels); + saveConfig(nextConfig); + }; + + const updateChannel = (id: string, patch: Partial) => { + updateChannels(config.channels.map((channel) => (channel.id === id ? { ...channel, ...patch, models: patch.models ? uniqueModels(patch.models) : channel.models } : channel))); + }; + + const addChannel = () => { + updateChannels([...config.channels, createModelChannel({ name: `渠道 ${config.channels.length + 1}` })]); + }; + + const deleteChannel = (id: string) => { + if (config.channels.length <= 1) { + message.warning("至少保留一个渠道"); return; } - setLoadingModels(true); + updateChannels(config.channels.filter((channel) => channel.id !== id)); + }; + + const refreshChannelModels = async (channel: ModelChannel) => { + if (!channel.baseUrl.trim() || !channel.apiKey.trim()) { + message.error("请先填写该渠道的 Base URL 和 API Key"); + return; + } + setLoadingChannelId(channel.id); try { - const models = await fetchImageModels(config); - const imageModels = filterModelsByCapability(models, "image"); - const videoModels = filterModelsByCapability(models, "video"); - const textModels = filterModelsByCapability(models, "text"); - const audioModels = filterModelsByCapability(models, "audio"); - const nextImageModels = resolveNextCapabilityModels(config.imageModels, imageModels, models); - const nextVideoModels = resolveNextCapabilityModels(config.videoModels, videoModels, models); - const nextTextModels = resolveNextCapabilityModels(config.textModels, textModels, models); - const nextAudioModels = resolveNextCapabilityModels(config.audioModels, audioModels, models); - updateConfig("models", models); - updateConfig("imageModels", nextImageModels); - updateConfig("videoModels", nextVideoModels); - updateConfig("textModels", nextTextModels); - updateConfig("audioModels", nextAudioModels); - if (nextImageModels.length && !nextImageModels.includes(config.imageModel)) updateConfig("imageModel", nextImageModels[0]); - if (nextVideoModels.length && !nextVideoModels.includes(config.videoModel)) updateConfig("videoModel", nextVideoModels[0]); - if (nextTextModels.length && !nextTextModels.includes(config.textModel)) updateConfig("textModel", nextTextModels[0]); - if (nextAudioModels.length && !nextAudioModels.includes(config.audioModel)) updateConfig("audioModel", nextAudioModels[0]); + const models = await fetchChannelModels(channel); + updateChannels(config.channels.map((item) => (item.id === channel.id ? { ...item, models } : item))); + message.success(`${channel.name} 模型列表已更新`); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取模型失败"); + } finally { + setLoadingChannelId(""); + } + }; + + const refreshAllModels = async () => { + const runnable = config.channels.filter((channel) => channel.baseUrl.trim() && channel.apiKey.trim()); + if (!runnable.length) { + message.error("请先填写至少一个渠道的 Base URL 和 API Key"); + return; + } + setLoadingChannelId("all"); + try { + const entries = await Promise.all(runnable.map(async (channel) => [channel.id, await fetchChannelModels(channel)] as const)); + const modelMap = new Map(entries); + updateChannels(config.channels.map((channel) => (modelMap.has(channel.id) ? { ...channel, models: modelMap.get(channel.id) || [] } : channel))); message.success("模型列表已更新"); } catch (error) { message.error(error instanceof Error ? error.message : "读取模型失败"); } finally { - setLoadingModels(false); + setLoadingChannelId(""); } }; const updateCapabilityModels = (group: ModelGroup, models: string[]) => { - const next = uniqueModels(models); + const next = uniqueModels(models.map((model) => normalizeModelOptionValue(model, config.channels)).filter(Boolean)); updateConfig(group.modelsKey, next); if (!next.includes(config[group.modelKey])) updateConfig(group.modelKey, next[0] || ""); }; @@ -174,189 +202,255 @@ export function AppConfigModal() { title={
配置与用户偏好
-
模型、渠道和画布默认行为
+
渠道聚合、模型选择和同步偏好
} open={isConfigOpen} - width={960} + width={980} centered onCancel={() => setConfigDialogOpen(false)} - styles={{ body: { maxHeight: "72vh", overflowY: "auto", paddingRight: 18 } }} + styles={{ body: { maxHeight: "72vh", overflowY: "auto", paddingRight: 12 } }} footer={ } > -
-
-
-
前台直连
-
AI 请求由浏览器直接发送到你配置的 OpenAI 兼容地址,API Key 只保存在当前浏览器本地。
-
-
- - updateConfig("baseUrl", event.target.value)} /> - - - updateConfig("apiKey", event.target.value)} /> - -
-
-
-
模型列表
-
当前已保存 {config.models.length} 个模型
-
- -
-
-
-
模型可选项
-
可从已拉取模型中选择,也可直接输入模型名后回车。
-
-
- {modelGroups.map((group) => ( - - updateConfig("canvasImageCount", event.target.value)} - onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))} - /> - - - updateConfig("audioFormat", value)} /> - - - updateConfig("audioSpeed", event.target.value)} - onBlur={(event) => updateConfig("audioSpeed", normalizeAudioSpeedValue(event.target.value))} - /> - -
- - updateConfig("audioInstructions", event.target.value)} /> - - - updateConfig("systemPrompt", event.target.value)} /> - -
-
-
-
- - WebDAV 同步 + +
+
+
多渠道聚合
+
每个渠道保存自己的 Base URL、API Key 和模型列表;模型选择时会显示模型名和渠道名。
+
+
+ + +
-
同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;服务不支持 CORS 时可走 Next.js 转发。
-
-
{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}
-
-
- - updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)} - options={[ - { label: "前端直连", value: "direct" }, - { label: "Next.js 转发", value: "nextjs" }, - ]} - /> - - - updateWebdavConfig("url", event.target.value)} /> - - - updateWebdavConfig("directory", event.target.value)} /> - - - updateWebdavConfig("username", event.target.value)} /> - - - updateWebdavConfig("password", event.target.value)} /> - -
-
- - - {webdavSyncStatus ? {webdavSyncStatus} : null} -
- {syncingWebdav || webdavSyncStatus ? ( -
- {webdavDomainKeys.map((key) => { - const item = webdavDomainProgress[key]; - const count = item.total ? `${item.current || 0}/${item.total}` : ""; - return ( -
-
- {item.label} - - {item.stage} - {count ? ` · ${count}` : ""} - +
+ {config.channels.map((channel) => ( +
+
+
+
{channel.name || "未命名渠道"}
+
已保存 {channel.models.length} 个模型
+
+
+ +
- +
+ + updateChannel(channel.id, { name: event.target.value })} /> + + + updateChannel(channel.id, { baseUrl: event.target.value })} /> + + + updateChannel(channel.id, { apiKey: event.target.value })} /> + + + updateCapabilityModels(group, models)} + /> + + ))} +
+
+ {modelGroups.map((group) => ( + + updateConfig(group.modelKey, model)} capability={group.capability} fullWidth /> + + ))} +
+ + ), + }, + { + key: "preferences", + label: "生成偏好", + children: ( +
+
+ + updateConfig("canvasImageCount", event.target.value)} + onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))} + /> + + + updateConfig("audioFormat", value)} /> + + + updateConfig("audioSpeed", event.target.value)} + onBlur={(event) => updateConfig("audioSpeed", normalizeAudioSpeedValue(event.target.value))} + /> + +
+ + updateConfig("audioInstructions", event.target.value)} /> + + + updateConfig("systemPrompt", event.target.value)} /> + + + ), + }, + { + key: "webdav", + label: "WebDAV", + children: ( +
+
+
+
+
+ + WebDAV 同步 +
+
同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;服务不支持 CORS 时可走 Next.js 转发。
- ); - })} -
- ) : null} -
- -
+
{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}
+
+
+ + updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)} + options={[ + { label: "前端直连", value: "direct" }, + { label: "Next.js 转发", value: "nextjs" }, + ]} + /> + + + updateWebdavConfig("url", event.target.value)} /> + + + updateWebdavConfig("directory", event.target.value)} /> + + + updateWebdavConfig("username", event.target.value)} /> + + + updateWebdavConfig("password", event.target.value)} /> + +
+
+ + + {webdavSyncStatus ? {webdavSyncStatus} : null} +
+ {syncingWebdav || webdavSyncStatus ? : null} +
+ + ), + }, + ]} + /> ); } -function normalizeImageCount(value: string) { - return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3)))); +function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig { + const models = modelOptionsFromChannels(channels); + const imageModels = keepOrSuggest(config.imageModels, filterModelsByCapability(models, "image"), models); + const videoModels = keepOrSuggest(config.videoModels, filterModelsByCapability(models, "video"), models); + const textModels = keepOrSuggest(config.textModels, filterModelsByCapability(models, "text"), models); + const audioModels = keepOrSuggest(config.audioModels, filterModelsByCapability(models, "audio"), models); + return { + ...config, + channels, + models, + baseUrl: channels[0]?.baseUrl || config.baseUrl, + apiKey: channels[0]?.apiKey || config.apiKey, + imageModels, + videoModels, + textModels, + audioModels, + imageModel: normalizeDefaultModel(config.imageModel, imageModels), + videoModel: normalizeDefaultModel(config.videoModel, videoModels), + textModel: normalizeDefaultModel(config.textModel, textModels), + audioModel: normalizeDefaultModel(config.audioModel, audioModels), + }; } -function resolveNextCapabilityModels(current: string[], suggested: string[], allModels: string[]) { +function keepOrSuggest(current: string[], suggested: string[], allModels: string[]) { const available = new Set(allModels); const kept = uniqueModels(current).filter((model) => available.has(model)); return kept.length ? kept : suggested; } +function normalizeDefaultModel(value: string, options: string[]) { + if (options.includes(value)) return value; + return options[0] || value; +} + +function normalizeImageCount(value: string) { + return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3)))); +} + function uniqueModels(models: string[]) { return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean))); } @@ -365,6 +459,29 @@ function formatWebdavTime(value: string) { return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } +function WebdavProgressGrid({ progress }: { progress: Record }) { + return ( +
+ {webdavDomainKeys.map((key) => { + const item = progress[key]; + const count = item.total ? `${item.current || 0}/${item.total}` : ""; + return ( +
+
+ {item.label} + + {item.stage} + {count ? ` · ${count}` : ""} + +
+ +
+ ); + })} +
+ ); +} + function getWebdavProgressPercent(item: WebdavDomainProgress) { if (item.status === "success") return 100; if (item.total) return Math.min(100, Math.round(((item.current || 0) / item.total) * 100)); diff --git a/web/src/components/layout/client-root-init.tsx b/web/src/components/layout/client-root-init.tsx index 11da38a..1b00c9b 100644 --- a/web/src/components/layout/client-root-init.tsx +++ b/web/src/components/layout/client-root-init.tsx @@ -4,19 +4,15 @@ import type { ReactNode } from "react"; import { useEffect, useRef } from "react"; import { App } from "antd"; -import { useConfigStore } from "@/stores/use-config-store"; +import { createModelChannel, useConfigStore } from "@/stores/use-config-store"; export function ClientRootInit({ children }: { children: ReactNode }) { const { message } = App.useApp(); const handledConfigParams = useRef(false); - const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings); const updateConfig = useConfigStore((state) => state.updateConfig); + const config = useConfigStore((state) => state.config); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); - useEffect(() => { - void loadPublicSettings(); - }, [loadPublicSettings]); - useEffect(() => { if (handledConfigParams.current) return; const searchParams = new URLSearchParams(window.location.search); @@ -29,12 +25,26 @@ export function ClientRootInit({ children }: { children: ReactNode }) { searchParams.delete("apiKey"); searchParams.delete("apikey"); window.history.replaceState(null, "", `${window.location.pathname}${searchParams.size ? `?${searchParams}` : ""}${window.location.hash}`); - updateConfig("channelMode", "local"); + const firstChannel = config.channels[0]; + updateConfig( + "channels", + firstChannel + ? config.channels.map((channel, index) => + index === 0 + ? { + ...channel, + ...(baseUrl ? { baseUrl } : {}), + ...(apiKey ? { apiKey } : {}), + } + : channel, + ) + : [createModelChannel({ id: "default", name: "默认渠道", baseUrl: baseUrl || undefined, apiKey: apiKey || "" })], + ); if (baseUrl) updateConfig("baseUrl", baseUrl); if (apiKey) updateConfig("apiKey", apiKey); openConfigDialog(false); message.success("已导入本地直连配置"); - }, [message, openConfigDialog, updateConfig]); + }, [config.channels, message, openConfigDialog, updateConfig]); return <>{children}; } diff --git a/web/src/components/layout/user-status-actions.tsx b/web/src/components/layout/user-status-actions.tsx index 829615d..abd2e73 100644 --- a/web/src/components/layout/user-status-actions.tsx +++ b/web/src/components/layout/user-status-actions.tsx @@ -1,9 +1,7 @@ "use client"; -import type { CSSProperties, RefObject } from "react"; -import { Avatar, Dropdown } from "antd"; -import { BookOpen, Keyboard, LogOut, Settings2 } from "lucide-react"; -import type { ItemType } from "antd/es/menu/interface"; +import type { CSSProperties } from "react"; +import { BookOpen, Keyboard, Settings2 } from "lucide-react"; import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; import { GitHubLink } from "@/components/layout/github-link"; @@ -13,40 +11,23 @@ import { cn } from "@/lib/utils"; import { canvasThemes } from "@/lib/canvas-theme"; import { useConfigStore } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; -import { useUserStore } from "@/stores/use-user-store"; type UserStatusActionsProps = { showConfig?: boolean; variant?: "default" | "canvas"; onOpenShortcuts?: () => void; - accountOpen?: boolean; - onAccountOpenChange?: (open: boolean) => void; - accountRef?: RefObject; - getPopupContainer?: (node: HTMLElement) => HTMLElement; }; -export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, accountOpen, onAccountOpenChange, accountRef, getPopupContainer }: UserStatusActionsProps) { +export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts }: UserStatusActionsProps) { const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); - const user = useUserStore((state) => state.user); - const logout = useUserStore((state) => state.clearSession); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const canvasTheme = canvasThemes[theme]; - const userName = user?.displayName || user?.username || ""; - const avatarUrl = user?.avatarUrl?.trim(); - const avatarText = (userName.trim()[0] || "U").toUpperCase(); const naturalIconClass = "inline-flex size-7 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"; const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined; const versionStyle = iconStyle; const gitHubClassName = "size-7 text-base"; const gitHubStyle = iconStyle; - const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text, background: "transparent" } : undefined; - const menuItems: ItemType[] = [ - { key: "user", disabled: true, label: {userName} }, - ...(onOpenShortcuts ? [{ key: "shortcuts", icon: , label: "快捷键", onClick: onOpenShortcuts }] : []), - { type: "divider" }, - { key: "logout", icon: , label: "清除本地账户", onClick: logout }, - ]; return (
@@ -61,28 +42,11 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp - {!user && onOpenShortcuts ? ( + {onOpenShortcuts ? ( ) : null} - {user ? ( -
- - - -
- ) : null}
); } diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 6065af3..bb2f7ab 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -5,7 +5,7 @@ import { Cpu } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import { selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; +import { modelOptionLabel, modelOptionName, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; type ModelPickerProps = { config: AiConfig; @@ -52,10 +52,10 @@ export function ModelPicker({ config, value, onChange, capability, className, fu )} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} - title={current || placeholder} + title={current ? modelOptionLabel(config, current) : placeholder} > - {current || placeholder} + {current ? modelOptionLabel(config, current) : placeholder} {options.length ? ( options.map((model) => ( - - + + )) ) : ( @@ -86,20 +86,20 @@ export function ModelPicker({ config, value, onChange, capability, className, fu function emptyModelLabel(config: AiConfig, capability?: ModelCapability) { const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : ""; if (capability && config.models.length) return "请先在上方配置可选模型"; - return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里拉取模型列表"; + return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型"; } -function ModelLabel({ model }: { model: string }) { +function ModelLabel({ config, model }: { config: AiConfig; model: string }) { return ( - {model} + {modelOptionLabel(config, model)} ); } function ModelIcon({ model }: { model: string }) { - const icon = resolveModelIcon(model); + const icon = resolveModelIcon(modelOptionName(model)); return icon ? : ; } diff --git a/web/src/components/video-settings-panel.tsx b/web/src/components/video-settings-panel.tsx index 8e608b8..ab7bce2 100644 --- a/web/src/components/video-settings-panel.tsx +++ b/web/src/components/video-settings-panel.tsx @@ -6,7 +6,7 @@ import { Switch } from "antd"; import { ImageSettingsTheme } from "@/components/image-settings-panel"; import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedancePixelLabel, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video"; import { type CanvasTheme } from "@/lib/canvas-theme"; -import type { AiConfig } from "@/stores/use-config-store"; +import { modelOptionName, type AiConfig } from "@/stores/use-config-store"; const resolutionOptions = [ { value: "720", label: "720p" }, @@ -103,7 +103,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = } function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) { - const model = config.model || config.videoModel; + const model = modelOptionName(config.model || config.videoModel); const resolution = normalizeSeedanceResolution(config.vquality, model); const ratio = normalizeSeedanceRatio(config.size); const duration = normalizeSeedanceDuration(config.videoSeconds); diff --git a/web/src/constant/credits.tsx b/web/src/constant/credits.tsx index 6ded99c..2c65cfa 100644 --- a/web/src/constant/credits.tsx +++ b/web/src/constant/credits.tsx @@ -14,7 +14,7 @@ export type ModelCreditCost = { credits: number; }; -export function modelCreditCost(modelCosts: ModelCreditCost[] | undefined, model: string) { +function modelCreditCost(modelCosts: ModelCreditCost[] | undefined, model: string) { return modelCosts?.find((item) => item.model === model)?.credits || 0; } diff --git a/web/src/lib/app-theme.ts b/web/src/lib/app-theme.ts index 60f88bf..7c2b518 100644 --- a/web/src/lib/app-theme.ts +++ b/web/src/lib/app-theme.ts @@ -1,4 +1,3 @@ -import type { CSSProperties } from "react"; import type { ThemeConfig } from "antd"; import { theme as antdTheme } from "antd"; @@ -29,14 +28,6 @@ const neutral = { }, }; -export const adminLayoutStyle = { - siderWidth: 232, - headerHeight: 56, - brandHeight: 64, - menu: { borderInlineEnd: 0, padding: "18px 12px", fontSize: 15 } satisfies CSSProperties, - menuItem: { height: 44, lineHeight: "44px", marginBlock: 4, borderRadius: 8 } satisfies CSSProperties, -}; - export function getAntThemeConfig(dark: boolean): ThemeConfig { const color = dark ? neutral.dark : neutral.light; diff --git a/web/src/lib/seedance-video.ts b/web/src/lib/seedance-video.ts index 4194396..6c23411 100644 --- a/web/src/lib/seedance-video.ts +++ b/web/src/lib/seedance-video.ts @@ -1,4 +1,4 @@ -import type { AiConfig } from "@/stores/use-config-store"; +import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; @@ -56,8 +56,9 @@ const seedancePixels = { }, } as const; -export function isSeedanceVideoConfig(config: Pick) { - return isSeedanceVideoModel(config.model || config.videoModel) || isArkPlanBaseUrl(config.baseUrl); +export function isSeedanceVideoConfig(config: AiConfig | Pick) { + const requestConfig = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config; + return isSeedanceVideoModel(modelOptionName(requestConfig.model || requestConfig.videoModel)) || isArkPlanBaseUrl(requestConfig.baseUrl); } export function isSeedanceVideoModel(model: string) { diff --git a/web/src/services/api/admin.ts b/web/src/services/api/admin.ts deleted file mode 100644 index d04ef70..0000000 --- a/web/src/services/api/admin.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { nanoid } from "nanoid"; - -import { fetchPrompts, type Prompt, type PromptListResponse } from "@/services/api/prompts"; -import { fetchImageModels } from "@/services/api/image"; - -export type AdminPromptCategory = { - category: string; - name: string; - description: string; - file: string; - githubUrl: string; - remote: boolean; -}; - -export type AdminUser = { - id: string; - username: string; - email: string; - displayName: string; - avatarUrl: string; - role: "user" | "admin"; - credits: number; - affCode: string; - affCount: number; - inviterId: string; - linuxDoId: string; - status: "active" | "ban"; - lastLoginAt: string; - createdAt: string; - updatedAt: string; -}; - -export type AdminUserListResponse = { - items: AdminUser[]; - total: number; -}; - -export type AdminCreditLog = { - id: string; - userId: string; - type: string; - amount: number; - balance: number; - relatedId: string; - remark: string; - extra: string; - createdAt: string; -}; - -export type AdminCreditLogListResponse = { - items: AdminCreditLog[]; - total: number; -}; - -export type AdminUserQuery = { - keyword?: string; - page?: number; - pageSize?: number; -}; - -export async function fetchAdminUsers(token: string, query: AdminUserQuery = {}) { - return { items: [], total: 0 }; -} - -export async function saveAdminUser(token: string, user: Partial & { password?: string }) { - return { ...emptyAdminUser(), ...user, id: user.id || nanoid() }; -} - -export async function adjustAdminUserCredits(token: string, id: string, credits: number) { - return { ...emptyAdminUser(), id, credits }; -} - -export async function deleteAdminUser(token: string, id: string) { - return true; -} - -export async function fetchAdminCreditLogs(token: string, query: AdminUserQuery = {}) { - return { items: [], total: 0 }; -} - -export async function saveAdminCreditLog(token: string, log: Partial) { - return { id: log.id || nanoid(), userId: "", type: "", amount: 0, balance: 0, relatedId: "", remark: "", extra: "", createdAt: new Date().toISOString(), ...log }; -} - -export async function deleteAdminCreditLog(token: string, id: string) { - return true; -} - -export async function fetchAdminPromptCategories(token: string) { - return promptCategories; -} - -export async function syncAdminPromptCategory(token: string, category: string) { - await fetchPrompts({ category, pageSize: 1 }); - return promptCategories; -} - -export type AdminPromptQuery = { - keyword?: string; - category?: string; - tag?: string[]; - page?: number; - pageSize?: number; -}; - -export type AdminAsset = { - id: string; - title: string; - type: "text" | "image" | "video"; - coverUrl: string; - tags: string[]; - category: string; - description: string; - content: string; - url: string; - createdAt: string; - updatedAt: string; -}; - -export type AdminAssetListResponse = { - items: AdminAsset[]; - tags: string[]; - total: number; -}; - -export async function fetchAdminPrompts(token: string, query: AdminPromptQuery = {}) { - return fetchPrompts(query); -} - -export async function saveAdminPrompt(token: string, prompt: Partial) { - const now = new Date().toISOString(); - return { id: prompt.id || nanoid(), title: "", coverUrl: "", prompt: "", tags: [], category: "", githubUrl: "", preview: "", createdAt: now, updatedAt: now, ...prompt }; -} - -export async function deleteAdminPrompt(token: string, id: string) { - return true; -} - -export async function deleteAdminPrompts(token: string, ids: string[]) { - return true; -} - -export type AdminAssetQuery = { - keyword?: string; - type?: string; - tag?: string[]; - page?: number; - pageSize?: number; -}; - -export async function fetchAdminAssets(token: string, query: AdminAssetQuery = {}) { - return { items: [], tags: [], total: 0 }; -} - -export async function saveAdminAsset(token: string, asset: Partial) { - return { id: asset.id || nanoid(), title: "", type: "text", coverUrl: "", tags: [], category: "", description: "", content: "", url: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...asset }; -} - -export async function deleteAdminAsset(token: string, id: string) { - return true; -} - -export type AdminModelChannel = { - protocol: "openai"; - name: string; - baseUrl: string; - apiKey: string; - models: string[]; - weight: number; - enabled: boolean; - remark: string; -}; - -export type AdminPublicModelChannelSettings = { - availableModels: string[]; - modelCosts: AdminModelCost[]; - defaultModel: string; - defaultImageModel: string; - defaultVideoModel: string; - defaultTextModel: string; - systemPrompt: string; - allowCustomChannel: boolean; -}; - -export type AdminModelCost = { - model: string; - credits: number; -}; - -export type AdminPublicSettings = { - modelChannel: AdminPublicModelChannelSettings; - auth: { - allowRegister: boolean; - linuxDo: { - enabled: boolean; - }; - }; -}; - -export type AdminPrivateSettings = { - channels: AdminModelChannel[]; - promptSync: { - enabled: boolean; - cron: string; - }; - auth: { - linuxDo: { - clientId: string; - clientSecret: string; - }; - }; -}; - -export type AdminSettings = { - public: AdminPublicSettings; - private: AdminPrivateSettings; -}; - -export async function fetchAdminSettings(token: string) { - return defaultAdminSettings(); -} - -export async function saveAdminSettings(token: string, settings: AdminSettings) { - return settings; -} - -export type AdminChannelActionRequest = { - index?: number; - channel: AdminModelChannel; - model?: string; -}; - -export async function fetchChannelModels(token: string, payload: AdminChannelActionRequest) { - return fetchImageModels({ ...defaultAiConfig(), baseUrl: payload.channel.baseUrl, apiKey: payload.channel.apiKey || "local", model: payload.model || "", models: payload.channel.models }); -} - -export async function testChannelModel(token: string, payload: AdminChannelActionRequest) { - return "前台直连配置已保存,请在前台生成时验证模型可用性"; -} - -const promptCategories: AdminPromptCategory[] = [ - { category: "gpt-image-2-prompts", name: "GPT Image 2 Prompts", description: "", file: "", githubUrl: "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", remote: true }, - { category: "awesome-gpt-image", name: "Awesome GPT Image", description: "", file: "", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", remote: true }, - { category: "awesome-gpt4o-image-prompts", name: "Awesome GPT-4o Image Prompts", description: "", file: "", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", remote: true }, - { category: "youmind-gpt-image-2", name: "YouMind GPT Image 2", description: "", file: "", githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", remote: true }, - { category: "youmind-nano-banana-pro", name: "YouMind Nano Banana Pro", description: "", file: "", githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", remote: true }, - { category: "davidwu-gpt-image2-prompts", name: "DavidWu GPT Image2 Prompts", description: "", file: "", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", remote: true }, -]; - -function emptyAdminUser(): AdminUser { - const now = new Date().toISOString(); - return { id: "", username: "", email: "", displayName: "", avatarUrl: "", role: "user", credits: 0, affCode: "", affCount: 0, inviterId: "", linuxDoId: "", status: "active", lastLoginAt: "", createdAt: now, updatedAt: now }; -} - -function defaultAdminSettings(): AdminSettings { - return { - public: { - modelChannel: { availableModels: [], modelCosts: [], defaultModel: "", defaultImageModel: "", defaultVideoModel: "", defaultTextModel: "", systemPrompt: "", allowCustomChannel: true }, - auth: { allowRegister: false, linuxDo: { enabled: false } }, - }, - private: { channels: [], promptSync: { enabled: false, cron: "" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } }, - }; -} - -function defaultAiConfig() { - return { - channelMode: "local" as const, - baseUrl: "", - apiKey: "", - model: "", - imageModel: "", - videoModel: "", - textModel: "", - audioModel: "", - audioVoice: "", - audioFormat: "", - audioSpeed: "", - audioInstructions: "", - videoSeconds: "", - vquality: "", - videoGenerateAudio: "", - videoWatermark: "", - systemPrompt: "", - models: [], - imageModels: [], - videoModels: [], - textModels: [], - audioModels: [], - quality: "", - size: "", - count: "", - canvasImageCount: "", - }; -} diff --git a/web/src/services/api/audio.ts b/web/src/services/api/audio.ts index a951c84..45c5346 100644 --- a/web/src/services/api/audio.ts +++ b/web/src/services/api/audio.ts @@ -2,7 +2,7 @@ import axios from "axios"; import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation"; import { uploadMediaFile, type UploadedFile } from "@/services/file-storage"; -import { buildApiUrl, type AiConfig } from "@/stores/use-config-store"; +import { buildApiUrl, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; function aiApiUrl(config: AiConfig, path: string) { return buildApiUrl(config.baseUrl, path); @@ -16,14 +16,15 @@ function aiHeaders(config: AiConfig) { } export async function requestAudioGeneration(config: AiConfig, prompt: string): Promise { - const model = (config.model || config.audioModel).trim(); - assertAudioConfig(config, model); + const requestConfig = resolveModelRequestConfig(config, config.model || config.audioModel); + const model = requestConfig.model.trim(); + assertAudioConfig(requestConfig, model); const format = normalizeAudioFormatValue(config.audioFormat); const instructions = config.audioInstructions.trim(); try { const response = await axios.post( - aiApiUrl(config, "/audio/speech"), + aiApiUrl(requestConfig, "/audio/speech"), { model, input: prompt, @@ -32,7 +33,7 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string): speed: Number(normalizeAudioSpeedValue(config.audioSpeed)), ...(instructions ? { instructions } : {}), }, - { headers: aiHeaders(config), responseType: "blob" }, + { headers: aiHeaders(requestConfig), responseType: "blob" }, ); await assertAudioBlob(response.data); return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) }); diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts deleted file mode 100644 index 4ae39ee..0000000 --- a/web/src/services/api/auth.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const AUTH_TOKEN_KEY = "infinite-canvas-auth-token-v1"; - -export type UserRole = "guest" | "user" | "admin"; - -export type AuthUser = { - id: string; - username: string; - displayName: string; - avatarUrl: string; - role: UserRole; - credits: number; - createdAt: string; - updatedAt: string; -}; - -export type AuthSession = { - token: string; - user: AuthUser; -}; - -export type AuthPayload = { - username: string; - password: string; -}; - -export async function login(payload: AuthPayload) { - const now = new Date().toISOString(); - return { token: "", user: { id: "local-user", username: payload.username || "local", displayName: payload.username || "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: now, updatedAt: now } }; -} - -export async function register(payload: AuthPayload) { - return login(payload); -} - -export async function fetchCurrentUser(token?: string) { - return { id: "local-user", username: "local", displayName: "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: "", updatedAt: "" }; -} diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index e7706c4..a04813d 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -1,6 +1,6 @@ import axios from "axios"; -import { buildApiUrl, type AiConfig } from "@/stores/use-config-store"; +import { buildApiUrl, resolveModelRequestConfig, type AiConfig, type ModelChannel } from "@/stores/use-config-store"; import { nanoid } from "nanoid"; import { dataUrlToFile } from "@/lib/image-utils"; import { buildImageReferencePromptText } from "@/lib/image-reference-prompt"; @@ -184,15 +184,16 @@ function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[]) } export async function requestGeneration(config: AiConfig, prompt: string) { + const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel); const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const quality = normalizeQuality(config.quality); const requestSize = resolveRequestSize(quality, config.size); try { const response = await axios.post( - aiApiUrl(config, "/images/generations"), + aiApiUrl(requestConfig, "/images/generations"), { - model: config.model, - prompt: withSystemPrompt(config, prompt), + model: requestConfig.model, + prompt: withSystemPrompt(requestConfig, prompt), n, ...(quality ? { quality } : {}), ...(requestSize ? { size: requestSize } : {}), @@ -200,7 +201,7 @@ export async function requestGeneration(config: AiConfig, prompt: string) { output_format: IMAGE_OUTPUT_FORMAT, }, { - headers: aiHeaders(config, "application/json"), + headers: aiHeaders(requestConfig, "application/json"), }, ); const images = parseImagePayload(response.data); @@ -211,13 +212,14 @@ export async function requestGeneration(config: AiConfig, prompt: string) { } export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage) { + const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel); const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const quality = normalizeQuality(config.quality); const requestSize = resolveRequestSize(quality, config.size); const requestPrompt = buildImageReferencePromptText(prompt, references); const formData = new FormData(); - formData.set("model", config.model); - formData.set("prompt", withSystemPrompt(config, requestPrompt)); + formData.set("model", requestConfig.model); + formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt)); formData.set("n", String(n)); formData.set("response_format", "b64_json"); formData.set("output_format", IMAGE_OUTPUT_FORMAT); @@ -232,7 +234,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references: if (mask) formData.set("mask", dataUrlToFile(mask)); try { - const response = await axios.post(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) }); + const response = await axios.post(aiApiUrl(requestConfig, "/images/edits"), formData, { headers: aiHeaders(requestConfig) }); const images = parseImagePayload(response.data); return images; } catch (error) { @@ -241,21 +243,22 @@ export async function requestEdit(config: AiConfig, prompt: string, references: } export async function requestImageQuestion(config: AiConfig, messages: ChatCompletionMessage[], onDelta: (text: string) => void) { + const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel); let buffer = ""; let answer = ""; let processedLength = 0; try { const response = await axios.post( - aiApiUrl(config, "/chat/completions"), + aiApiUrl(requestConfig, "/chat/completions"), { - model: config.model, - messages: withSystemMessage(config, messages), + model: requestConfig.model, + messages: withSystemMessage(requestConfig, messages), stream: true, }, { headers: { - ...aiHeaders(config, "application/json"), + ...aiHeaders(requestConfig, "application/json"), } as Record, responseType: "text", onDownloadProgress: (event) => { @@ -301,7 +304,7 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl return answer || "没有返回内容"; } -export async function fetchImageModels(config: AiConfig) { +export async function fetchImageModels(config: Pick) { try { const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), { headers: { @@ -316,3 +319,7 @@ export async function fetchImageModels(config: AiConfig) { throw new Error(readAxiosError(error, "读取模型失败")); } } + +export async function fetchChannelModels(channel: ModelChannel) { + return fetchImageModels({ baseUrl: channel.baseUrl, apiKey: channel.apiKey }); +} diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index 648beaf..b172db0 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -4,7 +4,7 @@ import { dataUrlToFile } from "@/lib/image-utils"; import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/file-storage"; import { imageToDataUrl } from "@/services/image-storage"; import { boolConfig, buildSeedancePromptText, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video"; -import { buildApiUrl, type AiConfig } from "@/stores/use-config-store"; +import { buildApiUrl, modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; @@ -47,20 +47,22 @@ export async function requestVideoGeneration(config: AiConfig, prompt: string, r } export async function createVideoGenerationTask(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise { - const model = (config.model || config.videoModel).trim(); - assertVideoConfig(config, model); - if (isSeedanceVideoConfig({ ...config, model })) { - return createSeedanceTask(config, model, prompt, references, videoReferences, audioReferences); + const selectedModel = (config.model || config.videoModel).trim(); + const requestConfig = resolveModelRequestConfig(config, selectedModel); + assertVideoConfig(requestConfig, requestConfig.model); + if (isSeedanceVideoConfig(requestConfig)) { + return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences); } if (videoReferences.length || audioReferences.length) { throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考素材"); } - return createOpenAIVideoTask(config, model, prompt, references); + return createOpenAIVideoTask(requestConfig, selectedModel, prompt, references); } export async function pollVideoGenerationTask(config: AiConfig, task: VideoGenerationTask): Promise { - assertVideoConfig(config, task.model); - return task.provider === "seedance" ? pollSeedanceTask(config, task) : pollOpenAIVideoTask(config, task); + const requestConfig = resolveModelRequestConfig(config, task.model); + assertVideoConfig(requestConfig, requestConfig.model); + return task.provider === "seedance" ? pollSeedanceTask(requestConfig, task) : pollOpenAIVideoTask(requestConfig, task); } export async function storeGeneratedVideo(result: VideoGenerationResult): Promise { @@ -71,7 +73,7 @@ export async function storeGeneratedVideo(result: VideoGenerationResult): Promis async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[]): Promise { const body = new FormData(); - body.append("model", model); + body.append("model", modelOptionName(model)); body.append("prompt", prompt); body.append("seconds", normalizeVideoSeconds(config.videoSeconds)); if (normalizeVideoSize(config.size)) body.append("size", normalizeVideoSize(config.size)!); @@ -112,10 +114,10 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin const content = await buildSeedanceContent(config, prompt, references, videoReferences, audioReferences); if (!content.length) throw new Error("请输入视频提示词,或连接参考图片/视频/音频"); const payload = { - model, + model: modelOptionName(model), content, ratio: normalizeSeedanceRatio(config.size), - resolution: normalizeSeedanceResolution(config.vquality, model), + resolution: normalizeSeedanceResolution(config.vquality, modelOptionName(model)), duration: normalizeSeedanceDuration(config.videoSeconds), generate_audio: boolConfig(config.videoGenerateAudio, true), watermark: boolConfig(config.videoWatermark, false), diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 0a61927..2950b49 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -3,13 +3,21 @@ import { useMemo } from "react"; import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { nanoid } from "nanoid"; -import type { AdminPublicSettings } from "@/services/api/admin"; +export type ModelChannel = { + id: string; + name: string; + baseUrl: string; + apiKey: string; + models: string[]; +}; export type AiConfig = { channelMode: "remote" | "local"; baseUrl: string; apiKey: string; + channels: ModelChannel[]; model: string; imageModel: string; videoModel: string; @@ -46,16 +54,26 @@ export type WebdavSyncConfig = { export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store"; export type ModelCapability = "image" | "video" | "text" | "audio"; +const CHANNEL_MODEL_SEPARATOR = "::"; export const defaultConfig: AiConfig = { channelMode: "local", baseUrl: "https://api.openai.com", apiKey: "", - model: "gpt-image-2", - imageModel: "gpt-image-2", - videoModel: "grok-imagine-video", - textModel: "gpt-5.5", - audioModel: "gpt-4o-mini-tts", + channels: [ + { + id: "default", + name: "默认渠道", + baseUrl: "https://api.openai.com", + apiKey: "", + models: ["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"], + }, + ], + model: "default::gpt-image-2", + imageModel: "default::gpt-image-2", + videoModel: "default::grok-imagine-video", + textModel: "default::gpt-5.5", + audioModel: "default::gpt-4o-mini-tts", audioVoice: "alloy", audioFormat: "mp3", audioSpeed: "1", @@ -65,11 +83,11 @@ export const defaultConfig: AiConfig = { videoGenerateAudio: "true", videoWatermark: "false", systemPrompt: "", - models: [], - imageModels: [], - videoModels: [], - textModels: [], - audioModels: [], + models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"], + imageModels: ["default::gpt-image-2"], + videoModels: ["default::grok-imagine-video"], + textModels: ["default::gpt-5.5"], + audioModels: ["default::gpt-4o-mini-tts"], quality: "auto", size: "1:1", count: "1", @@ -88,35 +106,28 @@ export const defaultWebdavSyncConfig: WebdavSyncConfig = { type ConfigStore = { config: AiConfig; webdav: WebdavSyncConfig; - publicSettings: AdminPublicSettings | null; - isPublicSettingsLoading: boolean; isConfigOpen: boolean; shouldPromptContinue: boolean; updateConfig: (key: K, value: AiConfig[K]) => void; updateWebdavConfig: (key: K, value: WebdavSyncConfig[K]) => void; - loadPublicSettings: () => Promise; isAiConfigReady: (config: AiConfig, model: string) => boolean; openConfigDialog: (shouldPromptContinue?: boolean) => void; setConfigDialogOpen: (isOpen: boolean) => void; clearPromptContinue: () => void; }; -function preferredModel(models: string[], predicate: (model: string) => boolean) { - return models.find(predicate) || ""; -} - function isVideoModelName(model: string) { - const value = model.toLowerCase(); + const value = modelOptionName(model).toLowerCase(); return value.includes("seedance") || value.includes("video") || value.includes("sora") || value.includes("veo") || value.includes("kling") || value.includes("wan") || value.includes("hailuo"); } function isImageModelName(model: string) { - const value = model.toLowerCase(); + const value = modelOptionName(model).toLowerCase(); return !isVideoModelName(model) && !isAudioModelName(model) && (value.includes("seedream") || value.includes("gpt-image") || value.includes("image") || value.includes("dall-e") || value.includes("dalle") || value.includes("imagen") || value.includes("flux") || value.includes("sdxl") || value.includes("stable-diffusion") || value.includes("midjourney")); } function isAudioModelName(model: string) { - const value = model.toLowerCase(); + const value = modelOptionName(model).toLowerCase(); return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("voice") || value.includes("music") || value.includes("sound"); } @@ -146,7 +157,8 @@ function modelListKey(capability: ModelCapability) { } function isAiConfigReady(config: AiConfig, model: string) { - return Boolean(model.trim() && config.baseUrl.trim() && config.apiKey.trim()); + const channel = resolveModelChannel(config, model); + return Boolean(model.trim() && channel.baseUrl.trim() && channel.apiKey.trim()); } export const useConfigStore = create()( @@ -154,8 +166,6 @@ export const useConfigStore = create()( (set, get) => ({ config: defaultConfig, webdav: defaultWebdavSyncConfig, - publicSettings: null, - isPublicSettingsLoading: false, isConfigOpen: false, shouldPromptContinue: false, updateConfig: (key, value) => @@ -172,15 +182,6 @@ export const useConfigStore = create()( [key]: value, }, })), - loadPublicSettings: async () => { - if (get().isPublicSettingsLoading) return; - set({ isPublicSettingsLoading: true }); - try { - set({ publicSettings: defaultPublicSettings(get().config) }); - } finally { - set({ isPublicSettingsLoading: false }); - } - }, isAiConfigReady: (config, model) => isAiConfigReady(config, model), openConfigDialog: (shouldPromptContinue = false) => set({ isConfigOpen: true, shouldPromptContinue }), setConfigDialogOpen: (isConfigOpen) => set({ isConfigOpen }), @@ -194,16 +195,21 @@ export const useConfigStore = create()( const persistedConfig = (persistedState.config || {}) as Partial; const persistedWebdav = (persistedState.webdav || {}) as Partial; const config = { ...defaultConfig, ...persistedConfig }; + if (!Array.isArray(persistedConfig.channels)) config.channels = []; + const channels = normalizeChannels(config); + const models = modelOptionsFromChannels(channels); return { ...current, webdav: { ...defaultWebdavSyncConfig, ...persistedWebdav }, config: { ...config, channelMode: "local", - imageModel: config.imageModel || config.model, - videoModel: config.videoModel || "grok-imagine-video", - textModel: config.textModel || config.model, - audioModel: config.audioModel || defaultConfig.audioModel, + channels, + models, + imageModel: normalizeModelOptionValue(config.imageModel || config.model, channels), + videoModel: normalizeModelOptionValue(config.videoModel || "grok-imagine-video", channels), + textModel: normalizeModelOptionValue(config.textModel || config.model, channels), + audioModel: normalizeModelOptionValue(config.audioModel || defaultConfig.audioModel, channels), audioVoice: config.audioVoice || defaultConfig.audioVoice, audioFormat: config.audioFormat || defaultConfig.audioFormat, audioSpeed: config.audioSpeed || defaultConfig.audioSpeed, @@ -213,10 +219,10 @@ export const useConfigStore = create()( videoGenerateAudio: config.videoGenerateAudio || "true", videoWatermark: config.videoWatermark || "false", canvasImageCount: config.canvasImageCount || "3", - imageModels: Array.isArray(persistedConfig.imageModels) ? normalizeModelList(config.imageModels) : filterModelsByCapability(config.models, "image"), - videoModels: Array.isArray(persistedConfig.videoModels) ? normalizeModelList(config.videoModels) : filterModelsByCapability(config.models, "video"), - textModels: Array.isArray(persistedConfig.textModels) ? normalizeModelList(config.textModels) : filterModelsByCapability(config.models, "text"), - audioModels: Array.isArray(persistedConfig.audioModels) ? normalizeModelList(config.audioModels) : filterModelsByCapability(config.models, "audio"), + imageModels: Array.isArray(persistedConfig.imageModels) ? normalizeModelList(config.imageModels, channels) : filterModelsByCapability(models, "image"), + videoModels: Array.isArray(persistedConfig.videoModels) ? normalizeModelList(config.videoModels, channels) : filterModelsByCapability(models, "video"), + textModels: Array.isArray(persistedConfig.textModels) ? normalizeModelList(config.textModels, channels) : filterModelsByCapability(models, "text"), + audioModels: Array.isArray(persistedConfig.audioModels) ? normalizeModelList(config.audioModels, channels) : filterModelsByCapability(models, "audio"), }, }; }, @@ -224,8 +230,11 @@ export const useConfigStore = create()( ), ); -function normalizeModelList(models: string[]) { - return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean))); +function normalizeModelList(models: string[], channels: ModelChannel[]) { + const allModelOptions = channels.flatMap((channel) => channel.models.map((model) => encodeChannelModel(channel.id, model))); + return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean))) + .map((model) => normalizeModelOptionValue(model, channels)) + .filter((model) => !allModelOptions.length || allModelOptions.includes(model) || !isChannelModelValue(model)); } export function useEffectiveConfig() { @@ -233,22 +242,113 @@ export function useEffectiveConfig() { return useMemo(() => ({ ...config, channelMode: "local" as const }), [config]); } -function defaultPublicSettings(config: AiConfig): AdminPublicSettings { +export function createModelChannel(channel?: Partial): ModelChannel { return { - modelChannel: { - availableModels: config.models, - modelCosts: [], - defaultModel: config.model, - defaultImageModel: config.imageModel, - defaultVideoModel: config.videoModel, - defaultTextModel: config.textModel, - systemPrompt: config.systemPrompt, - allowCustomChannel: true, - }, - auth: { allowRegister: false, linuxDo: { enabled: false } }, + id: channel?.id?.trim() || nanoid(), + name: channel?.name?.trim() || "新渠道", + baseUrl: channel?.baseUrl?.trim() || "https://api.openai.com", + apiKey: channel?.apiKey || "", + models: uniqueRawModels(channel?.models || []), }; } +export function encodeChannelModel(channelId: string, model: string) { + return `${channelId}${CHANNEL_MODEL_SEPARATOR}${model.trim()}`; +} + +export function isChannelModelValue(value: string) { + return value.includes(CHANNEL_MODEL_SEPARATOR); +} + +export function decodeChannelModel(value: string) { + const index = value.indexOf(CHANNEL_MODEL_SEPARATOR); + if (index < 0) return null; + return { channelId: value.slice(0, index), model: value.slice(index + CHANNEL_MODEL_SEPARATOR.length) }; +} + +export function modelOptionName(value: string) { + return decodeChannelModel(value)?.model || value; +} + +export function modelOptionLabel(config: AiConfig, value: string) { + const decoded = decodeChannelModel(value); + if (!decoded) return value; + const channel = config.channels.find((item) => item.id === decoded.channelId); + return channel ? `${decoded.model}(${channel.name})` : decoded.model; +} + +export function modelOptionsFromChannels(channels: ModelChannel[]) { + return uniqueModelOptions(channels.flatMap((channel) => channel.models.map((model) => encodeChannelModel(channel.id, model)))); +} + +export function normalizeModelOptionValue(value: string | undefined, channels: ModelChannel[]) { + const model = (value || "").trim(); + if (!model) return ""; + const decoded = decodeChannelModel(model); + if (decoded) { + const channel = channels.find((item) => item.id === decoded.channelId); + return channel && channel.models.includes(decoded.model) ? model : ""; + } + const channel = channels.find((item) => item.models.includes(decoded?.model || model)) || channels[0]; + return channel && channel.models.includes(decoded?.model || model) ? encodeChannelModel(channel.id, decoded?.model || model) : model; +} + +export function resolveModelChannel(config: AiConfig, value: string) { + const decoded = decodeChannelModel(value); + const model = decoded?.model || value; + const matched = decoded ? config.channels.find((channel) => channel.id === decoded.channelId) : config.channels.find((channel) => channel.models.includes(model)); + return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, models: config.models.map(modelOptionName) }); +} + +export function resolveModelRequestConfig(config: AiConfig, value: string) { + const channel = resolveModelChannel(config, value); + return { + ...config, + model: modelOptionName(value || config.model), + baseUrl: channel.baseUrl, + apiKey: channel.apiKey, + }; +} + +function normalizeChannels(config: AiConfig) { + const persistedChannels = Array.isArray(config.channels) ? config.channels : []; + const channels = persistedChannels.map((channel, index) => + createModelChannel({ + ...channel, + id: channel.id || (index === 0 ? "default" : `channel-${index + 1}`), + name: channel.name || (index === 0 ? "默认渠道" : `渠道 ${index + 1}`), + models: uniqueRawModels(channel.models || []), + }), + ); + if (!channels.length) { + channels.push( + createModelChannel({ + id: "default", + name: "默认渠道", + baseUrl: config.baseUrl || defaultConfig.baseUrl, + apiKey: config.apiKey || "", + models: uniqueRawModels([ + ...(config.models || []), + config.model, + config.imageModel, + config.videoModel, + config.textModel, + config.audioModel, + ]), + }), + ); + } + return channels.map((channel) => ({ ...channel, models: uniqueRawModels(channel.models) })); +} + +function uniqueRawModels(models: string[]) { + return Array.from(new Set((models || []).map((model) => modelOptionName(model).trim()).filter(Boolean))); +} + +function uniqueModelOptions(models: string[]) { + return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean))); +} + export function buildApiUrl(baseUrl: string, path: string) { let normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, ""); normalizedBaseUrl = normalizeArkPlanBaseUrl(normalizedBaseUrl); diff --git a/web/src/stores/use-user-store.ts b/web/src/stores/use-user-store.ts index f107230..bdf3ec2 100644 --- a/web/src/stores/use-user-store.ts +++ b/web/src/stores/use-user-store.ts @@ -1,51 +1,20 @@ "use client"; import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import { AUTH_TOKEN_KEY, type AuthPayload, type AuthUser } from "@/services/api/auth"; - -type UserStore = { - token: string; - user: AuthUser | null; - isReady: boolean; - isLoading: boolean; - setSession: (token: string, user: AuthUser) => void; - clearSession: () => void; - hydrateUser: () => Promise; - login: (payload: AuthPayload) => Promise; - register: (payload: AuthPayload) => Promise; +export type LocalUser = { + id: string; + username: string; + displayName: string; + avatarUrl: string; }; -export const useUserStore = create()( - persist( - (set, get) => ({ - token: "", - user: null, - isReady: false, - isLoading: false, - setSession: (token, user) => set({ token, user, isReady: true }), - clearSession: () => set({ token: "", user: null, isReady: true }), - hydrateUser: async () => { - set({ token: "", user: null, isReady: true, isLoading: false }); - }, - login: async (payload) => { - set({ isLoading: true }); - const now = new Date().toISOString(); - const user: AuthUser = { id: "local-user", username: payload.username || "local", displayName: payload.username || "本地用户", avatarUrl: "", role: "user", credits: 0, createdAt: now, updatedAt: now }; - set({ token: "", user, isReady: true, isLoading: false }); - return user; - }, - register: async (payload) => { - return get().login(payload); - }, - }), - { - name: AUTH_TOKEN_KEY, - partialize: (state) => ({ token: state.token }), - onRehydrateStorage: () => (state) => { - if (state) state.isReady = false; - }, - }, - ), -); +type UserStore = { + user: LocalUser | null; + clearSession: () => void; +}; + +export const useUserStore = create()((set) => ({ + user: null, + clearSession: () => set({ user: null }), +}));