Compare commits

...

6 Commits

Author SHA1 Message Date
HouYunFei a78eef73a8 fix(docker): 解决 Docker 构建和运行时版本信息读取失败问题
- 在 Dockerfile 中添加 CHANGELOG.md 文件复制到镜像中
- 修复前端在 Docker 环境下无法读取版本信息的问题
- 更新文档描述以反映构建和运行时的版本信息读取需求
- 调整测试用例描述以匹配实际的构建和运行场景
2026-05-20 16:47:49 +08:00
HouYunFei 7fb53b251c fix(build): 修复 Docker 前端构建阶段版本信息读取问题
- 在 Dockerfile 中补充拷贝 CHANGELOG.md 文件到构建环境
- 避免前端构建时因缺少版本文件导致的信息读取失败
- 更新文档测试用例,确保构建
2026-05-20 16:28:54 +08:00
HouYunFei 8fd4262bb6 chore: release v0.0.5 2026-05-20 16:24:40 +08:00
HouYunFei 7a27684e3c feat(app): 添加版本更新弹窗功能
- 在顶部导航栏和用户状态组件中集成版本更新弹窗
- 实现版本检查钩子,支持检查最新版本和更新日志
- 解析 CHANGELOG.md 文件并展示版本更新时间线
- 添加版本更新弹窗 UI 组件,显示当前版本和最新版本
- 在 Dockerfile 中更新 bun 版本依赖
- 调整 Next.js 配置以解析版本信息并注入环境变量
- 更新待测试文档中的版本更新功能描述
2026-05-20 15:25:57 +08:00
HouYunFei 2ab499bc54 refactor(server): 调整 Docker 运行架构为 Next.js 代理 Go API
- 修改 Dockerfile 中的运行命令,将 Next.js 设为对外服务,Go 服务在内部监听
- 更新端口配置,后端监听端口从 3000 改为 80
- 移除 Go 服务中的反向代理逻辑,统一由 Next.js 处理页面请求
- 修改 Next.js 配置,在生产环境中也启用 API 代理功能
- 更新文档描述,明确 Next.js 作为页面入口,API 请求代理到 Go 服务
- 调整开发环境端口配置,web 开发端口从 3001 改为 3000
- 更新版本号至 v0.0.4 并修改 CHANGELOG
2026-05-20 13:54:15 +08:00
HouYunFei fd533dd261 fix: use clipboard copy helper library 2026-05-20 13:29:12 +08:00
25 changed files with 314 additions and 40 deletions
+5 -2
View File
@@ -6,8 +6,11 @@ ADMIN_PASSWORD=infinite-canvas
JWT_SECRET=infinite-canvas
JWT_EXPIRE_HOURS=168
# 后端对外监听端口,Docker 默认由 Go 统一暴露 3000。
PORT=3000
# 后端监听端口
PORT=8080
# 前端开发代理默认使用 http://127.0.0.1:8080,如后端开发端口不同,启动前端时可单独设置 API_BASE_URL。
# API_BASE_URL=http://127.0.0.1:8080
# 数据库配置,默认使用本地 SQLite。
STORAGE_DRIVER=sqlite
+8
View File
@@ -66,6 +66,14 @@
- 数据库结构写到 `docs/backend-database.md`
- 文档不要写过期日期;除非用户明确要求记录具体时间。
## 发版本流程
- 发版本时,先把 `CHANGELOG.md``Unreleased` 变更整理成新的版本记录,并保留空的 `Unreleased` 标题。
- 按当前版本号提升一个版本,更新根目录 `VERSION`
- 将当前未提交的代码全部提交到 Git。
- 提交完成后,给当前提交打最新版本号对应的 tag,例如 `v0.0.5`
- 发版本流程中不要执行编译、测试或构建,除非用户明确要求。
## 项目注意事项
- 当前画布项目和“我的素材”主要保存在浏览器本地,不要在文档中误写成已支持云同步。
+12
View File
@@ -2,6 +2,18 @@
## Unreleased
+ [修复] Docker 构建和运行阶段补充拷贝 `CHANGELOG.md`,避免版本信息读取失败。
## v0.0.5 - 2026-05-20
+ [新增] 右上角版本号支持点击查看版本更新弹窗,展示当前版本、最新版本和按时间线整理的更新日志。
+ [新增] 设置弹窗支持配置系统提示词,AI 生图、编辑图和文本请求会自动携带。
## v0.0.4 - 2026-05-20
+ [调整] Docker 运行入口改为 Next.js 对外提供页面,`/api/*` 由 Next.js 代理到内部 Go 服务。
+ [修复] 文本复制在局域网 IP 访问时可能失败的问题。
## v0.0.3 - 2026-05-19
+ [修复] 更新 nanoid 依赖并修改 ID 生成方式,防止其他ip无法使用crypto模块导致的ID生成失败问题。
+7 -5
View File
@@ -1,10 +1,11 @@
# 构建 Next.js 前端产物。
FROM oven/bun:1 AS web-build
FROM oven/bun:1.3.13 AS web-build
WORKDIR /app/web
COPY web/package.json web/bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --registry=https://registry.npmmirror.com --cache-dir=/root/.bun/install/cache
COPY VERSION /app/VERSION
COPY CHANGELOG.md /app/CHANGELOG.md
COPY web ./
RUN bun run build
@@ -23,11 +24,12 @@ COPY service ./service
COPY main.go ./
RUN go build -o /server .
# 运行镜像:Go 对外监听 3000Next.js 只在容器内部监听 3001
FROM oven/bun:1
# 运行镜像:Next.js 对外监听 3000Go 只在容器内部监听 8080
FROM oven/bun:1.3.13
WORKDIR /app
COPY VERSION /app/VERSION
COPY CHANGELOG.md /app/CHANGELOG.md
COPY --from=api-build /server /app/server
COPY --from=web-build /app/web /app/web
ENV PROMPT_DATA_DIR=/app/data/prompts
@@ -35,5 +37,5 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates
RUN mkdir -p /app/data/prompts
EXPOSE 3000
# 先启动内部 Next.js,再由 Go 统一处理 /api/* 和页面反代
CMD ["sh", "-c", "cd /app/web && HOSTNAME=0.0.0.0 PORT=3001 bun run start & PORT=3000 /app/server"]
# 先启动内部 Go API,再由 Next.js 提供页面并代理 /api/*。
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && HOSTNAME=0.0.0.0 PORT=3000 bun run start"]
+11
View File
@@ -87,3 +87,14 @@ docker compose -f docker-compose.local.yml up -d --build
## 开源协议
本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。
## Star History
<a href="https://www.star-history.com/?repos=basketikun%2Finfinite-canvas&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=basketikun/infinite-canvas&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=basketikun/infinite-canvas&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=basketikun/infinite-canvas&type=date&legend=top-left" />
</picture>
</a>
+1 -1
View File
@@ -1 +1 @@
v0.0.3
v0.0.5
+1 -1
View File
@@ -152,7 +152,7 @@
## 后端能力
- Gin 提供 API 服务。
- Docker 运行时由 Go 提供统一入口,`/api/*` 直接处理,其它页面请求转到内部 Next.js 服务。
- Docker 运行时由 Next.js 提供页面入口,`/api/*` 请求代理到内部 Go 服务。
- GORM 管理数据库连接和自动迁移。
- 支持 SQLite、MySQL、PostgreSQL。
- 数据库保存用户、提示词分组、提示词和服务器素材。
+4
View File
@@ -1 +1,5 @@
# 待测试
- 点击右上角版本号时,应弹出版本更新弹窗,并展示当前版本、最新版本、检查更新入口和可滚动的版本时间线。
- 设置弹窗中填写系统提示词后,生图、编辑图和画布助手文本请求应携带该提示词。
- Docker 镜像构建和运行时,前端应能正常读取 `CHANGELOG.md` 并完成版本信息注入。
+1 -15
View File
@@ -2,17 +2,12 @@ package router
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
"github.com/basketikun/infinite-canvas/handler"
"github.com/basketikun/infinite-canvas/middleware"
"github.com/gin-gonic/gin"
)
const webBaseURL = "http://127.0.0.1:3001"
func New() *gin.Engine {
router := gin.Default()
router.RedirectTrailingSlash = false
@@ -47,16 +42,7 @@ func New() *gin.Engine {
handler.AdminDeleteAsset(c.Writer, c.Request, c.Param("id"))
})
webURL, _ := url.Parse(webBaseURL)
webProxy := httputil.NewSingleHostReverseProxy(webURL)
router.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
if path == "/api" || strings.HasPrefix(path, "/api/") {
middleware.NotFoundJSON(c)
return
}
webProxy.ServeHTTP(c.Writer, c.Request)
})
router.NoRoute(middleware.NotFoundJSON)
return router
}
+3
View File
@@ -12,6 +12,7 @@
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
@@ -646,6 +647,8 @@
"cookie-signature": ["cookie-signature@1.2.2", "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"copy-to-clipboard": ["copy-to-clipboard@4.0.2", "", {}, "sha512-gklSft7IuhriZKHKpuoA1fpJSLPNgvUMWMo5BlnzAJm0zNKnznoSv23IjtNqclx8eKi6ZcdvFFzYEER/+U1LoQ=="],
"cors": ["cors@2.8.6", "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cosmiconfig": ["cosmiconfig@9.0.1", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.1.tgz", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
+11 -4
View File
@@ -1,25 +1,32 @@
import type { NextConfig } from "next";
import { PHASE_DEVELOPMENT_SERVER } from "next/constants";
import { loadEnvConfig } from "@next/env";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { parseChangelog } from "./src/lib/release-info";
const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:3000";
const webDir = dirname(fileURLToPath(import.meta.url));
const version = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev";
const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev";
const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8");
export default function nextConfig(phase: string): NextConfig {
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
loadEnvConfig(resolve(webDir, ".."), isDev, undefined, true);
const apiBaseUrl = process.env.API_BASE_URL || "http://127.0.0.1:8080";
const releases = parseChangelog(localChangelog);
return {
allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
typescript: {
ignoreBuildErrors: true,
},
env: {
NEXT_PUBLIC_APP_VERSION: version,
NEXT_PUBLIC_APP_VERSION: localVersion,
NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases),
},
async rewrites() {
return isDev ? [{ source: "/api/:path*", destination: `${apiBaseUrl}/api/:path*` }] : [];
return [{ source: "/api/:path*", destination: `${apiBaseUrl}/api/:path*` }];
},
};
}
+2 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack -H 0.0.0.0 -p 3001",
"dev": "next dev --webpack -H 0.0.0.0 -p 3000",
"build": "next build",
"start": "next start"
},
@@ -16,6 +16,7 @@
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
+2 -1
View File
@@ -4,6 +4,7 @@ import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined,
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { App, Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd";
import { useEffect, useState } from "react";
import copy from "copy-to-clipboard";
import type { AdminAsset } from "@/services/api/admin";
import { useAdminAssets } from "../hooks/use-admin-assets";
@@ -33,7 +34,7 @@ export default function AdminAssetsPage() {
}, [editingAsset, form]);
const copyValue = async (value: string) => {
await navigator.clipboard.writeText(value);
copy(value);
message.success("已复制");
};
+2 -1
View File
@@ -4,6 +4,7 @@ import { CopyOutlined, DeleteOutlined, EditOutlined, ExportOutlined, EyeOutlined
import { ProTable, type ProColumns } from "@ant-design/pro-components";
import { App, Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd";
import { useEffect, useState } from "react";
import copy from "copy-to-clipboard";
import type { Prompt } from "@/services/api/prompts";
import { useAdminPrompts } from "../hooks/use-admin-prompts";
@@ -26,7 +27,7 @@ export default function AdminPromptsPage() {
}, [editingPrompt, form]);
const copyPrompt = async (value: string) => {
await navigator.clipboard.writeText(value);
copy(value);
message.success("已复制");
};
+2 -1
View File
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Card, Drawer, Empty, Image, Input, Pagination, Spin, Tag, Typography } from "antd";
import axios from "axios";
import copy from "copy-to-clipboard";
import { cn } from "@/lib/utils";
import { useAssetStore } from "@/stores/use-asset-store";
@@ -77,7 +78,7 @@ export default function AssetLibraryPage() {
};
const copyText = async (value: string) => {
await navigator.clipboard.writeText(value);
copy(value);
message.success("已复制");
};
+2 -1
View File
@@ -3,6 +3,7 @@
import { Copy, Download, PencilLine, Search, Trash2, Upload } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Card, Drawer, Empty, Form, Image, Input, Modal, Pagination, Select, Space, Tag, Typography } from "antd";
import copy from "copy-to-clipboard";
import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils";
import { uploadImage } from "@/services/image-storage";
@@ -139,7 +140,7 @@ export default function AssetsPage() {
const copyText = async (asset: Asset) => {
if (asset.kind !== "text") return;
await navigator.clipboard.writeText(asset.data.content);
copy(asset.data.content);
message.success("文本已复制");
};
@@ -2188,6 +2188,7 @@ function CanvasTopBar({
iconStyle={{ color: theme.node.text }}
gitHubClassName="size-11 text-base"
gitHubStyle={{ color: theme.node.text }}
versionStyle={{ color: theme.node.text }}
avatarStyle={{ borderColor: theme.toolbar.border, color: theme.node.text }}
userLabel={initial}
menuItems={[
+2 -1
View File
@@ -3,6 +3,7 @@
import { FolderPlus, Search } from "lucide-react";
import { type UIEvent, useEffect, useState } from "react";
import { App, Button, Empty, Input, Spin, Tag } from "antd";
import copy from "copy-to-clipboard";
import { PromptCard } from "@/components/prompts/prompt-card";
import { PromptDetailDialog } from "@/components/prompts/prompt-detail-dialog";
@@ -32,7 +33,7 @@ export default function PromptsPage() {
};
const copyPrompt = async (prompt: string) => {
await navigator.clipboard.writeText(prompt);
copy(prompt);
message.success("提示词已复制");
};
+5 -1
View File
@@ -9,6 +9,7 @@ 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 { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools";
import { fetchImageModels } from "@/services/api/image";
@@ -153,7 +154,7 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader =
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/>
<span className="shrink-0 text-xs font-medium text-stone-500 dark:text-stone-400">{appVersion}</span>
<VersionReleaseModal currentVersion={appVersion} />
<GitHubLink />
<Link href="/login" className="text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100">
@@ -226,6 +227,9 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader =
<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>
</Form>
</div>
</Modal>
+4 -1
View File
@@ -7,6 +7,7 @@ import type { ItemType } from "antd/es/menu/interface";
import { GitHubLink } from "@/components/github-link";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { VersionReleaseModal } from "@/components/version-release-modal";
import { cn } from "@/lib/utils";
import type { ThemeName } from "@/stores/use-theme-store";
@@ -27,6 +28,7 @@ type UserStatusActionsProps = {
avatarStyle?: CSSProperties;
gitHubClassName?: string;
gitHubStyle?: CSSProperties;
versionStyle?: CSSProperties;
userLabel?: ReactNode;
iconStyle?: CSSProperties;
};
@@ -48,6 +50,7 @@ export function UserStatusActions({
avatarStyle,
gitHubClassName,
gitHubStyle,
versionStyle,
userLabel,
iconStyle,
}: UserStatusActionsProps) {
@@ -76,7 +79,7 @@ export function UserStatusActions({
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/>
<span className="shrink-0 text-xs font-medium text-stone-500 dark:text-stone-400">{version}</span>
<VersionReleaseModal currentVersion={version} style={versionStyle} />
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
<div ref={accountRef}>
<Dropdown
@@ -0,0 +1,114 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { CSSProperties } from "react";
import { App, Modal, Tag, Timeline } from "antd";
import { useVersionCheck } from "@/hooks/use-version-check";
function getTagColor(type: string) {
if (type === "新增") return "green";
if (type === "修复") return "red";
if (type === "调整") return "blue";
if (type === "文档") return "purple";
return "default";
}
function getReleaseTitle(version: string) {
return version === "Unreleased" ? "未发布" : version;
}
type VersionReleaseModalProps = {
currentVersion: string;
className?: string;
style?: CSSProperties;
};
export function VersionReleaseModal({ currentVersion, className, style }: VersionReleaseModalProps) {
const { message } = App.useApp();
const [open, setOpen] = useState(false);
const { latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck(currentVersion);
const handleCheckLatestRelease = useCallback((showMessage = false) => {
void checkLatestRelease().then((success) => {
if (!showMessage) return;
if (success) message.success("已获取最新版本信息");
else message.error("获取最新版本信息失败");
});
}, [checkLatestRelease, message]);
useEffect(() => {
if (!open) return;
handleCheckLatestRelease();
}, [handleCheckLatestRelease, open]);
return (
<>
<button
type="button"
className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"}
style={style}
onClick={() => setOpen(true)}
title="查看版本更新"
>
<span className="relative inline-flex">
{currentVersion}
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
</span>
</button>
<Modal
title="版本更新"
open={open}
width={680}
centered
footer={null}
onCancel={() => setOpen(false)}
>
<div className="mb-5 grid grid-cols-2 gap-3">
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="text-xs text-stone-500 dark:text-stone-400"></div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{currentVersion}</div>
</div>
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-stone-500 dark:text-stone-400"></div>
<button
type="button"
className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300"
onClick={() => handleCheckLatestRelease(true)}
>
{checking ? "检查中..." : "检查更新"}
</button>
</div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div>
</div>
</div>
<div className="max-h-[56vh] overflow-y-auto pr-2">
<Timeline
items={releases.map((release) => ({
content: (
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{getReleaseTitle(release.version)}</span>
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
<div className="flex min-w-0 items-center gap-1.5">
{release.version === latestVersion ? <Tag color="green"></Tag> : null}
{release.version === currentVersion ? <Tag></Tag> : null}
</div>
</div>
<div className="mt-2 space-y-1.5">
{release.items.map((item, index) => (
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">{item.type}</Tag>
<span className="min-w-0 flex-1">{item.content}</span>
</div>
))}
</div>
</div>
),
}))}
/>
</div>
</Modal>
</>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseChangelog, type ReleaseInfo } from "@/lib/release-info";
const latestVersionUrl = "https://raw.githubusercontent.com/basketikun/infinite-canvas/main/VERSION";
const latestChangelogUrl = "https://raw.githubusercontent.com/basketikun/infinite-canvas/main/CHANGELOG.md";
function readLocalReleases(): ReleaseInfo[] {
try {
return JSON.parse(process.env.NEXT_PUBLIC_APP_RELEASES || "[]");
} catch {
return [];
}
}
function toVersionParts(version: string) {
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
return match ? match.slice(1).map(Number) : null;
}
function isNewerVersion(latestVersion: string, currentVersion: string) {
const latest = toVersionParts(latestVersion);
const current = toVersionParts(currentVersion);
if (!latest || !current) return false;
return latest.some((value, index) => value > current[index] && latest.slice(0, index).every((part, prevIndex) => part === current[prevIndex]));
}
export function useVersionCheck(currentVersion: string) {
const localReleases = useMemo(readLocalReleases, []);
const [latestVersion, setLatestVersion] = useState(currentVersion);
const [releases, setReleases] = useState<ReleaseInfo[]>(localReleases);
const [checking, setChecking] = useState(false);
const hasNewVersion = isNewerVersion(latestVersion, currentVersion);
const checkLatestVersion = useCallback(async () => {
try {
const response = await fetch(latestVersionUrl);
if (!response.ok) return false;
const version = await response.text();
setLatestVersion(version.trim() || currentVersion);
return true;
} catch {
return false;
}
}, [currentVersion]);
const checkLatestRelease = useCallback(async () => {
setChecking(true);
try {
const [versionResponse, changelogResponse] = await Promise.all([
fetch(latestVersionUrl),
fetch(latestChangelogUrl),
]);
if (!versionResponse.ok) throw new Error("版本读取失败");
if (!changelogResponse.ok) throw new Error("更新日志读取失败");
const [version, changelog] = await Promise.all([versionResponse.text(), changelogResponse.text()]);
setLatestVersion(version.trim() || currentVersion);
if (changelog.trim()) setReleases(parseChangelog(changelog));
return true;
} catch {
setLatestVersion(currentVersion);
setReleases(localReleases);
return false;
} finally {
setChecking(false);
}
}, [currentVersion, localReleases]);
useEffect(() => {
void checkLatestVersion();
}, [checkLatestVersion]);
return { latestVersion, releases, checking, hasNewVersion, checkLatestRelease };
}
+2
View File
@@ -4,6 +4,7 @@ export type AiConfig = {
model: string;
imageModel: string;
textModel: string;
systemPrompt: string;
models: string[];
quality: string;
size: string;
@@ -18,6 +19,7 @@ export const defaultConfig: AiConfig = {
model: "gpt-image-2",
imageModel: "gpt-image-2",
textModel: "gpt-5.5",
systemPrompt: "",
models: [],
quality: "auto",
size: "1:1",
+24
View File
@@ -0,0 +1,24 @@
export type ReleaseInfo = {
version: string;
date: string;
items: { type: string; content: string }[];
};
export function parseChangelog(content: string): ReleaseInfo[] {
return content
.split(/^## /m)
.slice(1)
.map((block) => {
const [title = "", ...lines] = block.trim().split("\n");
const [, version = title.trim(), date = ""] = title.match(/^(.+?)(?:\s+-\s+(.+))?$/) || [];
return {
version: version.trim(),
date: date.trim(),
items: lines
.map((line) => line.trim().match(/^\+\s+\[(.+?)\]\s+(.+)$/))
.filter((match): match is RegExpMatchArray => Boolean(match))
.map((match) => ({ type: match[1], content: match[2] })),
};
})
.filter((release) => release.items.length);
}
+14 -4
View File
@@ -7,7 +7,7 @@ import { imageToDataUrl } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
export type ChatCompletionMessage = {
role: "user" | "assistant";
role: "system" | "user" | "assistant";
content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
};
@@ -58,6 +58,16 @@ function parseStreamChunk(chunk: string, onDelta: (value: string) => void) {
if (deltaText) onDelta(deltaText);
}
function withSystemPrompt(config: AiConfig, prompt: string) {
const systemPrompt = config.systemPrompt.trim();
return systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
}
function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[]) {
const systemPrompt = config.systemPrompt.trim();
return systemPrompt ? [{ role: "system" as const, content: systemPrompt }, ...messages] : messages;
}
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 {
@@ -65,7 +75,7 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
buildApiUrl(config.baseUrl, "/images/generations"),
{
model: config.model,
prompt,
prompt: withSystemPrompt(config, prompt),
n,
quality: config.quality || undefined,
size: config.size || undefined,
@@ -88,7 +98,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const formData = new FormData();
formData.set("model", config.model);
formData.set("prompt", prompt);
formData.set("prompt", withSystemPrompt(config, prompt));
formData.set("n", String(n));
formData.set("response_format", "b64_json");
if (config.quality) {
@@ -122,7 +132,7 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
buildApiUrl(config.baseUrl, "/chat/completions"),
{
model: config.model,
messages,
messages: withSystemMessage(config, messages),
stream: true,
},
{