chore(fork): 本仓库定制 - 端口 7380/7381、AGENTS 约定、docker-compose、移除 backend/server
Some checks failed
Publish container images / Quality checks (push) Has been cancelled
Publish container images / Resolve build version (push) Has been cancelled
Publish container images / Build open-ai-canvas-backend (amd64) (push) Has been cancelled
Publish container images / Build open-ai-canvas-backend (arm64) (push) Has been cancelled
Publish container images / Build open-ai-canvas-web (multi) (push) Has been cancelled
Publish container images / Build open-ai-canvas-yingce-agent (multi) (push) Has been cancelled
Publish container images / Publish open-ai-canvas-backend manifest (push) Has been cancelled
Publish container images / Publish open-ai-canvas-web manifest (push) Has been cancelled
Publish container images / Publish open-ai-canvas-yingce-agent manifest (push) Has been cancelled
Publish container images / Publish release assets (push) Has been cancelled

This commit is contained in:
2026-10-13 11:17:32 +08:00
parent 4c1d2b4012
commit 5e5562fe85
24 changed files with 342 additions and 24 deletions

View File

@@ -4,7 +4,7 @@ CANVAS_REGISTRATION_ENABLED=false
CANVAS_ALLOW_PRIVATE_UPSTREAMS=false
# 多个主机使用英文逗号分隔,例如:gateway.internal,192.168.1.10
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS=
CANVAS_CORS_ORIGINS=http://localhost:3000
CANVAS_CORS_ORIGINS=http://localhost:7380
# 服务器对模型上游可访问的地址:本地存储的短时签名链接(Agent 看图、生成任务参考图)由它拼出,
# 内网部署还要把该主机放进 CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS。留空则本地存储无法签出链接。
CANVAS_PUBLIC_BASE_URL=
@@ -37,7 +37,8 @@ CANVAS_UPDATER_SOCKET_DIR=/run/open-ai-canvas-updater
# REDIS_URL=redis://redis:6379/0
# CANVAS_POSTGRES_DATA_PATH=./postgres-data
# CANVAS_REDIS_DATA_PATH=./redis-data
# CANVAS_HTTP_PORT=3000
# 宿主机访问端口;本项目使用 7380 以避免与同机其它工程(默认 3000)冲突
# CANVAS_HTTP_PORT=7380
# 后端日志:info 级别不输出成功的查询请求与 Agent 流式事件;排障时临时改为 debug。
# CANVAS_LOG_LEVEL=info

2
.gitignore vendored
View File

@@ -73,6 +73,8 @@ camera_language_guide.md
# Local backend executable; never commit build output.
backend/cmd/server/server
# 本项目附加:在 backend/ 目录直接 go build 会产出同名 server 二进制(曾误提交 62.7MB)。
/backend/server
# One-off local probe scripts and their compiled output.
backend/tmp_*
# Keep the runtime in source control; its optional debug map is not deployed.

View File

