diff --git a/CHANGELOG.md b/CHANGELOG.md index e724cfd..9483211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ + [新增] 管理后台新增系统设置页面,按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON。 + [调整] 模型渠道配置改为列表和抽屉编辑,新增协议、权重、远程获取模型列表、模型清单测试和批量测试能力。 + [调整] 公开系统配置中的模型相关字段统一收拢到 `modelChannel` 配置组。 ++ [新增] 用户侧配置支持云端渠道和本地直连两种模式,云端模式通过后端渠道代理请求模型接口。 + [新增] 后端新增 `settings` 表和系统设置接口,支持 public/private 两行 JSON 配置。 + [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。 diff --git a/docs/pending-test.md b/docs/pending-test.md index 5f0d81a..3a6cb1a 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -7,6 +7,9 @@ - 公开系统配置中的模型相关字段应保存到 `modelChannel` 配置组下。 - 管理后台系统设置页面应按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON,且可保存 public/private 配置。 - 管理后台系统设置页面的公开配置应把私有配置中已启用渠道的模型列表作为系统可用模型的下拉选项,但系统可用模型仍由管理员手动选择。 +- 用户侧配置弹窗应读取公共配置;未允许自定义渠道时固定使用云端渠道,允许时可切换云端渠道和本地直连。 +- 根布局初始化组件应在页面刷新后统一加载当前用户信息和公共系统配置。 +- 用户侧生图、画布节点生成和画布助手请求应按渠道模式分别走后端代理或浏览器本地直连。 - 管理后台私有配置中的模型渠道应以列表展示、通过抽屉新增编辑,并支持填写权重、远程获取模型列表、模型清单测试和批量测试。 - 管理后台亮色和暗色主题应使用接近 shadcn 的黑白中性色,侧栏、顶部栏、卡片、表格、弹窗和 JSON 编辑器不应再出现偏棕色或默认蓝色主色。 - 全局 Provider 和用户布局精简后,首页、工具页、画布详情页、404 页和管理后台应保持原有导航、主题切换、登录态初始化和数据请求能力。 diff --git a/docs/system-settings.md b/docs/system-settings.md index 8e03cd3..6026f8e 100644 --- a/docs/system-settings.md +++ b/docs/system-settings.md @@ -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 diff --git a/handler/ai.go b/handler/ai.go new file mode 100644 index 0000000..a7352a6 --- /dev/null +++ b/handler/ai.go @@ -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 +} diff --git a/model/setting.go b/model/setting.go index 7faf4dc..636840e 100644 --- a/model/setting.go +++ b/model/setting.go @@ -1,5 +1,7 @@ package model +import "encoding/json" + type SettingKey string const ( diff --git a/router/router.go b/router/router.go index 3dc2ed2..5e41d21 100644 --- a/router/router.go +++ b/router/router.go @@ -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)) diff --git a/service/settings.go b/service/settings.go index c3dc503..f8aa7bf 100644 --- a/service/settings.go +++ b/service/settings.go @@ -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 { diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 68a0687..18f1b21 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -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) => { @@ -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 diff --git a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx index de2de24..bf2e0a6 100644 --- a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx @@ -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("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} diff --git a/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx b/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx index 36235ed..2d0ad99 100644 --- a/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-config-node-panel.tsx @@ -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(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); diff --git a/web/src/app/(user)/canvas/components/canvas-node-prompt-panel.tsx b/web/src/app/(user)/canvas/components/canvas-node-prompt-panel.tsx index f2fb072..84087f5 100644 --- a/web/src/app/(user)/canvas/components/canvas-node-prompt-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-node-prompt-panel.tsx @@ -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); diff --git a/web/src/app/(user)/image/page.tsx b/web/src/app/(user)/image/page.tsx index 16a88f5..ae9d361 100644 --- a/web/src/app/(user)/image/page.tsx +++ b/web/src/app/(user)/image/page.tsx @@ -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(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([]); @@ -80,7 +79,7 @@ export default function ImagePage() { const [previewLog, setPreviewLog] = useState(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() {
- {model} · {config.size} · {config.quality} + {model} · {effectiveConfig.size} · {effectiveConfig.quality}
- +
@@ -390,7 +389,7 @@ export default function ImagePage() { setSettingsOpen(false)}>
- +
diff --git a/web/src/app/(user)/layout.tsx b/web/src/app/(user)/layout.tsx index 08f7d52..25bd868 100644 --- a/web/src/app/(user)/layout.tsx +++ b/web/src/app/(user)/layout.tsx @@ -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; diff --git a/web/src/components/app-providers.tsx b/web/src/components/app-providers.tsx index 9076a84..9c4b5b5 100644 --- a/web/src/components/app-providers.tsx +++ b/web/src/components/app-providers.tsx @@ -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 ( - {children} + + {children} + diff --git a/web/src/components/app-top-nav.tsx b/web/src/components/app-top-nav.tsx index afadba0..1c51272 100644 --- a/web/src/components/app-top-nav.tsx +++ b/web/src/components/app-top-nav.tsx @@ -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 =
配置
模型和密钥
} open={isConfigOpen} - width={560} + width={760} centered onCancel={() => setConfigDialogOpen(false)} footer={} >
- - onConfigChange("baseUrl", event.target.value)} /> - - - onConfigChange("apiKey", event.target.value)} /> - -
-
-
模型列表
-
当前已保存 {config.models.length} 个模型
+ {allowCustomChannel ? ( + + onConfigChange("channelMode", value as AiConfig["channelMode"])} + options={[{ label: "云端渠道", value: "remote" }, { label: "本地直连", value: "local" }]} + /> + + ) : null} + {effectiveMode === "local" ? ( + <> +
+ + onConfigChange("baseUrl", event.target.value)} /> + + + onConfigChange("apiKey", event.target.value)} /> + +
+
+
+
模型列表
+
当前已保存 {config.models.length} 个模型
+
+ +
+ + ) : ( +
+
云端渠道
+
由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。
- + )} +
+ + onConfigChange("imageModel", model)} fullWidth /> + + + onConfigChange("textModel", model)} fullWidth /> +
- - onConfigChange("imageModel", model)} fullWidth /> - - - onConfigChange("textModel", model)} fullWidth /> - - - onConfigChange("systemPrompt", event.target.value)} /> - + {effectiveMode === "local" ? ( + + onConfigChange("systemPrompt", event.target.value)} /> + + ) : null}
diff --git a/web/src/components/client-root-init.tsx b/web/src/components/client-root-init.tsx new file mode 100644 index 0000000..97a2d3e --- /dev/null +++ b/web/src/components/client-root-init.tsx @@ -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}; +} diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index ff59d80..8cfd550 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -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; diff --git a/web/src/lib/ai-config.ts b/web/src/lib/ai-config.ts deleted file mode 100644 index bde9edf..0000000 --- a/web/src/lib/ai-config.ts +++ /dev/null @@ -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}`; -} diff --git a/web/src/services/api/admin.ts b/web/src/services/api/admin.ts index ab29ea5..3fc7dc1 100644 --- a/web/src/services/api/admin.ts +++ b/web/src/services/api/admin.ts @@ -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 = { diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 70276a5..d86e343 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -1,6 +1,6 @@ import axios from "axios"; -import { buildApiUrl, type AiConfig } from "@/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( - 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(buildApiUrl(config.baseUrl, "/images/edits"), formData, { - headers: { - Authorization: `Bearer ${config.apiKey}`, - }, - }); + const response = await axios.post(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, 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: { diff --git a/web/src/stores/use-ai-config-store.ts b/web/src/stores/use-ai-config-store.ts deleted file mode 100644 index 4e3cfa1..0000000 --- a/web/src/stores/use-ai-config-store.ts +++ /dev/null @@ -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: (key: K, value: AiConfig[K]) => void; -}; - -export const useAiConfigStore = create()( - 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).config || {}) }; - return { ...current, config: { ...config, imageModel: config.imageModel || config.model, textModel: config.textModel || config.model } }; - }, - }, - ), -); diff --git a/web/src/stores/use-config-dialog-store.ts b/web/src/stores/use-config-dialog-store.ts deleted file mode 100644 index adcf2c7..0000000 --- a/web/src/stores/use-config-dialog-store.ts +++ /dev/null @@ -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()((set) => ({ - isOpen: false, - shouldPromptContinue: false, - openConfigDialog: (shouldPromptContinue = false) => - set({ - isOpen: true, - shouldPromptContinue, - }), - setConfigDialogOpen: (isOpen) => set({ isOpen }), - clearPromptContinue: () => set({ shouldPromptContinue: false }), -})); diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts new file mode 100644 index 0000000..af68e1c --- /dev/null +++ b/web/src/stores/use-config-store.ts @@ -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: (key: K, value: AiConfig[K]) => void; + loadPublicSettings: () => Promise; + openConfigDialog: (shouldPromptContinue?: boolean) => void; + setConfigDialogOpen: (isOpen: boolean) => void; + clearPromptContinue: () => void; +}; + +export const useConfigStore = create()( + 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("/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).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())); +}