mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 06:54:06 +08:00
feat: transition to a fully frontend architecture by removing backend dependencies and updating configuration for local deployment
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
## Unreleased
|
||||
|
||||
+ [新增] 新增网页版Agent Loop模式。
|
||||
+ [调整] 移除后端,项目定位为个人画布工具。
|
||||
|
||||
## v0.3.0 - 2026-06-15
|
||||
|
||||
|
||||
@@ -144,10 +144,10 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
|
||||
“素材库”入口目前保留为空的公共素材列表,主要数据仍建议放在“我的素材”中。
|
||||
|
||||
## 账号和后台
|
||||
## 配置和同步
|
||||
|
||||
- 当前版本不需要账号登录,登录页仅提示本地模式和配置入口。
|
||||
- `/admin/settings` 只保留前台配置、提示词缓存和本地数据说明。
|
||||
- 当前版本不需要账号登录,也不再提供后台管理页面。
|
||||
- 配置与用户偏好弹窗支持多个 OpenAI 兼容渠道、默认模型、生成偏好和 WebDAV 同步设置。
|
||||
- 第三方提示词和 WebDAV 可使用少量 Next.js route,AI 接口不经过项目后端代理。
|
||||
|
||||
## 当前限制
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 说明
|
||||
|
||||
@@ -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 崩溃。
|
||||
|
||||
@@ -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<AdminAsset> & { 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<AssetFormValues>();
|
||||
const [keywordText, setKeywordText] = useState(keyword);
|
||||
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
|
||||
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
|
||||
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(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<AdminAsset>[] = [
|
||||
{
|
||||
title: "封面",
|
||||
dataIndex: "coverUrl",
|
||||
width: 88,
|
||||
render: (_, item) => <Image src={item.coverUrl || item.url || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
|
||||
},
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
width: 260,
|
||||
render: (_, item) => (
|
||||
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>
|
||||
{item.title}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
width: 84,
|
||||
render: (_, item) => <Tag>{item.type === "image" ? "图片" : "文本"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
dataIndex: "tags",
|
||||
width: 180,
|
||||
render: (_, item) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
{(item.tags || []).slice(0, 3).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
dataIndex: "category",
|
||||
width: 120,
|
||||
render: (_, item) => <Typography.Text type="secondary">{item.category || "未标注"}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 112,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="详情">
|
||||
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ padding: 24 }}>
|
||||
<Flex vertical gap={16}>
|
||||
<Card variant="borderless">
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px">
|
||||
<Form.Item label="关键词">
|
||||
<Input.Search value={keywordText} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={() => searchAssets(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="180px">
|
||||
<Form.Item label="类型">
|
||||
<Select value={kind} onChange={changeKind} options={typeOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="220px">
|
||||
<Form.Item label="标签">
|
||||
<Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeywordText("");
|
||||
resetFilters();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchAssets(keywordText)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
<ProTable<AdminAsset>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={assets}
|
||||
loading={isLoading}
|
||||
search={false}
|
||||
defaultSize="middle"
|
||||
tableLayout="fixed"
|
||||
cardProps={{ variant: "borderless" }}
|
||||
headerTitle={
|
||||
<Space>
|
||||
<Typography.Text strong>素材列表</Typography.Text>
|
||||
<Tag>{total} 条</Tag>
|
||||
</Space>
|
||||
}
|
||||
options={{ density: true, setting: true, reload: () => void refreshAssets() }}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}>
|
||||
新增
|
||||
</Button>,
|
||||
]}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}>
|
||||
<Select options={editTypeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tagText" label="标签,用逗号分隔">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
{formType === "image" ? (
|
||||
<Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||||
<Input.TextArea rows={6} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="素材详情" open={Boolean(detailAsset)} width={760} onCancel={() => setDetailAsset(null)} footer={<Button onClick={() => setDetailAsset(null)}>关闭</Button>}>
|
||||
{detailAsset ? (
|
||||
<Flex vertical gap={14}>
|
||||
<Flex gap={14} align="start">
|
||||
<Image src={detailAsset.coverUrl || detailAsset.url || "/logo.svg"} alt={detailAsset.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
|
||||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{detailAsset.title}
|
||||
</Typography.Title>
|
||||
<Space wrap>
|
||||
<Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>
|
||||
{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}
|
||||
{(detailAsset.tags || []).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{detailAsset.description ? (
|
||||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
|
||||
{detailAsset.description}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
|
||||
<Button icon={<CopyOutlined />} onClick={() => copyText(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}>
|
||||
复制内容
|
||||
</Button>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="删除素材"
|
||||
open={Boolean(deletingAsset)}
|
||||
onCancel={() => setDeletingAsset(null)}
|
||||
onOk={async () => {
|
||||
if (!deletingAsset) return;
|
||||
await deleteAsset(deletingAsset.id);
|
||||
setDeletingAsset(null);
|
||||
}}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从服务器素材库中移除。
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>([]);
|
||||
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<AdminAsset>) => 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<AdminAsset>) => saveMutation.mutateAsync(asset),
|
||||
deleteAsset: (id: string) => deleteMutation.mutateAsync(id),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminCreditLogsPage() {
|
||||
redirect("/admin/settings");
|
||||
}
|
||||
@@ -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: <FileTextOutlined />, label: "提示词管理" },
|
||||
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
|
||||
{ key: "/admin/settings", icon: <SettingOutlined />, 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 (
|
||||
<Layout hasSider style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgLayout }}>
|
||||
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
|
||||
<Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
|
||||
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
|
||||
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}>
|
||||
无限画布
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activeKey]}
|
||||
style={adminLayoutStyle.menu}
|
||||
items={adminMenus.map((item) => ({
|
||||
...item,
|
||||
label: (
|
||||
<Link href={item.key} style={{ color: "inherit" }}>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
style: adminLayoutStyle.menuItem,
|
||||
}))}
|
||||
/>
|
||||
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
|
||||
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">
|
||||
前往画布
|
||||
</Button>
|
||||
</Flex>
|
||||
</Layout.Sider>
|
||||
<Layout style={{ background: antToken.colorBgLayout }}>
|
||||
<Layout.Header
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}
|
||||
>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{pageTitle}
|
||||
</Typography.Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<UserStatusActions showConfig={false} />
|
||||
</Flex>
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/settings");
|
||||
}
|
||||
@@ -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<Partial<Prompt> & { tagText?: string }>();
|
||||
const [keywordText, setKeywordText] = useState(keyword);
|
||||
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
|
||||
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
|
||||
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(null);
|
||||
const [selectedPromptIds, setSelectedPromptIds] = useState<string[]>([]);
|
||||
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<Prompt>[] = [
|
||||
{
|
||||
title: "封面",
|
||||
dataIndex: "coverUrl",
|
||||
width: 88,
|
||||
render: (_, item) => <Image src={item.coverUrl || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
|
||||
},
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
width: 260,
|
||||
render: (_, item) => (
|
||||
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailPrompt(item)}>
|
||||
{item.title}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
dataIndex: "category",
|
||||
width: 150,
|
||||
render: (_, item) => <Typography.Text type="secondary">{categoryName(item.category)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
dataIndex: "tags",
|
||||
width: 180,
|
||||
render: (_, item) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
{(item.tags || []).slice(0, 3).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 112,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="详情">
|
||||
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(item)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ padding: 24 }}>
|
||||
<Flex vertical gap={16}>
|
||||
<Card variant="borderless">
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px">
|
||||
<Form.Item label="关键词">
|
||||
<Input.Search value={keywordText} placeholder="搜索标题或提示词" allowClear enterButton={<SearchOutlined />} onSearch={() => searchPrompts(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="220px">
|
||||
<Form.Item label="分组">
|
||||
<Select value={category} onChange={changeCategory} options={categoryOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="220px">
|
||||
<Form.Item label="标签">
|
||||
<Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeywordText("");
|
||||
resetFilters();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchPrompts(keywordText)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
<ProTable<Prompt>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={prompts}
|
||||
loading={isLoading}
|
||||
search={false}
|
||||
defaultSize="middle"
|
||||
tableLayout="fixed"
|
||||
cardProps={{ variant: "borderless" }}
|
||||
headerTitle={
|
||||
<Space>
|
||||
<Typography.Text strong>提示词列表</Typography.Text>
|
||||
<Tag>{total} 条</Tag>
|
||||
</Space>
|
||||
}
|
||||
options={{ density: true, setting: true, reload: () => void refreshPrompts() }}
|
||||
rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }}
|
||||
toolBarRender={() => [
|
||||
<Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>
|
||||
批量删除{selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}
|
||||
</Button>,
|
||||
<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>
|
||||
同步
|
||||
</Button>,
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, tags: [] })}>
|
||||
新增
|
||||
</Button>,
|
||||
]}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Modal title={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Select options={categories.map((item) => ({ label: item.name, value: item.category }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tagText" label="标签,用逗号分隔">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}>
|
||||
<Input.TextArea rows={6} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="提示词详情" open={Boolean(detailPrompt)} width={760} onCancel={() => setDetailPrompt(null)} footer={<Button onClick={() => setDetailPrompt(null)}>关闭</Button>}>
|
||||
{detailPrompt ? (
|
||||
<Flex vertical gap={14}>
|
||||
<Flex gap={14} align="start">
|
||||
<Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
|
||||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{detailPrompt.title}
|
||||
</Typography.Title>
|
||||
<Space wrap>
|
||||
<Tag>{categoryName(detailPrompt.category)}</Tag>
|
||||
{(detailPrompt.tags || []).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{detailPrompt.preview ? (
|
||||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
|
||||
{detailPrompt.preview}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
|
||||
<Space>
|
||||
<Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}>
|
||||
复制提示词
|
||||
</Button>
|
||||
{detailPrompt.githubUrl ? (
|
||||
<Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank">
|
||||
远程源
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="同步远程提示词源"
|
||||
open={isSyncOpen}
|
||||
width={640}
|
||||
onCancel={() => !isSyncing && setIsSyncOpen(false)}
|
||||
mask={{ closable: !isSyncing }}
|
||||
footer={
|
||||
<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="category"
|
||||
dataSource={categories.filter((item) => item.remote)}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: "远程源",
|
||||
dataIndex: "name",
|
||||
render: (_, item) => (
|
||||
<Flex align="center" gap={8}>
|
||||
{item.name}
|
||||
{item.githubUrl ? (
|
||||
<Typography.Link href={item.githubUrl} target="_blank">
|
||||
<ExportOutlined />
|
||||
</Typography.Link>
|
||||
) : null}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "sync",
|
||||
width: 96,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isSyncing}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await syncCategory(item.category);
|
||||
setIsSyncOpen(false);
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
同步
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="删除提示词"
|
||||
open={Boolean(deletingPrompt)}
|
||||
onCancel={() => setDeletingPrompt(null)}
|
||||
onOk={async () => {
|
||||
if (!deletingPrompt) return;
|
||||
await deletePrompt(deletingPrompt.id);
|
||||
setDeletingPrompt(null);
|
||||
}}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。
|
||||
</Modal>
|
||||
|
||||
<Modal title="批量删除提示词" open={isBatchDeleteOpen} onCancel={() => setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>([]);
|
||||
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<AdminPromptCategory[]>(["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<Prompt>) => 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<Prompt>) => saveMutation.mutateAsync(prompt),
|
||||
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
|
||||
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="p-6">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<Settings2 className="mb-4 size-6 text-stone-700 dark:text-stone-200" />
|
||||
<Typography.Title level={4} className="!mb-2">
|
||||
前台直连配置
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">模型 Base URL、API Key、默认模型和 WebDAV 同步都保存在浏览器本地,不再通过后台渠道转发。</Typography.Paragraph>
|
||||
<Button type="primary" onClick={() => openConfigDialog(false)}>
|
||||
打开配置弹窗
|
||||
</Button>
|
||||
</Card>
|
||||
<Card>
|
||||
<Sparkles className="mb-4 size-6 text-stone-700 dark:text-stone-200" />
|
||||
<Typography.Title level={4} className="!mb-2">
|
||||
第三方提示词
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">提示词由 Next.js route 从开源仓库拉取,并缓存在当前运行实例的内存里;页面和管理列表都读取同一份缓存。</Typography.Paragraph>
|
||||
<Link href="/admin/prompts">
|
||||
<Button>查看提示词</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
<Card>
|
||||
<Database className="mb-4 size-6 text-stone-700 dark:text-stone-200" />
|
||||
<Typography.Title level={4} className="!mb-2">
|
||||
本地数据
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">画布项目、我的素材、生成记录和 AI Key 默认留在浏览器本地;需要跨设备时请在配置弹窗里使用 WebDAV 同步。</Typography.Paragraph>
|
||||
<Link href="/canvas">
|
||||
<Button>前往画布</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
redirect("/admin/settings");
|
||||
}
|
||||
@@ -2721,9 +2721,7 @@ function CanvasTopBar({
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const titleRef = useRef<HTMLDivElement>(null);
|
||||
const accountRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between px-4">
|
||||
@@ -2800,14 +2789,7 @@ function CanvasTopBar({
|
||||
<div className="pointer-events-auto flex items-center gap-1.5">
|
||||
<UserStatusActions
|
||||
variant="canvas"
|
||||
accountOpen={accountOpen}
|
||||
onAccountOpenChange={setAccountOpen}
|
||||
accountRef={accountRef}
|
||||
getPopupContainer={(node) => node.parentElement || document.body}
|
||||
onOpenShortcuts={() => {
|
||||
setShortcutsOpen(true);
|
||||
setAccountOpen(false);
|
||||
}}
|
||||
onOpenShortcuts={() => setShortcutsOpen(true)}
|
||||
/>
|
||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button, Tooltip } from "antd";
|
||||
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound, Wrench, X, XCircle } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { AuthUser } from "@/services/api/auth";
|
||||
import type { LocalUser } from "@/stores/use-user-store";
|
||||
|
||||
export type CanvasAgentChatAttachment = { id: string; name: string; url: string };
|
||||
export type CanvasAgentMode = "online" | "local";
|
||||
@@ -21,7 +21,7 @@ export type CanvasAgentChatMessage = {
|
||||
|
||||
const WORKING_TEXT = "working...";
|
||||
|
||||
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: AuthUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
|
||||
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
|
||||
const isUser = item.role === "user";
|
||||
const isSystem = item.role === "system";
|
||||
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
|
||||
@@ -276,7 +276,7 @@ function AgentAvatar({ theme }: { theme: (typeof canvasThemes)[keyof typeof canv
|
||||
);
|
||||
}
|
||||
|
||||
function AgentUserAvatar({ user, theme }: { user: AuthUser | null; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
function AgentUserAvatar({ user, theme }: { user: LocalUser | null; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
const avatarUrl = user?.avatarUrl?.trim();
|
||||
return (
|
||||
<span className="grid size-8 shrink-0 place-items-center overflow-hidden rounded-full" style={{ color: theme.node.text }}>
|
||||
|
||||
@@ -25,13 +25,12 @@ type CanvasConfigNodePanelProps = {
|
||||
|
||||
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle }: CanvasConfigNodePanelProps) {
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const mode = node.metadata?.generationMode || "image";
|
||||
const config = buildNodeConfig(globalConfig, node, mode);
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? count : 1 });
|
||||
const credits = requestCreditCost({ channelMode: config.channelMode, model: config.model, count: mode === "image" ? count : 1 });
|
||||
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
|
||||
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
|
||||
|
||||
@@ -31,7 +31,6 @@ type CanvasNodePromptPanelProps = {
|
||||
|
||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const mode = defaultMode(node.type);
|
||||
@@ -40,7 +39,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
|
||||
const isEditingExistingContent = hasTextContent || hasImageContent;
|
||||
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
|
||||
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? config.count : 1 });
|
||||
const credits = requestCreditCost({ channelMode: config.channelMode, model: config.model, count: mode === "image" ? config.count : 1 });
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
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 { nanoid } from "nanoid";
|
||||
import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
|
||||
@@ -396,7 +396,7 @@ export default function ImagePage() {
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
|
||||
<span className="truncate text-stone-500 dark:text-stone-400">
|
||||
{model} · {effectiveConfig.size} · {effectiveConfig.quality}
|
||||
{modelOptionLabel(effectiveConfig, model)} · {effectiveConfig.size} · {effectiveConfig.quality}
|
||||
</span>
|
||||
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
|
||||
调整
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "antd";
|
||||
import { ArrowLeft, Settings2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useConfigStore } from "@/stores/use-config-store";
|
||||
|
||||
export default function LoginPage() {
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
|
||||
<section className="w-full max-w-[440px] text-center">
|
||||
<span
|
||||
className="mx-auto mb-4 block size-12 bg-stone-950 dark:bg-stone-100"
|
||||
style={{
|
||||
mask: "url(/logo.svg) center / contain no-repeat",
|
||||
WebkitMask: "url(/logo.svg) center / contain no-repeat",
|
||||
}}
|
||||
aria-label="无限画布"
|
||||
/>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-stone-950 dark:text-stone-100">无需账号登录</h1>
|
||||
<p className="mt-3 text-base leading-7 text-stone-500 dark:text-stone-400">当前版本以浏览器本地数据为主,AI 请求使用你在本机配置的 Base URL 和 API Key 前台直连。</p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<Button type="primary" icon={<Settings2 className="size-4" />} onClick={() => openConfigDialog(false)}>
|
||||
打开配置
|
||||
</Button>
|
||||
<Link href="/canvas">
|
||||
<Button icon={<ArrowLeft className="size-4" />}>返回画布</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-stone-200 bg-stone-50 px-3 py-2 text-sm dark:border-stone-800 dark:bg-stone-900 sm:hidden">
|
||||
<span className="truncate text-stone-500 dark:text-stone-400">
|
||||
{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
|
||||
</span>
|
||||
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}>
|
||||
调整
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, LogIn } from "lucide-react";
|
||||
import { Home } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
@@ -14,13 +14,6 @@ export default function NotFound() {
|
||||
<Home className="size-4" />
|
||||
返回首页
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-10 items-center gap-2 rounded-lg border border-stone-200 bg-background px-4 text-sm font-medium text-stone-900 transition hover:bg-stone-100 dark:border-stone-800 dark:text-stone-100 dark:hover:bg-stone-800"
|
||||
>
|
||||
<LogIn className="size-4" />
|
||||
去登录
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -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<AppSyncDomainKey, WebdavDomainProg
|
||||
|
||||
export function AppConfigModal() {
|
||||
const { message } = App.useApp();
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("channels");
|
||||
const [loadingChannelId, setLoadingChannelId] = useState("");
|
||||
const [testingWebdav, setTestingWebdav] = useState(false);
|
||||
const [syncingWebdav, setSyncingWebdav] = useState(false);
|
||||
const [webdavSyncStatus, setWebdavSyncStatus] = useState("");
|
||||
@@ -67,53 +68,80 @@ export function AppConfigModal() {
|
||||
const shouldPromptContinue = useConfigStore((state) => 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<keyof AiConfig>).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<ModelChannel>) => {
|
||||
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={
|
||||
<div>
|
||||
<div className="text-lg font-semibold">配置与用户偏好</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">模型、渠道和画布默认行为</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">渠道聚合、模型选择和同步偏好</div>
|
||||
</div>
|
||||
}
|
||||
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={
|
||||
<Button type="primary" onClick={finishConfig}>
|
||||
完成
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="pt-1">
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="mb-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="text-sm font-semibold">前台直连</div>
|
||||
<div className="mt-1 text-xs leading-5 text-stone-500">AI 请求由浏览器直接发送到你配置的 OpenAI 兼容地址,API Key 只保存在当前浏览器本地。</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="Base URL" className="mb-4">
|
||||
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key" className="mb-4">
|
||||
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mb-5 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">模型列表</div>
|
||||
<div className="mt-1 text-xs text-stone-500">当前已保存 {config.models.length} 个模型</div>
|
||||
</div>
|
||||
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>
|
||||
拉取模型列表
|
||||
</Button>
|
||||
</div>
|
||||
<section className="mb-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-semibold">模型可选项</div>
|
||||
<div className="mt-1 text-xs text-stone-500">可从已拉取模型中选择,也可直接输入模型名后回车。</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{modelGroups.map((group) => (
|
||||
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
|
||||
<Select
|
||||
mode="tags"
|
||||
showSearch
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder={config.models.length ? `请选择或输入${group.optionsLabel}` : "输入模型名,或先拉取模型列表"}
|
||||
value={config[group.modelsKey]}
|
||||
options={modelOptions}
|
||||
onChange={(models) => updateCapabilityModels(group, models)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{modelGroups.map((group) => (
|
||||
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-4">
|
||||
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={15}
|
||||
value={config.canvasImageCount}
|
||||
onChange={(event) => updateConfig("canvasImageCount", event.target.value)}
|
||||
onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="默认音频声音" className="mb-4">
|
||||
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认音频格式" className="mb-4">
|
||||
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认音频语速" className="mb-4">
|
||||
<Input
|
||||
type="number"
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.05}
|
||||
value={config.audioSpeed}
|
||||
onChange={(event) => updateConfig("audioSpeed", event.target.value)}
|
||||
onBlur={(event) => updateConfig("audioSpeed", normalizeAudioSpeedValue(event.target.value))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="默认音频指令" className="mb-4">
|
||||
<Input.TextArea rows={2} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统提示词" className="mb-0">
|
||||
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
|
||||
</Form.Item>
|
||||
<section className="mt-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Cloud className="size-4" />
|
||||
WebDAV 同步
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: "channels",
|
||||
label: "渠道",
|
||||
children: (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">多渠道聚合</div>
|
||||
<div className="mt-1 text-xs text-stone-500">每个渠道保存自己的 Base URL、API Key 和模型列表;模型选择时会显示模型名和渠道名。</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={Boolean(loadingChannelId)} onClick={() => void refreshAllModels()}>
|
||||
拉取全部
|
||||
</Button>
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
|
||||
新增渠道
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-stone-500">同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;服务不支持 CORS 时可走 Next.js 转发。</div>
|
||||
</div>
|
||||
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="连接方式" className="mb-4 md:col-span-2">
|
||||
<Segmented
|
||||
block
|
||||
value={webdav.proxyMode}
|
||||
onChange={(value) => updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)}
|
||||
options={[
|
||||
{ label: "前端直连", value: "direct" },
|
||||
{ label: "Next.js 转发", value: "nextjs" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="WebDAV 地址" className="mb-4">
|
||||
<Input value={webdav.url} placeholder="https://nas.example.com/webdav" onChange={(event) => updateWebdavConfig("url", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="远程目录" extra={`会在该目录下分业务目录保存,每个目录包含 ${WEBDAV_MANIFEST_FILE_NAME} 和 files/`} className="mb-4">
|
||||
<Input value={webdav.directory} placeholder="infinite-canvas" onChange={(event) => updateWebdavConfig("directory", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="用户名" className="mb-0">
|
||||
<Input value={webdav.username} autoComplete="username" onChange={(event) => updateWebdavConfig("username", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="密码 / 应用密码" className="mb-0">
|
||||
<Input.Password value={webdav.password} autoComplete="current-password" onChange={(event) => updateWebdavConfig("password", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button icon={<Wifi className="size-4" />} disabled={!webdavReady || syncingWebdav} loading={testingWebdav} onClick={() => void testWebdav()}>
|
||||
测试连接
|
||||
</Button>
|
||||
<Button type="primary" icon={<RefreshCw className="size-4" />} disabled={!webdavReady || testingWebdav} loading={syncingWebdav} onClick={() => void syncWebdav()}>
|
||||
{syncingWebdav ? "同步中" : "立即同步"}
|
||||
</Button>
|
||||
{webdavSyncStatus ? <span className="text-xs text-stone-500">{webdavSyncStatus}</span> : null}
|
||||
</div>
|
||||
{syncingWebdav || webdavSyncStatus ? (
|
||||
<div className="mt-3 grid gap-2">
|
||||
{webdavDomainKeys.map((key) => {
|
||||
const item = webdavDomainProgress[key];
|
||||
const count = item.total ? `${item.current || 0}/${item.total}` : "";
|
||||
return (
|
||||
<div key={key} className="rounded-md border border-stone-200 px-3 py-2 dark:border-stone-800">
|
||||
<div className="mb-1 flex min-w-0 items-center justify-between gap-3 text-xs">
|
||||
<span className="shrink-0 font-medium text-stone-700 dark:text-stone-200">{item.label}</span>
|
||||
<span className="min-w-0 truncate text-right text-stone-500">
|
||||
{item.stage}
|
||||
{count ? ` · ${count}` : ""}
|
||||
</span>
|
||||
<div className="space-y-3">
|
||||
{config.channels.map((channel) => (
|
||||
<section key={channel.id} className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
|
||||
<div className="mt-1 text-xs text-stone-500">已保存 {channel.models.length} 个模型</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="small" loading={loadingChannelId === channel.id} onClick={() => void refreshChannelModels(channel)}>
|
||||
拉取模型
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
|
||||
</div>
|
||||
</div>
|
||||
<Progress percent={getWebdavProgressPercent(item)} size="small" status={getWebdavProgressStatus(item)} showInfo={false} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="渠道名称" className="mb-0">
|
||||
<Input value={channel.name} onChange={(event) => updateChannel(channel.id, { name: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Base URL" className="mb-0">
|
||||
<Input value={channel.baseUrl} onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key" className="mb-0">
|
||||
<Input.Password value={channel.apiKey} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="模型列表" className="mb-0">
|
||||
<Select mode="tags" showSearch allowClear maxTagCount="responsive" placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models })} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "models",
|
||||
label: "模型",
|
||||
children: (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="mb-4 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="text-sm font-semibold">默认模型和可选项</div>
|
||||
<div className="mt-1 text-xs leading-5 text-stone-500">可选项决定各处下拉框展示哪些模型;同名模型会以括号里的渠道名区分。</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{modelGroups.map((group) => (
|
||||
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
|
||||
<Select
|
||||
mode="tags"
|
||||
showSearch
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder={config.models.length ? `请选择或输入${group.optionsLabel}` : "先到渠道里填写或拉取模型"}
|
||||
value={config[group.modelsKey]}
|
||||
options={modelOptions}
|
||||
onChange={(models) => updateCapabilityModels(group, models)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{modelGroups.map((group) => (
|
||||
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-0">
|
||||
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "preferences",
|
||||
label: "生成偏好",
|
||||
children: (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={15}
|
||||
value={config.canvasImageCount}
|
||||
onChange={(event) => updateConfig("canvasImageCount", event.target.value)}
|
||||
onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="默认音频声音" className="mb-4">
|
||||
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认音频格式" className="mb-4">
|
||||
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认音频语速" className="mb-4">
|
||||
<Input
|
||||
type="number"
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.05}
|
||||
value={config.audioSpeed}
|
||||
onChange={(event) => updateConfig("audioSpeed", event.target.value)}
|
||||
onBlur={(event) => updateConfig("audioSpeed", normalizeAudioSpeedValue(event.target.value))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="默认音频指令" className="mb-4">
|
||||
<Input.TextArea rows={2} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统提示词" className="mb-0">
|
||||
<Input.TextArea rows={4} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "webdav",
|
||||
label: "WebDAV",
|
||||
children: (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<section className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Cloud className="size-4" />
|
||||
WebDAV 同步
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-stone-500">同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;服务不支持 CORS 时可走 Next.js 转发。</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</Form>
|
||||
</div>
|
||||
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="连接方式" className="mb-4 md:col-span-2">
|
||||
<Segmented
|
||||
block
|
||||
value={webdav.proxyMode}
|
||||
onChange={(value) => updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)}
|
||||
options={[
|
||||
{ label: "前端直连", value: "direct" },
|
||||
{ label: "Next.js 转发", value: "nextjs" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="WebDAV 地址" className="mb-4">
|
||||
<Input value={webdav.url} placeholder="https://nas.example.com/webdav" onChange={(event) => updateWebdavConfig("url", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="远程目录" extra={`会在该目录下分业务目录保存,每个目录包含 ${WEBDAV_MANIFEST_FILE_NAME} 和 files/`} className="mb-4">
|
||||
<Input value={webdav.directory} placeholder="infinite-canvas" onChange={(event) => updateWebdavConfig("directory", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="用户名" className="mb-0">
|
||||
<Input value={webdav.username} autoComplete="username" onChange={(event) => updateWebdavConfig("username", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="密码 / 应用密码" className="mb-0">
|
||||
<Input.Password value={webdav.password} autoComplete="current-password" onChange={(event) => updateWebdavConfig("password", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button icon={<Wifi className="size-4" />} disabled={!webdavReady || syncingWebdav} loading={testingWebdav} onClick={() => void testWebdav()}>
|
||||
测试连接
|
||||
</Button>
|
||||
<Button type="primary" icon={<RefreshCw className="size-4" />} disabled={!webdavReady || testingWebdav} loading={syncingWebdav} onClick={() => void syncWebdav()}>
|
||||
{syncingWebdav ? "同步中" : "立即同步"}
|
||||
</Button>
|
||||
{webdavSyncStatus ? <span className="text-xs text-stone-500">{webdavSyncStatus}</span> : null}
|
||||
</div>
|
||||
{syncingWebdav || webdavSyncStatus ? <WebdavProgressGrid progress={webdavDomainProgress} /> : null}
|
||||
</section>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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<AppSyncDomainKey, WebdavDomainProgress> }) {
|
||||
return (
|
||||
<div className="mt-3 grid gap-2">
|
||||
{webdavDomainKeys.map((key) => {
|
||||
const item = progress[key];
|
||||
const count = item.total ? `${item.current || 0}/${item.total}` : "";
|
||||
return (
|
||||
<div key={key} className="rounded-md border border-stone-200 px-3 py-2 dark:border-stone-800">
|
||||
<div className="mb-1 flex min-w-0 items-center justify-between gap-3 text-xs">
|
||||
<span className="shrink-0 font-medium text-stone-700 dark:text-stone-200">{item.label}</span>
|
||||
<span className="min-w-0 truncate text-right text-stone-500">
|
||||
{item.stage}
|
||||
{count ? ` · ${count}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Progress percent={getWebdavProgressPercent(item)} size="small" status={getWebdavProgressStatus(item)} showInfo={false} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
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: <span className="font-medium text-current">{userName}</span> },
|
||||
...(onOpenShortcuts ? [{ key: "shortcuts", icon: <Keyboard className="size-4" />, label: "快捷键", onClick: onOpenShortcuts }] : []),
|
||||
{ type: "divider" },
|
||||
{ key: "logout", icon: <LogOut className="size-4" />, label: "清除本地账户", onClick: logout },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="inline-flex shrink-0 items-center gap-1">
|
||||
@@ -61,28 +42,11 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
|
||||
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
|
||||
<VersionReleaseModal style={versionStyle} />
|
||||
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
|
||||
{!user && onOpenShortcuts ? (
|
||||
{onOpenShortcuts ? (
|
||||
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenShortcuts} aria-label="快捷键" title="快捷键">
|
||||
<Keyboard className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{user ? (
|
||||
<div ref={accountRef}>
|
||||
<Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
|
||||
<button type="button" className="flex size-7 shrink-0 items-center justify-center rounded-full bg-transparent p-0 text-[0] leading-[0] transition" aria-label="账户菜单">
|
||||
<Avatar
|
||||
size={24}
|
||||
src={avatarUrl ? <img src={avatarUrl} alt={userName} referrerPolicy="no-referrer" /> : undefined}
|
||||
alt={userName}
|
||||
className="!flex !items-center !justify-center border border-stone-300 bg-transparent text-[11px] font-semibold text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
|
||||
style={avatarStyle}
|
||||
>
|
||||
{avatarText}
|
||||
</Avatar>
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<ModelIcon model={current} />
|
||||
<span className="canvas-model-picker-text min-w-0 flex-1 truncate text-left">{current || placeholder}</span>
|
||||
<span className="canvas-model-picker-text min-w-0 flex-1 truncate text-left">{current ? modelOptionLabel(config, current) : placeholder}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
data-canvas-no-zoom
|
||||
@@ -69,8 +69,8 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
>
|
||||
{options.length ? (
|
||||
options.map((model) => (
|
||||
<SelectItem key={model} value={model} textValue={model}>
|
||||
<ModelLabel model={model} />
|
||||
<SelectItem key={model} value={model} textValue={modelOptionLabel(config, model)}>
|
||||
<ModelLabel config={config} model={model} />
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
@@ -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 (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ModelIcon model={model} />
|
||||
<span className="truncate">{model}</span>
|
||||
<span className="truncate">{modelOptionLabel(config, model)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelIcon({ model }: { model: string }) {
|
||||
const icon = resolveModelIcon(model);
|
||||
const icon = resolveModelIcon(modelOptionName(model));
|
||||
return icon ? <img src={icon} alt="" className="size-4 shrink-0 dark:invert" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<AiConfig, "model" | "videoModel" | "baseUrl">) {
|
||||
return isSeedanceVideoModel(config.model || config.videoModel) || isArkPlanBaseUrl(config.baseUrl);
|
||||
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "baseUrl">) {
|
||||
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) {
|
||||
|
||||
@@ -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<AdminUser> & { 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<AdminCreditLog>) {
|
||||
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<Prompt>) {
|
||||
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<AdminAsset>) {
|
||||
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: "",
|
||||
};
|
||||
}
|
||||
@@ -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<Blob> {
|
||||
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<Blob>(
|
||||
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) });
|
||||
|
||||
@@ -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: "" };
|
||||
}
|
||||
@@ -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<ImageApiResponse>(
|
||||
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<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
|
||||
const response = await axios.post<ImageApiResponse>(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<string, string>,
|
||||
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<AiConfig, "baseUrl" | "apiKey">) {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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<VideoGenerationTask> {
|
||||
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<VideoGenerationTaskState> {
|
||||
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<UploadedFile> {
|
||||
@@ -71,7 +73,7 @@ export async function storeGeneratedVideo(result: VideoGenerationResult): Promis
|
||||
|
||||
async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[]): Promise<VideoGenerationTask> {
|
||||
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),
|
||||
|
||||
@@ -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: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
|
||||
updateWebdavConfig: <K extends keyof WebdavSyncConfig>(key: K, value: WebdavSyncConfig[K]) => void;
|
||||
loadPublicSettings: () => Promise<void>;
|
||||
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<ConfigStore>()(
|
||||
@@ -154,8 +166,6 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
(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<ConfigStore>()(
|
||||
[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<ConfigStore>()(
|
||||
const persistedConfig = (persistedState.config || {}) as Partial<AiConfig>;
|
||||
const persistedWebdav = (persistedState.webdav || {}) as Partial<WebdavSyncConfig>;
|
||||
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<ConfigStore>()(
|
||||
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<ConfigStore>()(
|
||||
),
|
||||
);
|
||||
|
||||
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>): 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);
|
||||
|
||||
@@ -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<void>;
|
||||
login: (payload: AuthPayload) => Promise<AuthUser>;
|
||||
register: (payload: AuthPayload) => Promise<AuthUser>;
|
||||
export type LocalUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
export const useUserStore = create<UserStore>()(
|
||||
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<UserStore>()((set) => ({
|
||||
user: null,
|
||||
clearSession: () => set({ user: null }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user