@@ -164,3 +164,96 @@ cd web && bun run dev
- Git 提交说明使用 `<type>(<scope>): <业务模块> - <变更摘要>`,`type` 为 `feat|fix|refactor|perf|docs|test|build|ci|chore|revert`。
交付前至少检查:改动是否聚焦、调用方和类型是否同步、错误/权限/数据归属是否完整、必要文档是否同步、验证是否如实说明、是否留下密钥或本地数据。
---
# 本项目附加约定
以上第 1–9 章继承自上游 `ddcat-ai/open-ai-canvas`。以下为本仓库(`ttstd-infinite-canvas`)在底座之上叠加的约定;**与上游冲突时以本章为准**。
## A. 底座与上游同步
本仓库以 `ddcat-ai/open-ai-canvas` 为底座,通过 Git remote 保持可追溯的上游关系:
- `upstream` → `https://github.com/ddcat-ai/open-ai-canvas.git`(开发机上另存有一份克隆,可作离线 fetch 源)。
- 同步新功能:`git fetch upstream && git merge upstream/main`。冲突优先保留上游实现,本仓库的差异应集中在 B~D 三项。
- **功能一律优先使用上游源码实现**,不要在本仓库另起一套。确需偏离上游时,先在本章登记原因再改代码。
- `libtv.html` 与 `libtv-前端技术分析.md` 是竞品运行时快照与分析资料,属于本地参考,不要当作上游代码改动。
- 分支:本仓库工作分支为 `main`,上游分支也是 `main`(位于 `upstream` remote);同步用 `git fetch upstream && git merge upstream/main`。
## B. 端口约定
**不要改回上游默认的 3000 / 8080**——同机上 `infinite-canvas`、`open-ai-canvas`、`open-canvas` 都占用 3000,会互相抢占。
| 场景 | 前端 | 后端 |
| --- | --- | --- |
| 本地开发(宿主机) | **7380** | **7381** |
| Docker(宿主机映射) | **7380** → 容器 3000 | 不暴露(容器内仍 8080) |
容器内部端口保持上游原样(nginx `listen 3000`、后端 `:8080`),以减小与上游的差异面。改端口时必须同步:
- `web/package.json` — `dev` / `start` 的 `--port`
- `web/vite.config.ts` — `apiProxyTarget` 默认值
- `backend/cmd/server/main.go` — `CANVAS_BACKEND_ADDR` 默认值
- `scripts/start-local.ps1` — 后端地址、代理目标、提示文案
- `docker-compose.yml`、`docker-compose.local.yml` — 宿主机端口映射与 `CANVAS_CORS_ORIGINS`
- `.env.example` — `CANVAS_CORS_ORIGINS`、`CANVAS_HTTP_PORT`
- `README.md` 快速开始
`docker-compose.deploy.yml` / `docker-compose.server.yml` 与 `scripts/install-server*.sh` 面向独立服务器部署,保留上游的 3000 默认值,不做本地化改动。
`scripts/launch-canvas.ps1` 是上游维护者的个人脚本(硬编码 `D:\yingce\...`、`Administrator`、cloudflared 隧道),在本机不可用,**保持原样不要改**。
## C. 许可与商标红线
- 本仓库与上游均为 **MIT**。复制上游代码必须保留其版权声明与 `NOTICE`(声明 fork 自 `basketikun/infinite-canvas`,上游作者权利保留)。
- **`tldraw` 许可非 MIT**:生产环境商用需购买 license key,否则必须保留 "Made with tldraw" 水印。上游已预留 `VITE_TLDRAW_LICENSE_KEY`(构建期)与 `tldraw_license_key`(运行时设置),对外商用前必须就此明确决策。
- **不得新增他人 logo / 商标**:`web/public/icons/` 下的模型厂商图标、根 README 的赞助商与社群素材仅在上游语境下存在,对外分发前必须清理或替换为纯文字。
- **禁止把带 GPL 组件的 ffmpeg 打进分发镜像**:服务端 ffmpeg 使用 LGPL 构建或由用户挂载(`CANVAS_FFMPEG_PATH`)。
- 面向国内公众提供服务时,需处理 AI 生成内容标识、生成式 AI 服务备案与深度合成管理要求。
## D. 进程清理(强制)
验收或调试结束后清理进程时,**只允许按精确 PID 操作**,严禁用命令名、路径片段或宽正则批量匹配后终止。
正确做法:
1. `netstat -ano | Select-String 'LISTENING' | Select-String ':<端口>'` 拿到占用端口的 PID;
2. `Get-Process -Id <pid>` 确认进程名与可执行文件路径确属本项目;
3. `Stop-Process -Id <pid> -Force`;需要连带子进程时用 `taskkill /PID <pid> /T /F`。
禁止的做法(已有实际事故):
- ❌ `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'server\.exe' } | Stop-Process`
曾匹配到 IDE 的 `embeddings-server.exe`,导致误杀开发环境进程。
- ❌ `-match 'vite'` / `-match 'node'` / `-match 'concurrently'` / `-match 'go'`
这些字符串同样会命中编辑器本体、语言服务器,以及同机上其它工程。
补充要求:
- 需要批量清理时,**先只打印**匹配结果(PID、进程名、完整命令行)供人工确认,确认后另起一步执行终止;不要在同一条命令里「查完即杀」。
- 无法确认归属的进程一律不动,交回用户处理。同机存在多个同类工程时尤其要区分(例如 `infinite-canvas/web/.../vite.js` 与本项目的 `vite.js`)。
- 误杀非本项目进程时,必须立即如实告知用户,说明进程名、可能影响与恢复方式,不要轻描淡写。
## E. 上游同步与 `backend/server` 大文件
上游在 `9727656b`(2026-09-14)提交了 `backend/server` —— 一个 62.7 MB 的可执行构建产物(blob `cfba15a0`),且至今仍保留在 `upstream/main` 中。
本仓库曾用 `git filter-repo --path backend/server --invert-paths` 把它从全部历史移除,代价是本地历史与上游彻底断开血缘(不能用 `git merge`,只能「指定基线 + 快照提交」)。2026-10-13 起改为**以上游为基底、保留上游血缘**:`main` 重建为 `upstream/main@e70fe1b9` 的子提交(`d6d6eab5`,内容取当时工作树),随后正常合并 `upstream/main`。旧的「快照同步」规则随之作废。
现状与约束:
- `main` 是 `upstream/main` 的后代,**可以正常 `git merge upstream/main`**。日常同步:
```powershell
git fetch upstream
git merge upstream/main # 常规三方合并;冲突优先保留上游实现,本仓库差异应集中在 B~D 三项
git ls-files backend/server # 必须为空;若因上游改动而重新出现,执行 git rm backend/server 后再提交
git push origin main
```
- **`backend/server` 的 blob 常驻历史**(因为它在上游祖先里),克隆与推送体积会包含它;这是换取上游血缘的必然代价。它已被 `.gitignore` 的 `/backend/server` 忽略,也不在 `main` 的工作树与索引中(`git ls-files backend/server` 为空),因此本地不会被检出。上游将来清理该文件后,直接正常合并即可两全。
- **不要再次运行 `git filter-repo` 或任何历史重写**:那会重新切断与上游的血缘,回到旧的快照同步模式。
- 每次合并后检查 `git ls-files backend/server`;只有上游确实改动该文件时它才会重新进入索引,用 `git rm backend/server` 处理并提交,不要改回 `.gitignore` 的忽略规则。
- 不要用 `git diff --binary` 生成差异补丁:二进制补丁会把该 blob 整段写进补丁文件(实测 43.8 MB)。
- 迁移用的临时分支、bundle 以及 filter-repo 重写前的备份均已在本地清理;本地不再保留旧(filter-repo)历史,需要追溯只能看 `origin`(Gitea)或 `upstream`。

