refactor(config): 将AI配置逻辑迁移至统一的状态管理store

- 移除独立的ai-config.ts文件,将其功能整合到use-config-store
- 更新所有组件导入路径从 "@/lib/ai-config" 到 "@/stores/use-config-store"
- 实现云端渠道和本地直连两种配置模式的支持
- 添加模型渠道配置管理和API请求代理转发功能
- 统一配置验证逻辑和有效配置获取方法
- 更新组件中使用的配置状态钩子和API调用方式
This commit is contained in:
HouYunFei
2026-05-21 13:36:30 +08:00
parent afd9631735
commit 6f1e0d347b
23 changed files with 431 additions and 201 deletions
+1
View File
@@ -5,6 +5,7 @@
+ [新增] 管理后台新增系统设置页面,按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON。
+ [调整] 模型渠道配置改为列表和抽屉编辑,新增协议、权重、远程获取模型列表、模型清单测试和批量测试能力。
+ [调整] 公开系统配置中的模型相关字段统一收拢到 `modelChannel` 配置组。
+ [新增] 用户侧配置支持云端渠道和本地直连两种模式,云端模式通过后端渠道代理请求模型接口。
+ [新增] 后端新增 `settings` 表和系统设置接口,支持 public/private 两行 JSON 配置。
+ [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。
+3
View File
@@ -7,6 +7,9 @@
- 公开系统配置中的模型相关字段应保存到 `modelChannel` 配置组下。
- 管理后台系统设置页面应按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON,且可保存 public/private 配置。
- 管理后台系统设置页面的公开配置应把私有配置中已启用渠道的模型列表作为系统可用模型的下拉选项,但系统可用模型仍由管理员手动选择。
- 用户侧配置弹窗应读取公共配置;未允许自定义渠道时固定使用云端渠道,允许时可切换云端渠道和本地直连。
- 根布局初始化组件应在页面刷新后统一加载当前用户信息和公共系统配置。
- 用户侧生图、画布节点生成和画布助手请求应按渠道模式分别走后端代理或浏览器本地直连。
- 管理后台私有配置中的模型渠道应以列表展示、通过抽屉新增编辑,并支持填写权重、远程获取模型列表、模型清单测试和批量测试。
- 管理后台亮色和暗色主题应使用接近 shadcn 的黑白中性色,侧栏、顶部栏、卡片、表格、弹窗和 JSON 编辑器不应再出现偏棕色或默认蓝色主色。
- 全局 Provider 和用户布局精简后,首页、工具页、画布详情页、404 页和管理后台应保持原有导航、主题切换、登录态初始化和数据请求能力。
+8 -1
View File
@@ -35,7 +35,14 @@
| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择 |
| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择 |
| `systemPrompt` | string | 系统提示词 |
| `allowCustomChannel` | boolean | 是否允许用户自定义渠道 |
| `allowCustomChannel` | boolean | 是否允许用户在配置弹窗中切换为本地直连渠道 |
用户侧请求模式:
| 模式 | 说明 |
| --- | --- |
| 云端渠道 | 使用后端 `/api/ai/*` 代理接口,请求会按模型名匹配 `private.value.channels` 中的可用渠道 |
| 本地直连 | 仅 `allowCustomChannel``true` 时可选,用户在浏览器本地配置 `baseUrl``apiKey` 和模型列表后直接请求模型接口 |
## private.value
+113
View File
@@ -0,0 +1,113 @@
package handler
import (
"bytes"
"encoding/json"
"io"
"mime"
"mime/multipart"
"net/http"
"strings"
"github.com/basketikun/infinite-canvas/service"
)
func AIImagesGenerations(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/images/generations")
}
func AIImagesEdits(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/images/edits")
}
func AIChatCompletions(w http.ResponseWriter, r *http.Request) {
proxyAIRequest(w, r, "/chat/completions")
}
func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
body, contentType, modelName, err := readAIRequest(r)
if err != nil {
Fail(w, err.Error())
return
}
channel, err := service.SelectModelChannel(modelName)
if err != nil {
Fail(w, err.Error())
return
}
request, err := http.NewRequest(http.MethodPost, service.BuildModelChannelURL(channel, path), bytes.NewReader(body))
if err != nil {
Fail(w, err.Error())
return
}
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
Fail(w, err.Error())
return
}
defer response.Body.Close()
for key, values := range response.Header {
if strings.EqualFold(key, "Content-Length") {
continue
}
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, response.Body)
}
func readAIRequest(r *http.Request) ([]byte, string, string, error) {
contentType := r.Header.Get("Content-Type")
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, "", "", err
}
modelName := ""
if strings.HasPrefix(contentType, "multipart/form-data") {
modelName = readMultipartModel(body, contentType)
} else {
var payload struct {
Model string `json:"model"`
}
_ = json.Unmarshal(body, &payload)
modelName = payload.Model
}
if strings.TrimSpace(modelName) == "" {
return nil, "", "", errMissingModel
}
return body, contentType, modelName, nil
}
func readMultipartModel(body []byte, contentType string) string {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return ""
}
reader := multipart.NewReader(bytes.NewReader(body), params["boundary"])
form, err := reader.ReadForm(32 << 20)
if err != nil {
return ""
}
defer form.RemoveAll()
if values := form.Value["model"]; len(values) > 0 {
return values[0]
}
return ""
}
var errMissingModel = &aiError{"缺少模型名称"}
type aiError struct {
message string
}
func (err *aiError) Error() string {
return err.message
}
+2
View File
@@ -1,5 +1,7 @@
package model
import "encoding/json"
type SettingKey string
const (
+3
View File
@@ -20,6 +20,9 @@ func New() *gin.Engine {
api.POST("/auth/login", gin.WrapF(handler.Login))
api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser))
api.GET("/settings", gin.WrapF(handler.Settings))
api.POST("/ai/images/generations", gin.WrapF(handler.AIImagesGenerations))
api.POST("/ai/images/edits", gin.WrapF(handler.AIImagesEdits))
api.POST("/ai/chat/completions", gin.WrapF(handler.AIChatCompletions))
api.GET("/prompts", middleware.OptionalAuth, gin.WrapF(handler.Prompts))
api.GET("/assets", middleware.OptionalAuth, gin.WrapF(handler.Assets))
api.POST("/admin/login", gin.WrapF(handler.AdminLogin))
+8
View File
@@ -77,6 +77,14 @@ func SelectModelChannel(modelName string) (model.ModelChannel, error) {
return channels[0], nil
}
func BuildModelChannelURL(channel model.ModelChannel, path string) string {
baseURL := strings.TrimRight(channel.BaseURL, "/")
if !strings.HasSuffix(baseURL, "/v1") {
baseURL += "/v1"
}
return baseURL + path
}
func modelChannelsForModel(channels []model.ModelChannel, modelName string) []model.ModelChannel {
result := []model.ModelChannel{}
for _, channel := range channels {
@@ -6,11 +6,10 @@ import { useParams, useRouter } from "next/navigation";
import { Home, ImageIcon, Images, Keyboard, List, LogOut, Menu, MessageSquare, Plus, Redo2, Settings2, Trash2, Undo2, Upload } from "lucide-react";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { defaultConfig, isAiConfigReady, type AiConfig, useConfigStore, useEffectiveAiConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
import { createId } from "@/lib/id";
import { getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { useAssetStore } from "@/stores/use-asset-store";
@@ -18,7 +17,6 @@ import { useUserStore } from "@/stores/use-user-store";
import { UserStatusActions } from "@/components/user-status-actions";
import { cropDataUrl } from "../utils/canvas-image-data";
import { App, Button, Dropdown, Modal } from "antd";
import { defaultConfig, type AiConfig } from "@/lib/ai-config";
import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants";
import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections";
import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel";
@@ -232,8 +230,9 @@ function InfiniteCanvasPage() {
initialSelectedNodes: [],
});
const config = useAiConfigStore((state) => state.config);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveAiConfig(config);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const addAsset = useAssetStore((state) => state.addAsset);
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
const hydrated = useCanvasStore((state) => state.hydrated);
@@ -479,7 +478,7 @@ function InfiniteCanvasPage() {
}, [message]);
const createConnectedNode = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config, pending: PendingConnectionCreate) => {
const metadata = type === CanvasNodeType.Config ? { model: config.imageModel || config.model, size: config.size, count: 3 } : undefined;
const metadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: 3 } : undefined;
const newNode = createCanvasNode(type, pending.position, metadata);
const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType);
if (!connection) {
@@ -493,7 +492,7 @@ function InfiniteCanvasPage() {
setDialogNodeId(newNode.id);
setPendingConnectionCreate(null);
setConnecting(null);
}, [config.imageModel, config.model, config.size, message, setConnecting]);
}, [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting]);
const cancelPendingConnectionCreate = useCallback(() => {
setPendingConnectionCreate(null);
@@ -591,8 +590,8 @@ function InfiniteCanvasPage() {
(type: CanvasNodeType, position?: Position) => {
const targetPosition = position || getCanvasCenter();
const configMetadata = type === CanvasNodeType.Config ? {
model: config.imageModel || config.model,
size: config.size,
model: effectiveConfig.imageModel || effectiveConfig.model,
size: effectiveConfig.size,
count: 3,
} : undefined;
const newNode = createCanvasNode(type, targetPosition, configMetadata);
@@ -602,7 +601,7 @@ function InfiniteCanvasPage() {
setSelectedConnectionId(null);
setDialogNodeId(newNode.id);
},
[config.imageModel, config.model, config.size, getCanvasCenter],
[effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
);
const deleteNodes = useCallback((ids: Set<string>) => {
@@ -1310,8 +1309,8 @@ function InfiniteCanvasPage() {
const generateAngleNode = useCallback(async (node: CanvasNodeData, params: CanvasImageAngleParams) => {
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(config, node, "image"), count: "1" };
if (!generationConfig.baseUrl.trim() || !generationConfig.model.trim() || !generationConfig.apiKey.trim()) {
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
return;
}
@@ -1344,7 +1343,7 @@ function InfiniteCanvasPage() {
} finally {
setRunningNodeId(null);
}
}, [config, openConfigDialog]);
}, [effectiveConfig, openConfigDialog]);
const handleFontSizeChange = useCallback((nodeId: string, fontSize: number) => {
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, fontSize } } : node)));
@@ -1434,8 +1433,8 @@ function InfiniteCanvasPage() {
const handleGenerateNode = useCallback(
async (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => {
const sourceNode = nodesRef.current.find((node) => node.id === nodeId);
const generationConfig = buildGenerationConfig(config, sourceNode, mode);
if (!generationConfig.baseUrl.trim() || !generationConfig.model.trim() || !generationConfig.apiKey.trim()) {
const generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode);
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
return;
}
@@ -1617,14 +1616,14 @@ function InfiniteCanvasPage() {
setRunningNodeId(null);
}
},
[config, openConfigDialog],
[effectiveConfig, openConfigDialog],
);
const handleRetryNode = useCallback(
async (node: CanvasNodeData) => {
const sourceNode = findRetrySourceNode(node.id, nodesRef.current, connectionsRef.current) || node;
const generationConfig = { ...buildGenerationConfig(config, sourceNode, node.type === CanvasNodeType.Text ? "text" : "image"), count: "1" };
if (!generationConfig.baseUrl.trim() || !generationConfig.model.trim() || !generationConfig.apiKey.trim()) {
const generationConfig = { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : "image"), count: "1" };
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
openConfigDialog(true);
return;
}
@@ -1670,7 +1669,7 @@ function InfiniteCanvasPage() {
setRunningNodeId(null);
}
},
[config, message, openConfigDialog],
[effectiveConfig, message, openConfigDialog],
);
const generateImageFromTextNode = useCallback((node: CanvasNodeData) => {
@@ -1687,8 +1686,8 @@ function InfiniteCanvasPage() {
y: sourceNode.position.y + sourceNode.height / 2,
}, {
prompt: "",
model: config.imageModel || config.model,
size: config.size,
model: effectiveConfig.imageModel || effectiveConfig.model,
size: effectiveConfig.size,
count: 3,
});
const connection = { id: createId(), fromNodeId: sourceNode.id, toNodeId: configNode.id };
@@ -1701,7 +1700,7 @@ function InfiniteCanvasPage() {
setSelectedNodeIds(new Set([configNode.id]));
setSelectedConnectionId(null);
setDialogNodeId(configNode.id);
}, [config.imageModel, config.model, config.size, message]);
}, [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message]);
const insertAssistantImage = useCallback(async (image: CanvasAssistantImage) => {
const storedImage = image.storageKey
@@ -7,15 +7,13 @@ import { motion } from "motion/react";
import { ImageGenerationPending } from "@/components/image-generation-pending";
import { ModelPicker } from "@/components/model-picker";
import type { AiConfig } from "@/lib/ai-config";
import { isAiConfigReady, useConfigStore, useEffectiveAiConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { createId } from "@/lib/id";
import { cn } from "@/lib/utils";
import { requestEdit, requestGeneration, requestImageQuestion, type ChatCompletionMessage } from "@/services/api/image";
import { imageToDataUrl, uploadImage } from "@/services/image-storage";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useAssetStore } from "@/stores/use-asset-store";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ReferenceImage } from "@/types/image";
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
@@ -42,10 +40,11 @@ type CanvasAssistantPanelProps = {
export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeSessionId, onSelectNodeIds, onSessionsChange, onInsertImage, onInsertText, onPasteImage, onCollapseStart, onCollapse }: CanvasAssistantPanelProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const config = useAiConfigStore((state) => state.config);
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveAiConfig(config);
const cleanupImages = useAssetStore((state) => state.cleanupImages);
const updateConfig = useAiConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const updateConfig = useConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const [width, setWidth] = useState(390);
const [view, setView] = useState<"chat" | "history">("chat");
const [mode, setMode] = useState<AssistantMode>("image");
@@ -137,8 +136,8 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
};
const sendMessage = async (text: string, nextMode: AssistantMode, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => {
const requestConfig = { ...config, model: nextMode === "image" ? config.imageModel || config.model : config.textModel || config.model };
if (!requestConfig.baseUrl.trim() || !requestConfig.model.trim() || !requestConfig.apiKey.trim()) {
const requestConfig = { ...effectiveConfig, model: nextMode === "image" ? effectiveConfig.imageModel || effectiveConfig.model : effectiveConfig.textModel || effectiveConfig.model };
if (!isAiConfigReady(requestConfig, requestConfig.model)) {
openConfigDialog(true);
return;
}
@@ -290,7 +289,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
prompt={prompt}
isRunning={isRunning}
references={selectedReferences}
config={config}
config={effectiveConfig}
onModeChange={setMode}
onPromptChange={setPrompt}
onSubmit={submit}
@@ -6,10 +6,8 @@ import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIc
import { App, Button, Empty, Input, InputNumber, Modal, Segmented } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, type AiConfig } from "@/lib/ai-config";
import { defaultConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasSizePicker } from "./canvas-size-picker";
import type { NodeGenerationInput } from "./canvas-node-generation";
@@ -30,8 +28,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
const [previewOpen, setPreviewOpen] = useState(false);
const [editingTextId, setEditingTextId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
const globalConfig = useAiConfigStore((state) => state.config);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const globalConfig = useConfigStore((state) => state.config);
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);
@@ -5,10 +5,8 @@ import { ArrowUp, LoaderCircle } from "lucide-react";
import { Button, InputNumber } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, type AiConfig } from "@/lib/ai-config";
import { defaultConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasSizePicker } from "./canvas-size-picker";
@@ -25,8 +23,8 @@ type CanvasNodePromptPanelProps = {
};
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate }: CanvasNodePromptPanelProps) {
const globalConfig = useAiConfigStore((state) => state.config);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const globalConfig = useConfigStore((state) => state.config);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = defaultMode(node.type);
const config = buildNodeConfig(globalConfig, node, mode);
+12 -13
View File
@@ -8,14 +8,12 @@ import localforage from "localforage";
import { ModelPicker } from "@/components/model-picker";
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal";
import type { AiConfig } from "@/lib/ai-config";
import { isAiConfigReady, useConfigStore, useEffectiveAiConfig, type AiConfig } from "@/stores/use-config-store";
import { createId } from "@/lib/id";
import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
import { requestEdit, requestGeneration } from "@/services/api/image";
import { uploadImage } from "@/services/image-storage";
import { useAssetStore } from "@/stores/use-asset-store";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import type { ReferenceImage } from "@/types/image";
type GeneratedImage = {
@@ -61,9 +59,10 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName
export default function ImagePage() {
const { message } = App.useApp();
const fileInputRef = useRef<HTMLInputElement>(null);
const config = useAiConfigStore((state) => state.config);
const updateConfig = useAiConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const config = useConfigStore((state) => state.config);
const effectiveConfig = useEffectiveAiConfig(config);
const updateConfig = useConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const addAsset = useAssetStore((state) => state.addAsset);
const [prompt, setPrompt] = useState("");
const [references, setReferences] = useState<ReferenceImage[]>([]);
@@ -80,7 +79,7 @@ export default function ImagePage() {
const [previewLog, setPreviewLog] = useState<GenerationLog | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const model = config.imageModel || config.model;
const model = effectiveConfig.imageModel || effectiveConfig.model;
const canGenerate = Boolean(prompt.trim());
const generationCount = Math.max(1, Math.min(10, Number(config.count) || 1));
@@ -128,7 +127,7 @@ export default function ImagePage() {
message.error("请输入生图提示词");
return;
}
if (!config.baseUrl.trim() || !config.apiKey.trim() || !model) {
if (!isAiConfigReady(effectiveConfig, model)) {
message.warning("请先完成配置");
openConfigDialog(true);
return;
@@ -241,12 +240,12 @@ export default function ImagePage() {
message.error("请输入生图提示词");
return null;
}
if (!config.baseUrl.trim() || !config.apiKey.trim() || !model) {
if (!isAiConfigReady(effectiveConfig, model)) {
message.warning("请先完成配置");
openConfigDialog(true);
return null;
}
return { text, config: { ...config, model, count: "1" }, references: [...references] };
return { text, config: { ...effectiveConfig, model, count: "1" }, references: [...references] };
};
const runGenerationSlot = async (index: number, snapshot: { text: string; config: AiConfig; references: ReferenceImage[] }) => {
@@ -337,12 +336,12 @@ export default function ImagePage() {
</div>
<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} · {config.size} · {config.quality}</span>
<span className="truncate text-stone-500 dark:text-stone-400">{model} · {effectiveConfig.size} · {effectiveConfig.quality}</span>
<Button size="small" type="text" icon={<SlidersHorizontal className="size-4" />} onClick={() => setSettingsOpen(true)}></Button>
</div>
<div className="hidden gap-4 sm:grid sm:grid-cols-2">
<GenerationSettings config={config} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div>
</div>
@@ -390,7 +389,7 @@ export default function ImagePage() {
</Drawer>
<Drawer title="参数" placement="bottom" size="default" open={settingsOpen} onClose={() => setSettingsOpen(false)}>
<div className="grid grid-cols-2 gap-3">
<GenerationSettings config={config} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
<GenerationSettings config={effectiveConfig} model={model} updateConfig={updateConfig} openConfigDialog={openConfigDialog} />
</div>
</Drawer>
<PromptSelectDialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen} onSelect={setPrompt} />
+3 -3
View File
@@ -5,12 +5,12 @@ import { usePathname } from "next/navigation";
import { AppTopNav } from "@/components/app-top-nav";
import { type NavigationToolSlug, navigationTools } from "@/lib/navigation-tools";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
import { useConfigStore } from "@/stores/use-config-store";
export default function UserLayout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const config = useAiConfigStore((state) => state.config);
const updateConfig = useAiConfigStore((state) => state.updateConfig);
const config = useConfigStore((state) => state.config);
const updateConfig = useConfigStore((state) => state.updateConfig);
const slug = pathname.split("/").filter(Boolean)[0];
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
+4 -7
View File
@@ -7,9 +7,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
import { ClientRootInit } from "@/components/client-root-init";
import { getAntThemeConfig } from "@/lib/app-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
const queryClient = new QueryClient({
defaultOptions: {
@@ -23,7 +23,6 @@ const queryClient = new QueryClient({
export function AppProviders({ children }: { children: ReactNode }) {
const theme = useThemeStore((state) => state.theme);
const hydrateUser = useUserStore((state) => state.hydrateUser);
const dark = theme === "dark";
useEffect(() => {
@@ -31,15 +30,13 @@ export function AppProviders({ children }: { children: ReactNode }) {
document.documentElement.style.colorScheme = theme;
}, [dark, theme]);
useEffect(() => {
void hydrateUser();
}, [hydrateUser]);
return (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
<ProConfigProvider dark={dark}>
<App>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
<ClientRootInit>{children}</ClientRootInit>
</QueryClientProvider>
</App>
</ProConfigProvider>
</ConfigProvider>
+68 -30
View File
@@ -2,15 +2,14 @@
import { LogOut, Menu, Settings2, Shield } from "lucide-react";
import Link from "next/link";
import { App, Button, Drawer, Form, Input, Modal } from "antd";
import { App, Button, Drawer, Form, Input, Modal, Segmented } from "antd";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { ModelPicker } from "@/components/model-picker";
import { GitHubLink } from "@/components/github-link";
import { UserStatusActions } from "@/components/user-status-actions";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { VersionReleaseModal } from "@/components/version-release-modal";
import type { AiConfig } from "@/lib/ai-config";
import { useConfigStore, type AiConfig } from "@/stores/use-config-store";
import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools";
import { fetchImageModels } from "@/services/api/image";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -30,20 +29,33 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader =
const [loadingModels, setLoadingModels] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
const isConfigOpen = useConfigDialogStore((state) => state.isOpen);
const shouldPromptContinue = useConfigDialogStore((state) => state.shouldPromptContinue);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const setConfigDialogOpen = useConfigDialogStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigDialogStore((state) => state.clearPromptContinue);
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const isReady = useUserStore((state) => state.isReady);
const logout = useUserStore((state) => state.clearSession);
const publicSettings = useConfigStore((state) => state.publicSettings);
const modelChannel = publicSettings?.modelChannel;
const allowCustomChannel = modelChannel?.allowCustomChannel === true;
const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
const modelConfig = effectiveMode === "remote" && modelChannel ? {
...config,
models: modelChannel.availableModels,
imageModel: modelChannel.availableModels.includes(config.imageModel) ? config.imageModel : modelChannel.defaultImageModel || modelChannel.defaultModel,
textModel: modelChannel.availableModels.includes(config.textModel) ? config.textModel : modelChannel.defaultTextModel || modelChannel.defaultModel,
systemPrompt: modelChannel.systemPrompt,
} : config;
const finishConfig = () => {
setConfigDialogOpen(false);
if (!config.baseUrl.trim() || !config.imageModel.trim() || !config.textModel.trim() || !config.apiKey.trim()) return;
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
if (!modelConfig.imageModel.trim() || !modelConfig.textModel.trim()) return;
if (!allowCustomChannel && config.channelMode !== "remote") onConfigChange("channelMode", "remote");
if (shouldPromptContinue) {
message.success("配置已保存,请继续刚才的请求");
} else {
@@ -52,6 +64,7 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader =
clearPromptContinue();
};
const refreshModels = async () => {
if (effectiveMode === "remote") return;
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
message.error("请先填写 Base URL 和 API Key");
return;
@@ -197,35 +210,60 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader =
<Modal
title={<div><div className="text-lg font-semibold"></div><div className="mt-1 text-xs font-normal text-stone-500"></div></div>}
open={isConfigOpen}
width={560}
width={760}
centered
onCancel={() => setConfigDialogOpen(false)}
footer={<Button type="primary" size="large" onClick={finishConfig}></Button>}
>
<div className="pt-1">
<Form layout="vertical" requiredMark={false} size="large">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => onConfigChange("baseUrl", event.target.value)} />
</Form.Item>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => onConfigChange("apiKey", event.target.value)} />
</Form.Item>
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 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>
{allowCustomChannel ? (
<Form.Item label="渠道模式" className="mb-4">
<Segmented
block
value={effectiveMode}
onChange={(value) => onConfigChange("channelMode", value as AiConfig["channelMode"])}
options={[{ label: "云端渠道", value: "remote" }, { label: "本地直连", value: "local" }]}
/>
</Form.Item>
) : null}
{effectiveMode === "local" ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => onConfigChange("baseUrl", event.target.value)} />
</Form.Item>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => onConfigChange("apiKey", event.target.value)} />
</Form.Item>
</div>
<div className="mb-4 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 loading={loadingModels} onClick={() => void refreshModels()}></Button>
</div>
</>
) : (
<div className="mb-4 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
<div className="font-medium text-stone-900 dark:text-stone-100"></div>
<div className="mt-1"> {modelChannel?.availableModels.length || 0} </div>
</div>
<Button loading={loadingModels} onClick={() => void refreshModels()}></Button>
)}
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="默认生图模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => onConfigChange("imageModel", model)} fullWidth />
</Form.Item>
<Form.Item label="默认文本模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => onConfigChange("textModel", model)} fullWidth />
</Form.Item>
</div>
<Form.Item label="默认生图模型" className="mb-4">
<ModelPicker config={config} value={config.imageModel} onChange={(model) => onConfigChange("imageModel", model)} fullWidth />
</Form.Item>
<Form.Item label="默认文本模型" className="mb-0">
<ModelPicker config={config} value={config.textModel} onChange={(model) => onConfigChange("textModel", model)} fullWidth />
</Form.Item>
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={4} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => onConfigChange("systemPrompt", event.target.value)} />
</Form.Item>
{effectiveMode === "local" ? (
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => onConfigChange("systemPrompt", event.target.value)} />
</Form.Item>
) : null}
</Form>
</div>
</Modal>
+25
View File
@@ -0,0 +1,25 @@
"use client";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { usePathname } from "next/navigation";
import { useConfigStore } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
export function ClientRootInit({ children }: { children: ReactNode }) {
const pathname = usePathname();
const hydrateUser = useUserStore((state) => state.hydrateUser);
const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings);
const isLoginPage = pathname === "/login" || pathname === "/admin/login";
useEffect(() => {
void loadPublicSettings();
}, [loadPublicSettings]);
useEffect(() => {
if (!isLoginPage) void hydrateUser();
}, [hydrateUser, isLoginPage]);
return <>{children}</>;
}
+1 -1
View File
@@ -2,7 +2,7 @@
import { Select } from "antd";
import type { AiConfig } from "@/lib/ai-config";
import type { AiConfig } from "@/stores/use-config-store";
type ModelPickerProps = {
config: AiConfig;
-37
View File
@@ -1,37 +0,0 @@
export type AiConfig = {
baseUrl: string;
apiKey: string;
model: string;
imageModel: string;
textModel: string;
systemPrompt: string;
models: string[];
quality: string;
size: string;
count: string;
};
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
export const defaultConfig: AiConfig = {
baseUrl: "https://api.openai.com",
apiKey: "",
model: "gpt-image-2",
imageModel: "gpt-image-2",
textModel: "gpt-5.5",
systemPrompt: "",
models: [],
quality: "auto",
size: "1:1",
count: "1",
};
export function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, "");
}
export function buildApiUrl(baseUrl: string, path: string) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const apiBaseUrl = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
return `${apiBaseUrl}${path}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { apiDelete, apiGet, apiPost, compactApiParams } from "@/services/api/request";
import type { Prompt, PromptListResponse } from "@/services/api/prompts";
import { buildApiUrl } from "@/lib/ai-config";
import { buildApiUrl } from "@/stores/use-config-store";
import axios from "axios";
export type AdminPromptCategory = {
+21 -15
View File
@@ -1,6 +1,6 @@
import axios from "axios";
import { buildApiUrl, type AiConfig } from "@/lib/ai-config";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { createId } from "@/lib/id";
import { dataUrlToFile } from "@/lib/image-utils";
import { imageToDataUrl } from "@/services/image-storage";
@@ -63,6 +63,19 @@ function withSystemPrompt(config: AiConfig, prompt: string) {
return systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
}
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/ai${path}` : buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
return config.channelMode === "remote"
? contentType ? { "Content-Type": contentType } : undefined
: {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[]) {
const systemPrompt = config.systemPrompt.trim();
return systemPrompt ? [{ role: "system" as const, content: systemPrompt }, ...messages] : messages;
@@ -72,7 +85,7 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
try {
const response = await axios.post<ImageApiResponse>(
buildApiUrl(config.baseUrl, "/images/generations"),
aiApiUrl(config, "/images/generations"),
{
model: config.model,
prompt: withSystemPrompt(config, prompt),
@@ -82,10 +95,7 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
response_format: "b64_json",
},
{
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
headers: aiHeaders(config, "application/json"),
},
);
return parseImagePayload(response.data);
@@ -111,11 +121,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
files.forEach((file) => formData.append("image", file));
try {
const response = await axios.post<ImageApiResponse>(buildApiUrl(config.baseUrl, "/images/edits"), formData, {
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
return parseImagePayload(response.data);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
@@ -129,7 +135,7 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
try {
await axios.post(
buildApiUrl(config.baseUrl, "/chat/completions"),
aiApiUrl(config, "/chat/completions"),
{
model: config.model,
messages: withSystemMessage(config, messages),
@@ -137,9 +143,8 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
},
{
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
...aiHeaders(config, "application/json"),
} as Record<string, string>,
responseType: "text",
onDownloadProgress: (event) => {
const responseText = String(event.event?.target?.responseText || "");
@@ -170,6 +175,7 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
}
export async function fetchImageModels(config: AiConfig) {
if (config.channelMode === "remote") return config.models;
try {
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
headers: {
-31
View File
@@ -1,31 +0,0 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { CONFIG_STORE_KEY, defaultConfig, type AiConfig } from "@/lib/ai-config";
type AiConfigStore = {
config: AiConfig;
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
};
export const useAiConfigStore = create<AiConfigStore>()(
persist(
(set) => ({
config: defaultConfig,
updateConfig: (key, value) =>
set((state) => ({
config: {
...state.config,
[key]: value,
},
})),
}),
{
name: CONFIG_STORE_KEY,
merge: (persisted, current) => {
const config = { ...defaultConfig, ...((persisted as Partial<AiConfigStore>).config || {}) };
return { ...current, config: { ...config, imageModel: config.imageModel || config.model, textModel: config.textModel || config.model } };
},
},
),
);
-21
View File
@@ -1,21 +0,0 @@
import { create } from "zustand";
type ConfigDialogStore = {
isOpen: boolean;
shouldPromptContinue: boolean;
openConfigDialog: (shouldPromptContinue?: boolean) => void;
setConfigDialogOpen: (isOpen: boolean) => void;
clearPromptContinue: () => void;
};
export const useConfigDialogStore = create<ConfigDialogStore>()((set) => ({
isOpen: false,
shouldPromptContinue: false,
openConfigDialog: (shouldPromptContinue = false) =>
set({
isOpen: true,
shouldPromptContinue,
}),
setConfigDialogOpen: (isOpen) => set({ isOpen }),
clearPromptContinue: () => set({ shouldPromptContinue: false }),
}));
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useMemo } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { apiGet } from "@/services/api/request";
import type { AdminPublicSettings } from "@/services/api/admin";
export type AiConfig = {
channelMode: "remote" | "local";
baseUrl: string;
apiKey: string;
model: string;
imageModel: string;
textModel: string;
systemPrompt: string;
models: string[];
quality: string;
size: string;
count: string;
};
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
export const defaultConfig: AiConfig = {
channelMode: "remote",
baseUrl: "https://api.openai.com",
apiKey: "",
model: "gpt-image-2",
imageModel: "gpt-image-2",
textModel: "gpt-5.5",
systemPrompt: "",
models: [],
quality: "auto",
size: "1:1",
count: "1",
};
type ConfigStore = {
config: AiConfig;
publicSettings: AdminPublicSettings | null;
isPublicSettingsLoading: boolean;
isConfigOpen: boolean;
shouldPromptContinue: boolean;
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
loadPublicSettings: () => Promise<void>;
openConfigDialog: (shouldPromptContinue?: boolean) => void;
setConfigDialogOpen: (isOpen: boolean) => void;
clearPromptContinue: () => void;
};
export const useConfigStore = create<ConfigStore>()(
persist(
(set, get) => ({
config: defaultConfig,
publicSettings: null,
isPublicSettingsLoading: false,
isConfigOpen: false,
shouldPromptContinue: false,
updateConfig: (key, value) =>
set((state) => ({
config: {
...state.config,
[key]: value,
},
})),
loadPublicSettings: async () => {
if (get().isPublicSettingsLoading) return;
set({ isPublicSettingsLoading: true });
try {
set({ publicSettings: await apiGet<AdminPublicSettings>("/api/settings") });
} finally {
set({ isPublicSettingsLoading: false });
}
},
openConfigDialog: (shouldPromptContinue = false) => set({ isConfigOpen: true, shouldPromptContinue }),
setConfigDialogOpen: (isConfigOpen) => set({ isConfigOpen }),
clearPromptContinue: () => set({ shouldPromptContinue: false }),
}),
{
name: CONFIG_STORE_KEY,
partialize: (state) => ({ config: state.config }),
merge: (persisted, current) => {
const config = { ...defaultConfig, ...((persisted as Partial<ConfigStore>).config || {}) };
return { ...current, config: { ...config, channelMode: config.channelMode || "remote", imageModel: config.imageModel || config.model, textModel: config.textModel || config.model } };
},
},
),
);
export function useEffectiveAiConfig(config: AiConfig) {
const modelChannel = useConfigStore((state) => state.publicSettings?.modelChannel);
return useMemo(() => {
const channelMode = modelChannel?.allowCustomChannel ? config.channelMode : "remote";
if (channelMode === "local" || !modelChannel) return { ...config, channelMode };
const models = modelChannel.availableModels;
return {
...config,
channelMode,
models,
model: models.includes(config.model) ? config.model : modelChannel.defaultModel,
imageModel: models.includes(config.imageModel) ? config.imageModel : modelChannel.defaultImageModel || modelChannel.defaultModel,
textModel: models.includes(config.textModel) ? config.textModel : modelChannel.defaultTextModel || modelChannel.defaultModel,
systemPrompt: modelChannel.systemPrompt,
};
}, [config, modelChannel]);
}
export function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, "");
}
export function buildApiUrl(baseUrl: string, path: string) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
const apiBaseUrl = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
return `${apiBaseUrl}${path}`;
}
export function isAiConfigReady(config: AiConfig, model: string) {
return Boolean(model.trim()) && (config.channelMode === "remote" || Boolean(config.baseUrl.trim() && config.apiKey.trim()));
}