View File

@@ -96,7 +96,7 @@ mkdir -p .local/project-workbench-debug .local/cache/go-build .local/cache/go-mo
# 终端一:后端
cd backend
CANVAS_BACKEND_ADDR=127.0.0.1:8080 \
CANVAS_BACKEND_ADDR=127.0.0.1:7381 \
CANVAS_BACKEND_DATA_DIR=../.local/project-workbench-debug \
GOCACHE=../.local/cache/go-build \
GOMODCACHE=../.local/cache/go-mod \
@@ -108,7 +108,9 @@ bun install --frozen-lockfile
bun run dev
```
打开 <http://localhost:3000>。首次使用时注册管理员账号,并在设置中配置模型渠道。前端默认将 `/api` 代理到 `http://127.0.0.1:8080`;如需修改代理目标,可设置 `VITE_API_PROXY_TARGET`。
打开 <http://localhost:7380>。首次使用时注册管理员账号,并在设置中配置模型渠道。前端默认将 `/api` 代理到 `http://127.0.0.1:7381`;如需修改代理目标,可设置 `VITE_API_PROXY_TARGET`。
> **本项目端口约定**:本地开发使用前端 **7380** / 后端 **7381**,Docker 宿主机端口为 **7380**(容器内仍为上游默认的 3000/8080)。这样可与同机上的 `infinite-canvas`、`open-ai-canvas` 等工程并存。详见 [AGENTS.md](./AGENTS.md) 的「本项目附加约定」。
Windows PowerShell 用户可在仓库根目录执行:

View File

@@ -0,0 +1,8 @@
// 本项目附加文件(fork 专属):不改上游 main.go,仅让该命令启动时读取仓库根 .env。
package main
import "yingce/backend/internal/config"
func init() {
config.LoadDotEnv()
}

View File

@@ -0,0 +1,8 @@
// 本项目附加文件(fork 专属):不改上游 main.go,仅让该命令启动时读取仓库根 .env。
package main
import "yingce/backend/internal/config"
func init() {
config.LoadDotEnv()
}

View File

@@ -0,0 +1,8 @@
// 本项目附加文件(fork 专属):不改上游 main.go,仅让该命令启动时读取仓库根 .env。
package main
import "yingce/backend/internal/config"
func init() {
config.LoadDotEnv()
}

View File

@@ -0,0 +1,8 @@
// 本项目附加文件(fork 专属):不改上游 main.go,仅让该命令启动时读取仓库根 .env。
package main
import "yingce/backend/internal/config"
func init() {
config.LoadDotEnv()
}

View File

@@ -0,0 +1,8 @@
// 本项目附加文件(fork 专属):不改上游 main.go,仅让该命令启动时读取仓库根 .env。
package main
import "yingce/backend/internal/config"
func init() {
config.LoadDotEnv()
}

View File

@@ -0,0 +1,28 @@
// 本项目附加文件(fork 专属):不改上游 main.go,达到同样的启动行为。
// 独立成一个文件后,上游对同目录 main.go 的任何改动都不会再与本文件冲突。
package main
import (
"log"
"os"
"strings"
"yingce/backend/internal/config"
)
func init() {
config.LoadDotEnv()
// 本地开发默认绑定 127.0.0.1:7381(见 AGENTS.md「本项目附加约定」)。
// 显式配置优先,例如 Compose 注入的 CANVAS_BACKEND_ADDR=":8080" 不会被覆盖。
if strings.TrimSpace(os.Getenv("CANVAS_BACKEND_ADDR")) == "" {
_ = os.Setenv("CANVAS_BACKEND_ADDR", "127.0.0.1:7381")
}
// 启动诊断:确认 .env / 环境变量已被读到,避免误用空 DATABASE_URL 时的静默回退。
dbURLState := "empty"
if strings.TrimSpace(os.Getenv("DATABASE_URL")) != "" {
dbURLState = "set"
}
log.Printf("fork-env: CANVAS_BACKEND_ADDR=%s, DATABASE_URL=%s", os.Getenv("CANVAS_BACKEND_ADDR"), dbURLState)
}

View File

@@ -0,0 +1,83 @@
// Package config 提供轻量的运行时配置辅助能力。
//
// 后端只从环境变量读取配置;为方便本地开发(不依赖启动脚本注入),
// 这里提供一个零依赖的 .env 加载器,供 server / migrate-schema 等命令共用。
package config
import (
"bufio"
"log"
"os"
"path/filepath"
"strings"
)
// LoadDotEnv 从若干候选路径读取 .env 并注入进程环境变量。
//
// 规则:
// - 已存在的环境变量优先,不会被覆盖(Docker/脚本注入的值始终生效);
// - 跳过空行与 # 注释,兼容 export 前缀与首尾引号;
// - 找不到 .env 时静默跳过,不影响生产部署。
func LoadDotEnv() {
path := findDotEnvPath()
if path == "" {
return
}
file, err := os.Open(path)
if err != nil {
return
}
defer file.Close()
log.Printf("已从 %s 加载 .env 配置(已存在的环境变量优先,不覆盖)", path)
scanner := bufio.NewScanner(file)
// 连接串可能较长,放宽单行上限
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := strings.TrimPrefix(scanner.Text(), "\ufeff") // 去掉可能的 UTF-8 BOM
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
idx := strings.IndexByte(line, '=')
if idx <= 0 {
continue
}
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
value = value[1 : len(value)-1]
}
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); exists {
continue
}
_ = os.Setenv(key, value)
}
}
// findDotEnvPath 依次查找:显式指定的 CANVAS_ENV_FILE、当前目录、上级目录、上上级目录。
func findDotEnvPath() string {
if explicit := strings.TrimSpace(os.Getenv("CANVAS_ENV_FILE")); explicit != "" {
if info, err := os.Stat(explicit); err == nil && !info.IsDir() {
return explicit
}
}
candidates := []string{
".env",
filepath.Join("..", ".env"),
filepath.Join("..", "..", ".env"),
}
for _, candidate := range candidates {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
return ""
}

Binary file not shown.

View File

@@ -25,7 +25,7 @@ services:
CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false}
CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false}
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-}
CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-http://localhost:3000}
CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-http://localhost:7380}
# 服务器访问地址:Agent 看图与生成参考图都靠它签发上游可取到的短时链接。
CANVAS_PUBLIC_BASE_URL: ${CANVAS_PUBLIC_BASE_URL:-}
CANVAS_LOG_LEVEL: ${CANVAS_LOG_LEVEL:-debug}

View File

@@ -18,7 +18,7 @@ services:
CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false}
CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false}
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-}
CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-http://localhost:3000}
CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-http://localhost:7380}
# 服务器访问地址:Agent 看图与生成参考图都靠它签发上游可取到的短时链接。
CANVAS_PUBLIC_BASE_URL: ${CANVAS_PUBLIC_BASE_URL:-}
ENABLE_PROVIDER_PLUGINS: ${ENABLE_PROVIDER_PLUGINS:-false}
@@ -42,7 +42,7 @@ services:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
- "7380:3000"
volumes:
- web-data:/usr/share/nginx/html
depends_on:

View File

@@ -18,7 +18,7 @@ services:
CANVAS_REGISTRATION_ENABLED: ${CANVAS_REGISTRATION_ENABLED:-false}
CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false}
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-}
CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-http://localhost:3000}
CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-http://localhost:7380}
# 服务器访问地址:Agent 看图与生成参考图都靠它签发上游可取到的短时链接。
CANVAS_PUBLIC_BASE_URL: ${CANVAS_PUBLIC_BASE_URL:-}
CANVAS_LOG_LEVEL: ${CANVAS_LOG_LEVEL:-info}
@@ -35,7 +35,7 @@ services:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
- "7380:3000"
depends_on:
backend:
condition: service_healthy

View File

@@ -2954,6 +2954,9 @@ SSE 现在按 revision 变化发送紧凑快照,无变化只 heartbeat;客
主分支提交前复测:前端 `bun run test` 全量 1925 项通过,生产构建、类型检查、UI 退场 lint 通过;构建仍有大分块提示。同步了技能/工具标签结构与多行连接菜单的源码守护断言。后端 `go test ./...` 全量通过,工具、迁移、资源引用、handler 及工具预览权限/项目封面专项通过。本轮未执行真实模型调用、PostgreSQL 实库迁移或登录态浏览器验收,这些项目继续保留待验收。
画布页本轮改动:区域选择(底部工具栏胶囊切到「区域选择」)下滚轮改为上下平移画布,缩放保留给 Command/Ctrl + 滚轮(含触控板捏合);带 `data-canvas-wheel-scroll` 的节点内部区域仍保持自身滚动。顶栏分享按钮右侧新增「账户与设置」按钮,点击复用画布内「去设置」的 `navigateToSettings({ section: "channels", continueCreation: true })`,进入 `/settings?section=channels&continue=1`(顶部有「返回创作 / 保存并返回」,与模型缺失时的跳转一致,不新增全局配置弹窗)。曾尝试在画布内用抽屉内嵌设置页,因嵌套 Router 报错已移除。类型检查通过;待登录态浏览器验收:区域选择的滚轮方向与实际滚动手感,设置按钮跳转后返回画布时画布内容与滚动位置是否保持,以及设置项保存后画布生成默认值立即生效。
节点面板快捷键:`[data-canvas-node-panel]` 面板内任意位置按 ⌘/Ctrl + Enter 触发面板主按钮(目前只有创作面板的生成按钮带 `data-canvas-panel-submit`),生成中或提示词为空时按钮禁用,快捷键不拦截按键。待验收:文本框内按下不插入换行、输入法组合中不触发、面板内其他输入框与配置面板不被误触发。
## 画布媒体交互与 Agent 循环止损复测(2026-09-23)
本轮针对画布媒体节点、生成任务状态及云端 Agent 重复读取问题完成实现和复测。代码仍未提交,以下结论基于当前工作区的专项测试与隔离本地浏览器环境,不代表已部署生产。

View File

@@ -13,6 +13,39 @@ $dataDir = Join-Path $repoRoot ".local\project-workbench-debug"
$goBuildCache = Join-Path $repoRoot ".local\cache\go-build"
$goModuleCache = Join-Path $repoRoot ".local\cache\go-mod"
# 从仓库根 .env 加载配置(KEY=VALUE,支持 # 注释与空行;不存在则跳过)。
# 后端只读取环境变量,这里把 .env 注入进程环境,子进程(go/bun)自动继承。
$envFile = Join-Path $repoRoot ".env"
if (-not (Test-Path -LiteralPath $envFile)) {
# Windows 常见坑:在记事本等里新建文件常变成 .env.txt
$alt = Join-Path $repoRoot ".env.txt"
if (Test-Path -LiteralPath $alt) { $envFile = $alt }
else { Write-Host "未找到 $envFile,将使用内置默认值(SQLite)。如需 PostgreSQL 请先创建该文件。" -ForegroundColor Yellow }
}
if (Test-Path -LiteralPath $envFile) {
Write-Host "正在从 $envFile 加载配置..." -ForegroundColor Cyan
$loaded = @()
Get-Content -LiteralPath $envFile | ForEach-Object {
$line = $_.Trim()
if ($line -eq "" -or $line.StartsWith("#")) { return }
if ($line -match '^\s*export\s+') { $line = $line -replace '^\s*export\s+', '' }
if ($line -match '^\s*([^=]+)=(.*)$') {
$name = $matches[1].Trim()
$value = $matches[2].Trim()
if ($value.StartsWith('"') -and $value.EndsWith('"')) {
$value = $value.Substring(1, $value.Length - 2)
}
[Environment]::SetEnvironmentVariable($name, $value, [System.EnvironmentVariableTarget]::Process)
$loaded += $name
}
}
Write-Host "已加载变量:$($loaded -join ', ')" -ForegroundColor Cyan
if (-not $env:DATABASE_URL) { Write-Host "警告:DATABASE_URL 为空,后端将回退 SQLite(数据不会同步)。" -ForegroundColor Yellow }
}
# 本地绑定默认值:未显式配置时,后端只监听本机、数据落在本地调试目录
if (-not $env:CANVAS_BACKEND_ADDR) { $env:CANVAS_BACKEND_ADDR = '127.0.0.1:7381' }
if (-not $env:CANVAS_BACKEND_DATA_DIR) { $env:CANVAS_BACKEND_DATA_DIR = $dataDir }
foreach ($commandName in @("go", "bun")) {
if (-not (Get-Command $commandName -ErrorAction SilentlyContinue)) {
throw "未找到 $commandName,请先安装项目要求的运行时。"
@@ -45,7 +78,9 @@ function Test-ListeningPort([int]$Port) {
}
}
foreach ($port in @(3000, 8080)) {
# 本项目本地开发端口为前端 7380 / 后端 7381(见 AGENTS.md「本项目附加约定」)。
# 上游默认的 3000 / 8080 在同机上被其它工程占用,不能作为探测目标。
foreach ($port in @(7380, 7381)) {
if (Test-ListeningPort $port) {
throw "端口 $port 已被占用,请先关闭占用进程后重试。"
}
@@ -70,19 +105,17 @@ $dataDirLiteral = ConvertTo-PowerShellLiteral $dataDir
$backendCommand = @"
`$ErrorActionPreference = 'Stop'
Set-Location -LiteralPath $backendDirLiteral
`$env:CANVAS_BACKEND_ADDR = '127.0.0.1:8080'
`$env:CANVAS_BACKEND_DATA_DIR = $dataDirLiteral
`$env:GOCACHE = $(ConvertTo-PowerShellLiteral $goBuildCache)
`$env:GOMODCACHE = $(ConvertTo-PowerShellLiteral $goModuleCache)
Write-Host '影策后端:http://127.0.0.1:8080' -ForegroundColor Cyan
Write-Host "影策后端:$($env:CANVAS_BACKEND_ADDR)" -ForegroundColor Cyan
go run ./cmd/server
"@
$webCommand = @"
`$ErrorActionPreference = 'Stop'
Set-Location -LiteralPath $webDirLiteral
`$env:VITE_API_PROXY_TARGET = 'http://127.0.0.1:8080'
Write-Host '影策前端:http://localhost:3000' -ForegroundColor Cyan
`$env:VITE_API_PROXY_TARGET = 'http://127.0.0.1:7381'
Write-Host '影策前端:http://localhost:7380' -ForegroundColor Cyan
bun run dev
"@
@@ -91,4 +124,4 @@ $webProcess = Start-Process -FilePath $powerShellPath -WindowStyle Normal -Worki
Write-Host "已打开前后端开发窗口。" -ForegroundColor Green
Write-Host "后端窗口 PID: $($backendProcess.Id);前端窗口 PID: $($webProcess.Id)"
Write-Host "访问 http://localhost:3000;分别在两个窗口按 Ctrl+C 停止服务。"
Write-Host "访问 http://localhost:7380;分别在两个窗口按 Ctrl+C 停止服务。"

View File

@@ -4,14 +4,14 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 3000",
"dev": "vite --host 0.0.0.0 --port 7380",
"build": "tsc --noEmit && vite build",
"pretest": "bun test test/tiptap-dependency-versions.test.ts test/admin-ui-regressions.test.ts test/announcement-prompt-regressions.test.ts test/password-reset.test.ts test/oss-settings.test.ts test/channel-model-editor-form.test.ts test/channel-model-price-tier-form.test.ts test/admin-logical-models.test.ts && bun test test/canvas-grid-viewport.test.tsx",
"typecheck": "tsc --noEmit",
"test:canvas": "bun test test/canvas-media-performance.test.ts test/canvas-node-title-interaction.test.ts test/canvas-spatial-index.test.ts",
"test": "bun test",
"test:previs:e2e": "bun scripts/previs-p0-chrome-e2e.mjs",
"start": "vite preview --host 0.0.0.0 --port 3000",
"start": "vite preview --host 0.0.0.0 --port 7380",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint src test"

View File

@@ -396,6 +396,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
<Button
type="text"
className={`canvas-node-composer-submit canvas-node-composer-submit-canvas ${showCost ? "has-cost" : ""}`}
data-canvas-panel-submit
disabled={isRunning || isSubmitDisabled}
style={
{

View File

@@ -219,6 +219,18 @@ export function CanvasViewport({
event.preventDefault();
interactingRef.current = true;
const current = viewportRef.current;
// 区域选择模式下滚轮用于上下浏览画布,缩放保留给 Ctrl/Meta + 滚轮(触控板捏合)。
if (boxSelectEnabled && !isPinchZoom) {
const shiftHorizontal = event.shiftKey && absX < 1;
scheduleViewportChange({
x: current.x - (shiftHorizontal ? deltaY : deltaX),
y: current.y - (shiftHorizontal ? 0 : deltaY),
k: current.k,
}, true);
return;
}
const rawAbsY = Math.abs(event.deltaY);
const looksLikeMouseWheel = event.deltaMode !== 0 || (rawAbsY >= 80 && Math.abs(rawAbsY - Math.round(rawAbsY / 100) * 100) < 1);
const looksLikeTrackpadPan = !isPinchZoom && (event.shiftKey || absX > 0 || (!looksLikeMouseWheel && absY > 0));
@@ -255,7 +267,7 @@ export function CanvasViewport({
true,
);
},
[containerRef, scheduleViewportChange],
[boxSelectEnabled, containerRef, scheduleViewportChange],
);
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {

View File

@@ -1,5 +1,5 @@
import { motion, useReducedMotion } from "motion/react";
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react";
import { useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject } from "react";
import { Clapperboard, Image as ImageIcon, List, Music2, Pencil, Table2, Video, WandSparkles, Workflow as WorkflowIcon } from "lucide-react";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
@@ -161,6 +161,19 @@ export function CanvasNodePanelOverlay({
};
}, [containerRef, dragOffset?.x, dragOffset?.y, isDragging, node.height, node.id, node.position.x, node.position.y, node.width, panelHeight, panelWidth, viewport]);
// 面板内任意位置按下 ⌘/Ctrl + Enter 时直接提交,避免先把手移回输入框或生成按钮。
// 只有面板自己声明了主按钮([data-canvas-panel-submit])才生效,且按钮禁用时不拦截按键。
const handleSubmitShortcut = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Enter" || event.shiftKey || event.altKey) return;
if (!(event.ctrlKey || event.metaKey)) return;
if (event.nativeEvent.isComposing) return;
const submit = panelRef.current?.querySelector<HTMLButtonElement>("[data-canvas-panel-submit]");
if (!submit || submit.disabled || submit.getAttribute("aria-disabled") === "true") return;
event.preventDefault();
event.stopPropagation();
submit.click();
};
return (
<div
ref={panelRef}
@@ -168,6 +181,7 @@ export function CanvasNodePanelOverlay({
data-canvas-node-panel
className={`thin-scrollbar absolute max-w-[calc(100%_-_24px)] ${allowOverflow ? "overflow-visible" : "overflow-y-auto"}`}
style={{ left: 0, top: 0, transform: `translate3d(${initialPosition.left}px, ${initialPosition.top}px, 0)`, width: initialWidth, maxHeight: allowOverflow ? "none" : "calc(100% - 84px)", zIndex }}
onKeyDownCapture={handleSubmitShortcut}
onMouseDown={(event) => event.stopPropagation()}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}

View File

@@ -109,8 +109,8 @@ export const CANVAS_SHORTCUTS: CanvasShortcutItem[] = [
id: "box-select-tool",
category: "selection",
title: "使用区域选择工具",
description: "底部工具栏的胶囊开关切到区域选择后,空白处拖动可连续框选",
keys: [["区域选择", "拖动"]],
description: "底部工具栏的胶囊开关切到区域选择后,空白处拖动可连续框选;滚轮上下移动画布,缩放请用 Command/Ctrl + 滚轮",
keys: [["区域选择", "拖动"], ["区域选择", "滚轮"]],
keywords: ["工具栏", "多选", "selection"],
},
{

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Clapperboard, CloudDownload, CopyPlus, Focus, FolderKanban, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Share2, Trash2, Undo2, Upload } from "lucide-react";
import { Clapperboard, CloudDownload, CopyPlus, Focus, FolderKanban, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Save, Search, Settings, Share2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Tooltip } from "antd";
import { WorkspaceCreditGiftMark } from "@/components/layout/workspace-credit-gift-mark";
@@ -10,6 +10,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary";
import type { CanvasShortDramaProgress } from "@/lib/canvas/canvas-short-drama";
import { canvasThemes } from "@/lib/canvas-theme";
import { navigateToSettings } from "@/lib/settings-navigation";
import { useCanvasThemeStore } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { CanvasShortcutsModal } from "./canvas-shortcuts-modal";
@@ -236,6 +237,9 @@ export function CanvasTopBar({
<CanvasTopBarTooltip label="分享画布">
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Share2 className="size-4" />} onClick={onShare} aria-label="分享画布" />
</CanvasTopBarTooltip>
<CanvasTopBarTooltip label="账户与设置">
<Button type="text" className="canvas-topbar-action !h-10 !w-10 !min-w-10 !rounded-xl !p-0" style={{ color: theme.node.text }} icon={<Settings className="size-4" />} onClick={() => navigateToSettings({ section: "channels", continueCreation: true })} aria-label="账户与设置" />
</CanvasTopBarTooltip>
</div>
</div>
<CanvasShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />

View File

@@ -9,7 +9,9 @@ const appVersion = process.env.CANVAS_BUILD_VERSION?.trim() || readFileSync(reso
const buildCommit = process.env.CANVAS_BUILD_COMMIT?.trim() || process.env.VITE_BUILD_COMMIT?.trim() || "unknown";
const buildTime = process.env.CANVAS_BUILD_TIME?.trim() || process.env.VITE_BUILD_TIME?.trim() || "unknown";
const appChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8");
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8080";
// 本项目本地开发端口:前端 7380、后端 7381(见 AGENTS.md「本项目附加约定」)。
// 避开上游默认的 3000 / 8080,以便与 infinite-canvas、open-ai-canvas 等同机工程并存。
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET?.trim() || "http://127.0.0.1:7381";
export default defineConfig({
plugins: [react()],