fix(*): 平台稳定性 - 修复会话隔离、运行时缓存与部署镜像更新链路
This commit is contained in:
@@ -13,8 +13,12 @@ COPY assets /app/assets
|
||||
COPY web ./
|
||||
ARG VITE_TLDRAW_LICENSE_KEY
|
||||
ARG BUILD_VERSION
|
||||
ARG BUILD_COMMIT=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
ENV VITE_TLDRAW_LICENSE_KEY=${VITE_TLDRAW_LICENSE_KEY}
|
||||
ENV CANVAS_BUILD_VERSION=${BUILD_VERSION}
|
||||
ENV CANVAS_BUILD_COMMIT=${BUILD_COMMIT}
|
||||
ENV CANVAS_BUILD_TIME=${BUILD_TIME}
|
||||
# 生产镜像只构建云端工作台前端;Agent Runtime 在后端 Worker 中运行。
|
||||
RUN bun --bun ./node_modules/vite/bin/vite.js build
|
||||
|
||||
|
||||
@@ -151,10 +151,11 @@ sudo docker compose --env-file .env \
|
||||
不需要源码时,可使用镜像部署脚本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-server-image.sh | sudo bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-server-image.sh \
|
||||
| sudo env CANVAS_IMAGE_TAG=v1.5.7.1 bash
|
||||
```
|
||||
|
||||
生产环境请在 `/opt/open-ai-canvas/.env` 中将 `CANVAS_IMAGE_TAG` 固定为具体 Release,不要使用 `latest`。更新流程、数据库迁移、备份和回退说明见[系统更新文档](docs/content/docs/backend/system-update.mdx)。
|
||||
脚本会先按指定 Release 拉取镜像,再解析并写入 `CANVAS_BACKEND_IMAGE`、`CANVAS_WEB_IMAGE` 的 GHCR digest;生产 Compose 不接受缺失 digest 的默认镜像或 `latest`。更新流程、数据库迁移、备份和回退说明见[系统更新文档](docs/content/docs/backend/system-update.mdx)。
|
||||
|
||||
## 安全边界
|
||||
|
||||
|
||||
@@ -188,6 +188,18 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channelIDs := make([]string, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
channelIDs = append(channelIDs, channel.ID)
|
||||
}
|
||||
channelModels, err := s.repo.ChannelModelReferences(channelIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modelsByChannel := make(map[string][]model.ChannelModel, len(channels))
|
||||
for _, item := range channelModels {
|
||||
modelsByChannel[item.ChannelID] = append(modelsByChannel[item.ChannelID], item)
|
||||
}
|
||||
result := &AdminReferenceData{
|
||||
Users: make([]AdminUserReference, 0, len(users)),
|
||||
Channels: make([]AdminChannelReference, 0, len(channels)),
|
||||
@@ -196,10 +208,7 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error
|
||||
result.Users = append(result.Users, AdminUserReference{ID: user.ID, Username: user.Username, DisplayName: user.DisplayName})
|
||||
}
|
||||
for _, channel := range channels {
|
||||
items, itemErr := s.repo.ChannelModels(channel.ID, true)
|
||||
if itemErr != nil {
|
||||
return nil, itemErr
|
||||
}
|
||||
items := modelsByChannel[channel.ID]
|
||||
models := make([]string, 0, len(items))
|
||||
displayNames := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestCachedTextReplayIsolatesUsersCursorsAndCopies(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeConcurrencyCacheDoesNotCacheAuthoritativePolicy(t *testing.T) {
|
||||
func TestRuntimeConcurrencyCacheCachesAuthoritativePolicy(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -101,8 +101,8 @@ func TestRuntimeConcurrencyCacheDoesNotCacheAuthoritativePolicy(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if queries.Load() != before+2 {
|
||||
t.Fatal("authoritative policy incorrectly cached")
|
||||
if queries.Load() != before {
|
||||
t.Fatalf("authoritative policy cache missed: queries=%d before=%d", queries.Load(), before)
|
||||
}
|
||||
policy := defaultRuntimePolicy()
|
||||
policy.Task.WorkerConcurrency = 7
|
||||
|
||||
@@ -164,11 +164,6 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
logicalModels, logicalModelsErr := svc.PublicLogicalModels(nil)
|
||||
if logicalModelsErr != nil {
|
||||
failService(c, logicalModelsErr)
|
||||
return
|
||||
}
|
||||
limits, err := svc.PublicRuntimeLimits()
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
@@ -184,7 +179,7 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"user": publicUser, "logicalModels": logicalModels, "runtimeLimits": limits, "drawingEngine": drawingEngine, "features": features})
|
||||
ok(c, gin.H{"user": publicUser, "runtimeLimits": limits, "drawingEngine": drawingEngine, "features": features})
|
||||
})
|
||||
r.GET("/channels/system", func(c *gin.Context) {
|
||||
actor, err := currentUser(c, svc)
|
||||
|
||||
@@ -264,13 +264,19 @@ func (m *Manager) runUpdate(fromVersion, targetVersion string) {
|
||||
m.failWithoutRollback(PhaseFailed, fmt.Errorf("保存旧 Compose 配置:%w", err))
|
||||
return
|
||||
}
|
||||
if err := replaceFile(m.envPath(), m.previousEnvPath(), 0o600); err != nil {
|
||||
m.failWithoutRollback(PhaseFailed, fmt.Errorf("保存旧部署环境:%w", err))
|
||||
return
|
||||
}
|
||||
|
||||
m.setPhase(PhasePulling, "拉取目标版本镜像")
|
||||
if err := m.compose(nextCompose, targetVersion, m.config.StepTimeout, nil, "pull", "backend", "web"); err != nil {
|
||||
targetImages := immutableImageRefs(m.config.Repository, targetVersion)
|
||||
if err := m.composeWithImages(nextCompose, targetVersion, targetImages, m.config.StepTimeout, nil, "pull", "backend", "web"); err != nil {
|
||||
m.failWithoutRollback(PhaseFailed, err)
|
||||
return
|
||||
}
|
||||
if err := m.verifyImages(targetVersion); err != nil {
|
||||
targetImages, err = m.verifyImages(targetVersion)
|
||||
if err != nil {
|
||||
m.failWithoutRollback(PhaseFailed, err)
|
||||
return
|
||||
}
|
||||
@@ -282,13 +288,13 @@ func (m *Manager) runUpdate(fromVersion, targetVersion string) {
|
||||
}
|
||||
|
||||
m.setPhase(PhaseMigrating, "执行目标版本数据库迁移")
|
||||
if err := m.compose(nextCompose, targetVersion, m.config.StepTimeout, nil, "run", "--rm", "migrate"); err != nil {
|
||||
if err := m.composeWithImages(nextCompose, targetVersion, targetImages, m.config.StepTimeout, nil, "run", "--rm", "migrate"); err != nil {
|
||||
m.failWithRollback(err, fromVersion, backup)
|
||||
return
|
||||
}
|
||||
|
||||
m.setPhase(PhaseSwitching, "切换 Compose 配置与镜像版本")
|
||||
if err := setEnvValue(m.envPath(), "CANVAS_IMAGE_TAG", strings.TrimPrefix(targetVersion, "v")); err != nil {
|
||||
if err := setDeploymentImages(m.envPath(), targetVersion, targetImages); err != nil {
|
||||
m.failWithRollback(err, fromVersion, backup)
|
||||
return
|
||||
}
|
||||
@@ -297,7 +303,7 @@ func (m *Manager) runUpdate(fromVersion, targetVersion string) {
|
||||
m.failWithRollback(err, fromVersion, backup)
|
||||
return
|
||||
}
|
||||
if err := m.compose(m.composePath(), targetVersion, m.config.StepTimeout, nil, "up", "-d", "--remove-orphans", "--wait", "--wait-timeout", "600"); err != nil {
|
||||
if err := m.composeWithImages(m.composePath(), targetVersion, targetImages, m.config.StepTimeout, nil, "up", "-d", "--remove-orphans", "--wait", "--wait-timeout", "600"); err != nil {
|
||||
m.failWithRollback(err, fromVersion, backup)
|
||||
return
|
||||
}
|
||||
@@ -368,10 +374,12 @@ func (m *Manager) runRollback(targetVersion string, backup Backup, automatic boo
|
||||
failures = append(failures, fmt.Errorf("恢复旧 Compose 配置:%w", err))
|
||||
}
|
||||
}
|
||||
if err := m.restoreDatabase(backup); err != nil {
|
||||
failures = append(failures, err)
|
||||
if _, err := os.Stat(m.previousEnvPath()); err == nil {
|
||||
if err := replaceFile(m.previousEnvPath(), m.envPath(), 0o600); err != nil {
|
||||
failures = append(failures, fmt.Errorf("恢复旧部署环境:%w", err))
|
||||
}
|
||||
}
|
||||
if err := setEnvValue(m.envPath(), "CANVAS_IMAGE_TAG", strings.TrimPrefix(targetVersion, "v")); err != nil {
|
||||
if err := m.restoreDatabase(backup); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
started := true
|
||||
@@ -513,6 +521,9 @@ func (m *Manager) envPath() string { return filepath.Join(m.config.InstallDir, m
|
||||
func (m *Manager) previousComposePath() string {
|
||||
return filepath.Join(m.config.StateDir, "previous-compose.yml")
|
||||
}
|
||||
func (m *Manager) previousEnvPath() string {
|
||||
return filepath.Join(m.config.StateDir, "previous.env")
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
buffer := make([]byte, 12)
|
||||
|
||||
@@ -133,7 +133,7 @@ func (m *Manager) preflight(composePath, targetVersion string) error {
|
||||
if err := checkBackupDiskSpace(m.config.BackupDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.compose(composePath, targetVersion, 2*time.Minute, nil, "config", "--quiet"); err != nil {
|
||||
if err := m.composeWithImages(composePath, targetVersion, immutableImageRefs(m.config.Repository, targetVersion), 2*time.Minute, nil, "config", "--quiet"); err != nil {
|
||||
return fmt.Errorf("目标 Compose 校验失败:%w", err)
|
||||
}
|
||||
current, err := m.currentVersion()
|
||||
@@ -243,7 +243,25 @@ func (m *Manager) restartSelf() {
|
||||
_ = m.runner.Run(ctx, "systemctl", []string{"restart", m.config.ServiceName}, nil, io.Discard, io.Discard)
|
||||
}
|
||||
|
||||
type deploymentImages struct {
|
||||
backend string
|
||||
web string
|
||||
}
|
||||
|
||||
func immutableImageRefs(repository, version string) deploymentImages {
|
||||
owner := strings.SplitN(repository, "/", 2)[0]
|
||||
tag := strings.TrimPrefix(version, "v")
|
||||
return deploymentImages{
|
||||
backend: "ghcr.io/" + owner + "/open-ai-canvas-backend:" + tag,
|
||||
web: "ghcr.io/" + owner + "/open-ai-canvas-web:" + tag,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) compose(composePath, imageTag string, timeout time.Duration, stdout io.Writer, arguments ...string) error {
|
||||
return m.composeWithImages(composePath, imageTag, deploymentImages{}, timeout, stdout, arguments...)
|
||||
}
|
||||
|
||||
func (m *Manager) composeWithImages(composePath, imageTag string, images deploymentImages, timeout time.Duration, stdout io.Writer, arguments ...string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
args := []string{"compose", "--env-file", m.envPath(), "-f", composePath}
|
||||
@@ -252,7 +270,11 @@ func (m *Manager) compose(composePath, imageTag string, timeout time.Duration, s
|
||||
if stdout == nil {
|
||||
stdout = io.Discard
|
||||
}
|
||||
err := m.runner.Run(ctx, "docker", args, []string{"CANVAS_IMAGE_TAG=" + strings.TrimPrefix(imageTag, "v")}, stdout, &stderr)
|
||||
environment := []string{"CANVAS_IMAGE_TAG=" + strings.TrimPrefix(imageTag, "v")}
|
||||
if images.backend != "" {
|
||||
environment = append(environment, "CANVAS_BACKEND_IMAGE="+images.backend, "CANVAS_WEB_IMAGE="+images.web)
|
||||
}
|
||||
err := m.runner.Run(ctx, "docker", args, environment, stdout, &stderr)
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
if len(message) > 1000 {
|
||||
@@ -266,22 +288,47 @@ func (m *Manager) compose(composePath, imageTag string, timeout time.Duration, s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) verifyImages(targetVersion string) error {
|
||||
owner := strings.SplitN(m.config.Repository, "/", 2)[0]
|
||||
for _, image := range []string{"ghcr.io/" + owner + "/open-ai-canvas-backend:", "ghcr.io/" + owner + "/open-ai-canvas-web:"} {
|
||||
func (m *Manager) verifyImages(targetVersion string) (deploymentImages, error) {
|
||||
images := immutableImageRefs(m.config.Repository, targetVersion)
|
||||
refs := []string{images.backend, images.web}
|
||||
digests := make([]string, 0, len(refs))
|
||||
for _, image := range refs {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
var output bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
err := m.runner.Run(ctx, "docker", []string{"image", "inspect", image + strings.TrimPrefix(targetVersion, "v"), "--format", "{{json .RepoDigests}}"}, nil, &output, &stderr)
|
||||
err := m.runner.Run(ctx, "docker", []string{"image", "inspect", image, "--format", "{{json .RepoDigests}}"}, nil, &output, &stderr)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("校验目标镜像摘要失败:%s", strings.TrimSpace(stderr.String()))
|
||||
return deploymentImages{}, fmt.Errorf("校验目标镜像摘要失败:%s", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
if !strings.Contains(output.String(), "@sha256:") {
|
||||
return fmt.Errorf("目标镜像 %s 未包含仓库摘要", image+targetVersion)
|
||||
var repositoryDigests []string
|
||||
if err := json.Unmarshal(output.Bytes(), &repositoryDigests); err != nil {
|
||||
return deploymentImages{}, fmt.Errorf("解析目标镜像摘要失败:%w", err)
|
||||
}
|
||||
repository := strings.Split(image, ":")[0]
|
||||
var digest string
|
||||
for _, candidate := range repositoryDigests {
|
||||
if strings.HasPrefix(candidate, repository+"@sha256:") {
|
||||
digest = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if digest == "" {
|
||||
return deploymentImages{}, fmt.Errorf("目标镜像 %s 未包含仓库摘要", image)
|
||||
}
|
||||
digests = append(digests, digest)
|
||||
}
|
||||
return nil
|
||||
return deploymentImages{backend: digests[0], web: digests[1]}, nil
|
||||
}
|
||||
|
||||
func setDeploymentImages(path, version string, images deploymentImages) error {
|
||||
if err := setEnvValue(path, "CANVAS_IMAGE_TAG", strings.TrimPrefix(version, "v")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := setEnvValue(path, "CANVAS_BACKEND_IMAGE", images.backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return setEnvValue(path, "CANVAS_WEB_IMAGE", images.web)
|
||||
}
|
||||
|
||||
func (m *Manager) createBackup(version string) (Backup, error) {
|
||||
|
||||
@@ -28,10 +28,11 @@ func (nopHost) ChannelConcurrencyLimit(string) (int, error) {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo *repository.Repository
|
||||
host Host
|
||||
Coordinator *Coordinator
|
||||
concurrencyCache *BoundedReadCache[string, RuntimeTaskPolicy]
|
||||
repo *repository.Repository
|
||||
host Host
|
||||
Coordinator *Coordinator
|
||||
concurrencyCache *BoundedReadCache[string, RuntimeTaskPolicy]
|
||||
runtimePolicyCache *BoundedReadCache[string, RuntimePolicySetting]
|
||||
}
|
||||
|
||||
func New(repo *repository.Repository, coordinator *Coordinator, host Host) *Service {
|
||||
@@ -39,10 +40,11 @@ func New(repo *repository.Repository, coordinator *Coordinator, host Host) *Serv
|
||||
host = nopHost{}
|
||||
}
|
||||
return &Service{
|
||||
repo: repo,
|
||||
host: host,
|
||||
Coordinator: coordinator,
|
||||
concurrencyCache: NewBoundedReadCache[string, RuntimeTaskPolicy](1, 1024, 1, 2*time.Second),
|
||||
repo: repo,
|
||||
host: host,
|
||||
Coordinator: coordinator,
|
||||
concurrencyCache: NewBoundedReadCache[string, RuntimeTaskPolicy](1, 1024, 1, 2*time.Second),
|
||||
runtimePolicyCache: NewBoundedReadCache[string, RuntimePolicySetting](1, 8192, 1, 2*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const runtimePolicySettingKey = "runtime_policy"
|
||||
@@ -216,8 +214,21 @@ func selfUseRuntimePolicy() RuntimePolicySetting {
|
||||
func SelfUseRuntimePolicy() RuntimePolicySetting { return selfUseRuntimePolicy() }
|
||||
|
||||
func (s *Service) RuntimePolicy() (RuntimePolicySetting, error) {
|
||||
_, value, err := s.readRuntimePolicy()
|
||||
return value, err
|
||||
if s.runtimePolicyCache == nil {
|
||||
_, value, err := s.readRuntimePolicy()
|
||||
return value, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
return s.runtimePolicyCache.Get(ctx, runtimePolicySettingKey, func(ctx context.Context) (RuntimePolicySetting, int, error) {
|
||||
reader := &Service{
|
||||
repo: s.repo.WithContext(ctx),
|
||||
host: s.host,
|
||||
Coordinator: s.Coordinator,
|
||||
}
|
||||
_, value, err := reader.readRuntimePolicy()
|
||||
return value, 2048, err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) RuntimeConcurrencySetting() (RuntimeTaskPolicy, error) {
|
||||
@@ -225,7 +236,7 @@ func (s *Service) RuntimeConcurrencySetting() (RuntimeTaskPolicy, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
return s.concurrencyCache.Get(ctx, runtimePolicySettingKey, func(ctx context.Context) (RuntimeTaskPolicy, int, error) {
|
||||
reader := &Service{repo: s.repo.WithContext(ctx), host: s.host, Coordinator: s.Coordinator, concurrencyCache: s.concurrencyCache}
|
||||
reader := &Service{repo: s.repo.WithContext(ctx), host: s.host, Coordinator: s.Coordinator, concurrencyCache: s.concurrencyCache, runtimePolicyCache: s.runtimePolicyCache}
|
||||
policy, err := reader.RuntimePolicy()
|
||||
return policy.Task, 256, err
|
||||
})
|
||||
@@ -284,6 +295,9 @@ func (s *Service) UpdateRuntimePolicySetting(actor *model.User, value RuntimePol
|
||||
}
|
||||
|
||||
s.concurrencyCache.Clear()
|
||||
if s.runtimePolicyCache != nil {
|
||||
s.runtimePolicyCache.Clear()
|
||||
}
|
||||
if err := s.appendAdminAudit(actor, "runtime_policy.update", "system_setting", runtimePolicySettingKey, "更新资源与请求策略", map[string]any{"before": before, "after": value}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -303,6 +317,9 @@ func (s *Service) ResetRuntimePolicySetting(actor *model.User) (*PublicRuntimePo
|
||||
}
|
||||
|
||||
s.concurrencyCache.Clear()
|
||||
if s.runtimePolicyCache != nil {
|
||||
s.runtimePolicyCache.Clear()
|
||||
}
|
||||
after := DefaultRuntimePolicy()
|
||||
if err := s.appendAdminAudit(actor, "runtime_policy.reset", "system_setting", runtimePolicySettingKey, "重置资源与请求策略", map[string]any{"before": before, "after": after}); err != nil {
|
||||
return nil, err
|
||||
@@ -311,14 +328,14 @@ func (s *Service) ResetRuntimePolicySetting(actor *model.User) (*PublicRuntimePo
|
||||
}
|
||||
|
||||
func (s *Service) readRuntimePolicy() (*model.SystemSetting, RuntimePolicySetting, error) {
|
||||
setting, err := s.repo.SystemSetting(runtimePolicySettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
value := DefaultRuntimePolicy()
|
||||
return nil, value, validateRuntimePolicy(value)
|
||||
}
|
||||
setting, err := s.repo.SystemSettingOptional(runtimePolicySettingKey)
|
||||
if err != nil {
|
||||
return nil, RuntimePolicySetting{}, err
|
||||
}
|
||||
if setting == nil {
|
||||
value := DefaultRuntimePolicy()
|
||||
return nil, value, validateRuntimePolicy(value)
|
||||
}
|
||||
value := DefaultRuntimePolicy()
|
||||
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
|
||||
return nil, RuntimePolicySetting{}, errors.New("资源与请求策略配置格式无效")
|
||||
|
||||
@@ -86,6 +86,20 @@ func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]mo
|
||||
return items, r.attachChannelModelPriceTiers(pointers)
|
||||
}
|
||||
|
||||
func (r *Repository) ChannelModelReferences(channelIDs []string) ([]model.ChannelModel, error) {
|
||||
if len(channelIDs) == 0 {
|
||||
return []model.ChannelModel{}, nil
|
||||
}
|
||||
var items []model.ChannelModel
|
||||
if err := r.db.Select("id", "channel_id", "model_key", "display_name", "sort_order", "enabled").
|
||||
Where("channel_id IN ?", channelIDs).
|
||||
Order("channel_id asc, sort_order asc, created_at asc, id asc").
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateDuplicatedSystemChannel(channel *model.ModelChannel, channelModels []model.ChannelModel, priceTiers []model.ChannelModelPriceTier) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(channel).Error; err != nil {
|
||||
|
||||
@@ -835,6 +835,17 @@ func (r *Repository) SystemSetting(key string) (*model.SystemSetting, error) {
|
||||
return &setting, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SystemSettingOptional(key string) (*model.SystemSetting, error) {
|
||||
var setting model.SystemSetting
|
||||
if err := r.db.Where("key = ?", key).Limit(1).Find(&setting).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if setting.Key == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &setting, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SaveSystemSetting(setting *model.SystemSetting) error {
|
||||
return r.db.Save(setting).Error
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
migrate:
|
||||
image: ghcr.io/ddcat-ai/open-ai-canvas-backend:${CANVAS_IMAGE_TAG:-latest}
|
||||
image: ${CANVAS_BACKEND_IMAGE:?请先配置 CANVAS_BACKEND_IMAGE(必须包含镜像 digest)}
|
||||
command: ["migrate-schema", "up"]
|
||||
environment:
|
||||
CANVAS_BACKEND_DATA_DIR: /data
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
backend:
|
||||
image: ghcr.io/ddcat-ai/open-ai-canvas-backend:${CANVAS_IMAGE_TAG:-latest}
|
||||
image: ${CANVAS_BACKEND_IMAGE:?请先配置 CANVAS_BACKEND_IMAGE(必须包含镜像 digest)}
|
||||
environment:
|
||||
CANVAS_BACKEND_ADDR: ":8080"
|
||||
CANVAS_BACKEND_DATA_DIR: /data
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
stop_grace_period: ${CANVAS_SHUTDOWN_TIMEOUT:-10m}
|
||||
|
||||
web:
|
||||
image: ghcr.io/ddcat-ai/open-ai-canvas-web:${CANVAS_IMAGE_TAG:-latest}
|
||||
image: ${CANVAS_WEB_IMAGE:?请先配置 CANVAS_WEB_IMAGE(必须包含镜像 digest)}
|
||||
ports:
|
||||
- "${CANVAS_HTTP_PORT:-3000}:3000"
|
||||
volumes:
|
||||
|
||||
@@ -25,6 +25,20 @@ description: 信封、错误码与 OpenAPI
|
||||
|
||||
当前自动签名能力:公开 CDN、七牛私有下载签名和对象存储源站签名。阿里云/腾讯云私有 CDN 签名尚未自动支持,不能仅填写 CDN 域名就宣称已命中 CDN。真实 CORS、Range、CDN 命中率及账单收益必须在实际云环境通过浏览器网络记录和服务端流量观测验收。
|
||||
|
||||
## 画布资源完整性与 409
|
||||
|
||||
画布写入在保存历史快照前校验当前文档中的资源引用;引用须属于当前用户且处于 `ready`。引用收集器递归遍历受支持的资源字段,因此 `nodes[节点 ID].metadata.videoPreview.storageKey` 等嵌套派生封面也会参与检查。历史快照保存会一并校验新版本引用;若资源在保存期间缺失或状态变化,事务回滚,画布 revision 不递增。
|
||||
|
||||
HTTP 409 不能一概解释为 revision 冲突:普通版本冲突使用 `reason: conflict`;资源完整性失败使用 `reason: canvas_history_resources_missing`,并在 `details` 中返回 `resourceIds` 和 `missingResources`(含资源 ID、JSON 路径、节点 ID、引用类型及 `source`)。客户端应按 `reason` 分流,不解析 `msg` 猜测原因。显式资源修复写入仅允许历史 preimage 存在缺失资源,不允许新画布仍引用缺失资源,也不绕过 revision CAS。
|
||||
|
||||
运维扫描/修复仅处理当前画布中可安全清除的失效 `videoPreview`;不删除主媒体、不扫描并改写所有历史快照,也不替代备份。使用目标数据库对应的新版本二进制,先在维护窗口备份并执行只读扫描:
|
||||
|
||||
```sh
|
||||
CANVAS_BACKEND_DATA_DIR=/明确的数据目录 ./migrate-schema repair-canvas-resources
|
||||
```
|
||||
|
||||
复核 JSON 报告后,只有 `repairable` 项可由明确授权的操作员再次运行 `repair-canvas-resources --apply`。命令要求显式配置 `CANVAS_BACKEND_DATA_DIR` 或 `DATABASE_URL`,逐画布按版本 CAS 保存并保留原始历史;有未解决主媒体/素材绑定问题时以非零退出,不会自动恢复或重传。发生并发编辑时停止并重新扫描,不能用强制覆盖代替恢复有效历史或重新上传。
|
||||
|
||||
## 生成成功后的重试保存
|
||||
|
||||
`POST /tasks/:id/recover-media` 无需请求体。当前用户只能恢复自己有保存恢复记录的失败任务;重入队仍返回原任务 ID,不请求重新生成、不新建计费订单。已排队、运行或完成时幂等返回当前任务。取消任务、已退款订单、任务/账单归属不一致、项目已删除或归档时拒绝恢复。
|
||||
|
||||
@@ -19,7 +19,7 @@ description: Host Updater、Release 检查、数据库备份迁移、健康验
|
||||
4. 拉取目标 backend/web 镜像并要求存在仓库 `sha256` 摘要。任何失败都在停服前中止。
|
||||
5. 停止 web/backend,等待 Backend 的 SIGTERM drain 完成;PostgreSQL 和 Redis 保持运行。
|
||||
6. 用目标 backend 镜像独立执行 `migrate-schema up`。整批迁移在事务和 PostgreSQL advisory lock 内完成,迁移名称与 checksum 必须匹配。
|
||||
7. 原子更新 `CANVAS_IMAGE_TAG` 和 Compose 文件,启动目标版本。
|
||||
7. 解析目标 backend/web 镜像的仓库 digest,原子更新 `CANVAS_IMAGE_TAG`、`CANVAS_BACKEND_IMAGE`、`CANVAS_WEB_IMAGE` 和 Compose 文件,启动目标版本;运行容器始终使用 `image@sha256:...`,不使用 `latest`。
|
||||
8. 连续检查 `/api/health/ready` 的数据库、运行时、schema 和 build version,并经过稳定窗口后才标记成功。
|
||||
|
||||
更新状态写入 `/var/lib/open-ai-canvas-updater/state.json`。Backend 或浏览器在切换期间断线不会终止宿主机操作,恢复后可继续读取日志。
|
||||
@@ -30,20 +30,27 @@ description: Host Updater、Release 检查、数据库备份迁移、健康验
|
||||
|
||||
1. 停止目标 web/backend;
|
||||
2. 恢复更新前保存的 Compose 文件;
|
||||
3. 再次校验备份 ZIP 和 SHA-256;
|
||||
4. 使用 `pg_restore --clean --if-exists` 恢复 PostgreSQL;
|
||||
5. 恢复旧 `CANVAS_IMAGE_TAG`,启动旧服务并重新进行稳定窗口验证。
|
||||
3. 恢复更新前保存的 `.env`,确保 backend/web 镜像仍绑定到原 digest;
|
||||
4. 再次校验备份 ZIP 和 SHA-256;
|
||||
5. 使用 `pg_restore --clean --if-exists` 恢复 PostgreSQL;
|
||||
6. 启动旧服务并重新进行稳定窗口验证。
|
||||
|
||||
回退任一步失败时状态变为 `manual_intervention`,不伪造成功。管理后台仅在存在旧版本和已校验备份时开放人工回退按钮。
|
||||
|
||||
## 安装与升级约束
|
||||
|
||||
GHCR 镜像部署必须把 `CANVAS_IMAGE_TAG` 固定到具体 Release,不可使用 `latest`。安装更新器:
|
||||
GHCR 镜像部署必须把 `CANVAS_IMAGE_TAG` 固定到具体 Release,并把 `CANVAS_BACKEND_IMAGE`、`CANVAS_WEB_IMAGE` 固定到对应 GHCR digest,不可使用 `latest` 或仅使用可变 tag。首次安装时显式指定 Release:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-server-image.sh \
|
||||
| sudo env CANVAS_IMAGE_TAG=v1.5.7.1 bash
|
||||
```
|
||||
|
||||
已有部署只安装更新器时,先确认 `.env` 已包含两个 `@sha256:...` 镜像引用,再执行:
|
||||
|
||||
```bash
|
||||
cd /opt/open-ai-canvas
|
||||
curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-host-updater.sh | sudo bash
|
||||
sudo docker compose --env-file .env -f docker-compose.deploy.yml up -d --force-recreate backend web --wait
|
||||
```
|
||||
|
||||
脚本会下载与当前 Release 匹配的 Linux amd64/arm64 二进制和 `SHA256SUMS`,生成随机 `CANVAS_UPDATER_TOKEN`,创建 systemd 服务、Socket 目录、状态目录和 `backups/`。Token 只保存在服务器 `.env` 与 root-only 更新器环境文件中。
|
||||
@@ -59,7 +66,7 @@ sudo docker compose --env-file .env -f docker-compose.deploy.yml up -d --force-r
|
||||
3. 前端 Bun 构建阶段固定到 `BUILDPLATFORM`,静态资源只生成一次,再放入 AMD64/ARM64 nginx 镜像。Bun 与 `web/package.json`、质量检查保持 `1.3.9` 一致。
|
||||
4. 先推送 `sha-<完整提交>-amd64/arm64` 后端镜像及 `sha-<完整提交>-multi` 前端镜像,以摘要 artifact 传递结果。两种后端架构和前端全部成功后,才从摘要合并发布 manifest,不用可变标签作为合并源。
|
||||
5. 同一 SHA 的 main/tag 发布串行运行;后到的一轮仍经过质量门禁,然后复用已有 SHA 镜像,不重复编译。只有明确的镜像不存在可以转入构建,鉴权、限流、网络和异常摘要必须报错。
|
||||
6. 发布保留完整 SHA 和短 SHA 标签;只有版本 tag 推送写入具体版本标签。`main` 只有在仍是远端最新提交时才更新 `latest`,旧提交的慢任务不能覆盖新版本。正式部署仍应固定版本标签,不依赖 `latest`。
|
||||
6. 发布保留完整 SHA 和短 SHA 标签;只有版本 tag 推送写入具体版本标签。`main` 只有在仍是远端最新提交时才更新 `latest`,旧提交的慢任务不能覆盖新版本。正式部署由安装器和更新器解析并落盘 digest,不依赖 `latest`。
|
||||
|
||||
Go 检查显式使用 `backend/go.sum` 作为缓存依赖键,并上传 `go-test-timings`(JSONL),可按 `Test` / `Elapsed` 定位慢测试。Docker 的后端模块下载保留在普通镜像层,编译步骤可以直接读取;支付插件只按实际 import 下载依赖,不重复预取完整后端模块图。版本参数下移到编译入口。GHA 构建缓存按镜像和架构隔离,避免并行任务共用同一 scope。`go-build` cache mount 只用于同一个 builder 内的复用,当前没有额外的跨 Runner mount 导出方案;不能把它当作持久编译缓存。冷缓存首轮和缓存上传仍可能较慢,需要比较连续两轮的实际耗时。
|
||||
|
||||
|
||||
@@ -5,6 +5,16 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 画布资源断链、前端发布兼容与画布热点
|
||||
|
||||
- 已实现:统一递归识别持久资源字段(含嵌套 `videoPreview`);保存前和历史快照保存时校验当前资源归属/ready 状态,资源缺失返回带路径和 ID 的结构化 409。显式修复保留坏版本历史但要求新版本完整且 revision CAS 成功。后台 `repair-canvas-resources` 默认只读,仅可清除确认缺失的派生视频预览;主媒体问题报告为 unresolved。
|
||||
- 已实现:哈希 chunk 采用长缓存,缺失资源不缓存;HTML 显式 `no-cache, must-revalidate`。静态容器发布保留当前与上一发布 chunk,并在保留窗口内清理更旧资源;Vite preload 失败只自动恢复一次,之后交由路由错误页,用户手动重试仍可用。已有数据卷不会凭空恢复首次部署前已删的 chunk。
|
||||
- 已实现:同步显式 repair/force 请求不会被正在运行的普通同步吞掉;素材元数据请求按账号 scope/generation 隔离迟到响应。画布内容 hash 对 viewport/revision-only 副本复用;视口更新走轻量路径;loading 节点共享画布级 ticker,页面隐藏时暂停该 ticker。
|
||||
- [ ] 先备份隔离/测试数据库,验证 `repair-canvas-resources` dry-run、`--apply`、重复运行幂等、并发 revision 冲突、只清派生 preview、不改主媒体;检查 unresolved 后人工恢复历史或重传。未扫描或改写生产数据库。
|
||||
- [ ] 使用发布测试卷连续发布三版以上:旧 tab 延迟触发旧 chunk 后仍可加载;验证 HTML 与哈希资源 Cache-Control、旧 chunk 404 不被 SPA fallback 改写,以及浏览器一次性恢复不循环。当前没有 CDN/Ingress 线上响应证据,Firefox MIME 具体响应类型未确认。
|
||||
- [ ] Firefox/Chrome/Safari 实际页面走关键 lazy route;检查离线草稿、登录切换和错误重试行为。尚未做真实浏览器/CDN 验收。
|
||||
- [ ] 使用 React Profiler 与长时间运行的大画布记录任务更新、节点数/可见数、主线程长任务和页面隐藏行为。当前只做结构性降热点,不推断整画布必然全量重渲染或已达到某个帧率。
|
||||
|
||||
## 原任务 ID 媒体保存恢复
|
||||
|
||||
- [x] 故障注入:下载 503 后服务实例重建继续原任务、OSS 上传失败保留暂存文件及资源 ID、素材登记异常使任务成功事务回滚,登记恢复无需再次下载。
|
||||
|
||||
288
docs/design/canvas-final-performance-architecture.md
Normal file
288
docs/design/canvas-final-performance-architecture.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# 画布性能与媒体分发最终改造方案
|
||||
|
||||
状态:基于当前 `main`(`7bf655e9`)和现有 OSS/CDN 重构重新整理。本文是当前仓库的最终目标和一次性改造边界,不把已经存在的能力重复当成待建设,也不把关键问题推到二期、三期。
|
||||
|
||||
## 1. 最终结论
|
||||
|
||||
当前仓库已经完成了画布优化的第一代基础设施:
|
||||
|
||||
- 资源访问已经收敛到 `assets.ResolveAccess` 和 `POST /api/resources/access`。
|
||||
- 画布已经有 `canvas-live-viewport` 的合成层预览、Leafer underlay/overlay 图形层、空间索引、节点拖拽预览和选择预览。
|
||||
- 图片、视频和音频的多数展示路径已经使用 `storageKey` 解析访问地址。
|
||||
- 云资源 `/file` 路径已经能够重定向到 CDN/源站,平台不必转发云端媒体正文。
|
||||
|
||||
因此,最终改造方向不是重写成 WebGL,也不是复制 LibTV 的组件,而是把当前已经存在的能力合并成一个闭环:
|
||||
|
||||
```text
|
||||
稳定资源身份
|
||||
-> 统一访问描述
|
||||
-> poster-first 媒体状态机
|
||||
-> 展示与字节读取分离
|
||||
-> 交互预览与持久化提交分离
|
||||
-> 可见节点 / 轻量节点 / 完整节点三层 LOD
|
||||
-> 同一帧节点、连接、对齐线更新
|
||||
-> 以设备帧预算驱动调度和验收
|
||||
```
|
||||
|
||||
用户手势帧永远不承担以下工作:整视频 Blob 下载、视频首帧编码上传、批量 React 复杂节点挂载、全量连线重算、逐 pointermove 持久化和签名地址同步写入画布。
|
||||
|
||||
## 2. 外部方案需要修正的地方
|
||||
|
||||
外部方案仍然正确的核心原则是视频默认图片化、旧画面不清空、交互期间不做重活、屏内节点不能被屏外预算截断,以及节点与连线使用同一帧预览。
|
||||
|
||||
以下内容不能直接照搬:
|
||||
|
||||
1. **OSS snapshot 尚未成为当前仓库的统一首帧供应链。** 当前 `web/src/services/canvas-video-preview.ts` 仍在缺少持久 poster 时使用浏览器截帧并后台上传;这只能是历史资源的降级路径,不能继续作为新资源的正常路径。
|
||||
2. **当前没有完整的 `SelfVirtualizingNode` 和渐进挂载队列。** `use-canvas-render-model.ts` 主要负责空间查询和 DOM 节点挂载窗口,固定使用 280/420/720 节点预算;这不是完整 LOD,也不能宣称支持任意万级画布。
|
||||
3. **Leafer 目前主要承载连接、选框、对齐线和草稿线。** 节点正文仍是 DOM。`canvas-live-viewport.ts` 已解决交互期合成变换,但不能替代节点 LOD 和挂载调度。
|
||||
4. **小地图仍然为所有节点创建 DOM 和标签。** 只有图片预览数量限制为 24,节点和标签没有虚拟化;节点规模增大时,小地图本身会变成主线程负担。
|
||||
5. **当前播放后整文件缓存会制造重复流量。** `resource-blob-cache.ts` 曾由播放器播放后延迟 4 秒拉取完整 Blob;播放 Range 和完整下载可能并存,且缓存预算最高可达 2 GiB。这不属于普通展示行为。
|
||||
6. **`provider-input` 的浏览器边界与文档不一致。** 当前登录用户 `/resources/access` 直接以 `allowProvider=true` 处理该用途,前端可以申请长 TTL 模型输入地址。最终合同必须把模型输入迁移到服务端受控读取,浏览器公开 batch 只接受展示、复制、下载和浏览器字节处理。
|
||||
7. **固定数字不是性能承诺。** 0.23/0.27、256px、30+4、280/420/720、5000 条连接、DPR 上限 3 都只能是初始参数。最终参数必须由真实设备和帧预算测量得到。
|
||||
|
||||
## 3. 最终分层
|
||||
|
||||
### 3.1 持久数据层
|
||||
|
||||
画布 JSON 只保存稳定身份和业务元数据:
|
||||
|
||||
```ts
|
||||
type CanvasMediaRef = {
|
||||
kind: "image" | "video" | "audio";
|
||||
resourceId?: string;
|
||||
storageKey?: string; // resource:<id>
|
||||
originalUrl?: string; // 外部资源历史兼容字段
|
||||
previewContent?: string; // 已经持久化的静态预览
|
||||
videoPreview?: {
|
||||
storageKey?: string;
|
||||
content?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
mimeType?: string;
|
||||
};
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
mimeType?: string;
|
||||
};
|
||||
```
|
||||
|
||||
临时签名 URL、`blob:` URL、当前播放器实例、缓存 lease 和访问描述不得写入画布 JSON、任务结果、localStorage 或日志。
|
||||
|
||||
上传、生成、导入三条路径必须在创建节点时统一写入同一 `resource:<id>`。只有外部直链未转存时才保留外部 URL,并明确标记为外部资源。
|
||||
|
||||
### 3.2 访问描述层
|
||||
|
||||
`assets.ResolveAccess` 是唯一分发策略入口。浏览器端的 `media-access` 只负责:
|
||||
|
||||
- 按 scope、资源、用途、变体合并请求。
|
||||
- 使用服务端返回的 `expiresAt`、`refreshAt` 和 `revision`。
|
||||
- 账号切换时取消在途请求并清理旧描述。
|
||||
- 只把描述交给 `<img>`、`<video>`、原生下载或明确的字节读取器。
|
||||
|
||||
服务端用途矩阵固定为:
|
||||
|
||||
| 用途 | 浏览器公开 batch | 服务端内部 | 变体 |
|
||||
| --- | --- | --- | --- |
|
||||
| `display` | 允许 | 允许 | original/playback |
|
||||
| `copy` | 允许 | 允许 | original |
|
||||
| `download` | 允许 | 允许 | original |
|
||||
| `browser-process` | 允许,必须满足 CORS/Range | 允许 | original/playback |
|
||||
| `provider-input` | 禁止 | 仅模型请求组合根 | original |
|
||||
|
||||
模型输入由后端在真正发出上游请求前解析,前端只提交稳定资源身份或引用绑定。不得让浏览器先申请 4 小时地址再把它当作普通页面能力。
|
||||
|
||||
`ResourceAccess` 的缓存键至少包含:
|
||||
|
||||
```text
|
||||
userScope + resourceId + purpose + requestedVariant + serverRevision
|
||||
```
|
||||
|
||||
响应返回后必须再次校验发起时的 scope 和 access generation;账号切换后旧请求即使晚到,也不能写入新账号缓存。
|
||||
|
||||
### 3.3 媒体层
|
||||
|
||||
视频节点采用 poster-first 状态机:
|
||||
|
||||
```text
|
||||
no-poster -> requesting-poster -> poster-visible
|
||||
poster-visible -> activating -> player-created
|
||||
player-created -> metadata-ready -> first-frame-ready -> player-visible
|
||||
任何失败 -> poster-visible / 可重试状态
|
||||
```
|
||||
|
||||
硬规则:
|
||||
|
||||
- 未选中视频不挂载 `<video>`。
|
||||
- 激活时 poster 保持在底层,播放器真实出帧后才淡入。
|
||||
- 播放 URL 不先置空,不能制造黑屏窗口。
|
||||
- `canplay` 或 `requestVideoFrameCallback` 之前播放器保持透明。
|
||||
- 播放失败、签名过期和 CORS 失败都回到 poster,并给出可见重试。
|
||||
- 普通播放不调用完整 Blob 缓存。Blob 只服务裁剪、像素读取、导出、识别和明确离线场景。
|
||||
- 播放器池只保留少量热实例,按设备内存和可见性淘汰。
|
||||
|
||||
### 3.4 首帧供应链
|
||||
|
||||
新上传和生成完成的视频必须优先得到服务端可复用的静态首帧:
|
||||
|
||||
1. OSS 资源使用对应对象存储的 `video/snapshot` 或同等处理能力生成首帧访问描述。
|
||||
2. 处理参数固定由服务端白名单生成,宽度按 400/800 等档位选择,不接受任意客户端处理串。
|
||||
3. 签名 URL 只在内存中使用,不写回画布。
|
||||
4. 高频资源可以异步物化为独立 poster 对象,但不放进上传同步关键路径。
|
||||
5. 历史没有 poster 的资源才使用浏览器截帧兜底,且该路径必须限流、可取消、可观测。
|
||||
|
||||
首帧接口需要验证资源归属、资源类型、ready 状态、实际存储位置和访问用途。非支持 OSS 资源返回结构化 `supported=false`,不能伪造一个视频 URL 当作图片。
|
||||
|
||||
### 3.5 画布渲染层
|
||||
|
||||
画布分成四个运行时状态:
|
||||
|
||||
```text
|
||||
committedViewport 持久化视口
|
||||
liveViewport 手势期间实时视口
|
||||
committedNodes 持久化节点状态
|
||||
mediaRuntime poster/player/cache 运行时状态
|
||||
```
|
||||
|
||||
平移、缩放、小地图拖动和节点拖动期间只更新 live 状态、CSS 合成层和 Leafer preview;手势结束后一次性提交 React/store/服务端状态。媒体运行时状态不能因为 viewport 每帧变化而重置。
|
||||
|
||||
节点渲染采用三层 LOD:
|
||||
|
||||
| 层级 | 条件 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| `shell` | 远景、屏外 retain 区、交互资源不足 | 尺寸、标题、连接入口、色块或低清 poster |
|
||||
| `preview` | entry margin、可见但非焦点 | poster/图片、最少状态、静态文字 |
|
||||
| `full` | 屏内焦点、选中、拖动、连接、编辑 | 完整业务组件和交互控件 |
|
||||
|
||||
进入和退出完整层必须有迟滞;选中、拖动、连接和编辑是强制升级条件。屏内节点和 entry margin 节点不能被总预算裁掉,预算只用于更远的 shell 和完整层数量。
|
||||
|
||||
渐进挂载调度器以帧预算为准:
|
||||
|
||||
- 每帧先测量 `performance.now()` 消耗。
|
||||
- 剩余预算足够时才挂载下一个完整节点。
|
||||
- pointerdown、wheel、拖动、框选期间暂停后台升级。
|
||||
- 优先级顺序为当前交互节点、屏内节点、entry margin、按距离排序的 shell。
|
||||
- 连续超预算时自动降低媒体效果和完整层并发,而不是硬编码一个全球固定数量。
|
||||
|
||||
### 3.6 连接和图形层
|
||||
|
||||
Leafer 保留为连接、选框、对齐线和草稿线的图形层。连接路径按连接 ID 缓存,并维护节点到连接的反向索引。
|
||||
|
||||
拖动的一帧必须按以下顺序完成:
|
||||
|
||||
```text
|
||||
pointermove
|
||||
-> 计算 dragOffset
|
||||
-> DOM 节点临时 translate
|
||||
-> Leafer 只更新受影响连接
|
||||
-> 更新选择框和对齐线
|
||||
-> 同一 RAF 提交图形脏区
|
||||
```
|
||||
|
||||
只有一端移动时重算曲线,两端一起移动时可整体平移旧路径。屏内连接不能因为屏外连接预算截断。拖动期间暂停流光动画,避免动画和路径重算争抢帧预算。
|
||||
|
||||
### 3.7 小地图
|
||||
|
||||
小地图改为单一 Canvas/Leafer 绘制:
|
||||
|
||||
- 节点按颜色和尺寸画矩形。
|
||||
- 只对当前焦点或极少数指定节点画 poster。
|
||||
- 不创建全量标题 DOM。
|
||||
- 世界范围使用空间索引或增量 bounds,不在每次 pointermove 全量计算。
|
||||
- 视口框拖动保留抓取偏移;框内拖动不跳到鼠标中心。
|
||||
- 小地图拖动持续发布 live viewport,render window 同步跟随。
|
||||
|
||||
## 4. 缓存最终规则
|
||||
|
||||
缓存分成四类,不能互相替代:
|
||||
|
||||
1. **请求合并**:同一 scope、资源、用途、变体、档位只允许一个在途请求。
|
||||
2. **访问描述缓存**:只缓存短期签名描述,按服务端 `refreshAt` 和 `revision` 失效。
|
||||
3. **展示对象缓存**:交给浏览器/CDN;普通展示不转 Blob。
|
||||
4. **字节缓存**:仅给明确 Blob 消费者,按 scope、实际变体、revision 隔离,有单文件上限、总量上限、LRU 和可取消下载。
|
||||
|
||||
账号切换必须同时:
|
||||
|
||||
- 清理 access descriptor、in-flight descriptor 和 resource metadata cache。
|
||||
- 撤销展示用途产生的 Object URL。
|
||||
- 取消或使旧 Blob 下载代际失效。
|
||||
- 清理播放器池和媒体 lease。
|
||||
- 响应提交前核对 scope/generation。
|
||||
|
||||
Service Worker 只允许缓存公开、无 token、无签名敏感参数的缩略图。私有 OSS、原始视频、鉴权 API 和包含用户 token 的 URL 不进入公开 Service Worker。
|
||||
|
||||
## 5. 后端 OSS/CDN 合同
|
||||
|
||||
资源访问顺序固定为:
|
||||
|
||||
```text
|
||||
业务授权
|
||||
-> ready 校验
|
||||
-> 变体解析
|
||||
-> 实际存储位置解析
|
||||
-> 用途能力与保护校验
|
||||
-> CDN/源站签名或明确平台流计划
|
||||
```
|
||||
|
||||
当前仓库必须保持以下边界:
|
||||
|
||||
- CDN 是分发能力,不等于 OSS 厂商名称。
|
||||
- 历史资源按资源绑定的 provider、endpoint、bucket 和 storage setting 读取,不能把所有历史对象拼到当前 CDN 域名。
|
||||
- `download` 使用对象存储响应的 Content-Disposition,不能因为改文件名而把媒体正文拉回平台。
|
||||
- local/proxy 正文流必须带原因、字节量、Range 和失败指标。
|
||||
- `/api/resources/access` 不得成为浏览器获取长效 provider-input 凭据的入口。
|
||||
- `revision` 必须包含资源实际版本、播放副本状态和分发配置版本;配置变化后旧描述不能继续被前端长期使用。
|
||||
- CORS、GET/HEAD、Range、206、416、签名过期、续期和 CDN 命中必须通过真实链路验证,不能用本地单测替代。
|
||||
|
||||
## 6. 一次性改造清单
|
||||
|
||||
这次改造以一个完整发布批次交付,下面是同一批次内的工作流,不是功能延期:
|
||||
|
||||
1. **访问合同收口**:公开 batch 拒绝 `provider-input`;模型输入迁移到后端组合根;访问描述缓存增加 scope/generation/revision 失效;补全账号切换清理。
|
||||
2. **展示与字节读取分离**:移除播放后自动整 Blob 下载;保留 Blob 只给裁剪、导出、识别等真实字节消费者;补取消、去重、LRU 和单文件上限。
|
||||
3. **poster-first 完整化**:新资源首帧走 OSS/CDN snapshot;浏览器截帧只做历史/不支持资源兜底;播放器保留 poster 到真实首帧。
|
||||
4. **LOD 和渐进挂载**:在现有 `useCanvasRenderModel` 上增加 shell/preview/full 三层模型、迟滞和帧预算队列;当前固定节点预算降为初始策略,不作为产品承诺。
|
||||
5. **小地图换成图形绘制**:去掉全量节点标签 DOM 和图片遍历,保留色块、焦点预览和视口框。
|
||||
6. **连接增量更新**:保留现有 Leafer 路径缓存和拖动预览,补齐连接空间索引裁剪、屏内连接强制保留和动画暂停。
|
||||
7. **运行时观测**:记录手势帧 P50/P95/max、50ms 长任务、可见/挂载节点、完整层数量、播放器数量、poster 请求、Blob 字节、平台正文 200/206 字节、CDN/origin 命中和降级原因。
|
||||
8. **全入口清理**:静态扫描并删除页面自行拼接签名 URL、旧 `direct/proxy` 开关、展示路径隐式 Blob、重复的 provider 判断和旧直链 helper。
|
||||
|
||||
## 7. 验收门槛
|
||||
|
||||
### 画布交互
|
||||
|
||||
- 5%、10%、25% 缩放下四向连续平移,屏内节点不成片消失。
|
||||
- 快速跳远、缩放和小地图持续拖动不出现成片黑边;新区域在手势进行中进入 render window。
|
||||
- 单节点、多节点拖动时节点、连接端点、选择框、对齐线同一帧移动。
|
||||
- pointercancel、Esc、失焦不会写入错误位置,也不会触发点击或工具栏。
|
||||
- 连续框选不激活播放器、不显示单节点工具栏、不批量升级完整节点。
|
||||
|
||||
### 媒体
|
||||
|
||||
- 未选中视频 DOM 中 `<video>` 数量为 0。
|
||||
- 选中视频在播放器真实出帧前 poster 持续可见。
|
||||
- 播放失败、断网、签名过期和 CORS 失败不会黑屏。
|
||||
- 普通播放不会触发整文件 Blob 下载;播放 Range 不与后台整文件下载重复。
|
||||
- 相同资源/用途/变体的并发访问描述请求为 1。
|
||||
- 账号切换后旧账号 URL、Blob、播放器和 in-flight 结果不会进入新账号。
|
||||
|
||||
### OSS/CDN
|
||||
|
||||
- CDN、源站、local、明确批准的 proxy、历史资源位置均有合同测试。
|
||||
- 真实浏览器验证 CORS、Range、206、416、签名过期、续期、暂停后继续和下载文件名。
|
||||
- 平台只对 local/approved-proxy 发送媒体正文;每次正文都有结构化原因。
|
||||
- `provider-input` 不再由登录浏览器 batch 直接获取长效 URL。
|
||||
|
||||
### 规模与设备
|
||||
|
||||
- 至少覆盖 100、300、1000、3000 节点和 100、900、2000、5000 条连接。
|
||||
- 覆盖 DPR 1/2、390px 窄屏、桌面、低端设备模拟、冷缓存和热缓存。
|
||||
- 记录 P50、P95、最大帧间隔和长任务,不能只看平均 FPS。
|
||||
- 所有固定数字都必须回填到设备/帧预算实测结果,不把某次机器上的 60fps 结果写成普遍承诺。
|
||||
|
||||
## 8. 风险控制
|
||||
|
||||
- 旧资源没有 poster 时允许降级为占位卡和可取消浏览器截帧,但不得挂载常驻被动播放器。
|
||||
- OSS/CDN 配置无效时明确返回结构化错误,不把故障静默转成平台代理。
|
||||
- 图形层异常时保留 DOM 节点和静态连接;LOD 调度异常时退回 shell/preview,不在手势帧内批量升级。
|
||||
- 资源清理继续执行画布、项目、任务、素材和历史快照引用检查;性能改造不改变删除安全边界。
|
||||
- 不以新增 Service Worker、`will-change`、WebGL 或 OffscreenCanvas 掩盖访问合同和渲染调度问题。
|
||||
@@ -23,6 +23,11 @@ require_root() {
|
||||
command -v openssl >/dev/null 2>&1 || fail "缺少 openssl"
|
||||
[[ -f "${INSTALL_DIR}/.env" ]] || fail "未找到 ${INSTALL_DIR}/.env"
|
||||
[[ -f "${INSTALL_DIR}/docker-compose.deploy.yml" ]] || fail "未找到部署 Compose"
|
||||
local backend_image web_image
|
||||
backend_image="$(sed -n 's/^CANVAS_BACKEND_IMAGE=//p' "${INSTALL_DIR}/.env" | tail -n 1)"
|
||||
web_image="$(sed -n 's/^CANVAS_WEB_IMAGE=//p' "${INSTALL_DIR}/.env" | tail -n 1)"
|
||||
[[ "$backend_image" =~ ^ghcr\.io/[^[:space:]]+@sha256:[a-f0-9]{64}$ ]] || fail "CANVAS_BACKEND_IMAGE 必须固定为 GHCR digest"
|
||||
[[ "$web_image" =~ ^ghcr\.io/[^[:space:]]+@sha256:[a-f0-9]{64}$ ]] || fail "CANVAS_WEB_IMAGE 必须固定为 GHCR digest"
|
||||
}
|
||||
|
||||
read_image_tag() {
|
||||
|
||||
@@ -6,8 +6,10 @@ REPOSITORY_REF="${REPOSITORY_REF:-main}"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/open-ai-canvas}"
|
||||
CANVAS_HTTP_PORT="${CANVAS_HTTP_PORT:-3000}"
|
||||
REQUESTED_IMAGE_TAG="${CANVAS_IMAGE_TAG:-}"
|
||||
CANVAS_IMAGE_TAG="${REQUESTED_IMAGE_TAG:-latest}"
|
||||
CANVAS_IMAGE_TAG="${CANVAS_IMAGE_TAG#v}"
|
||||
CANVAS_IMAGE_TAG="${REQUESTED_IMAGE_TAG#v}"
|
||||
IMAGE_REPOSITORY="${IMAGE_REPOSITORY:-ghcr.io/ddcat-ai/open-ai-canvas}"
|
||||
CANVAS_BACKEND_IMAGE=""
|
||||
CANVAS_WEB_IMAGE=""
|
||||
COMPOSE_FILE="docker-compose.deploy.yml"
|
||||
COMPOSE_URL="${COMPOSE_URL:-https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/${REPOSITORY_REF}/${COMPOSE_FILE}}"
|
||||
UPDATER_INSTALL_URL="${UPDATER_INSTALL_URL:-https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/${REPOSITORY_REF}/scripts/install-host-updater.sh}"
|
||||
@@ -28,7 +30,10 @@ require_root() {
|
||||
[[ "$(uname -s)" == "Linux" ]] || fail "一键部署脚本仅支持 Linux 服务器"
|
||||
[[ "$CANVAS_HTTP_PORT" =~ ^[0-9]+$ ]] || fail "CANVAS_HTTP_PORT 必须是 1 到 65535 的数字"
|
||||
((CANVAS_HTTP_PORT >= 1 && CANVAS_HTTP_PORT <= 65535)) || fail "CANVAS_HTTP_PORT 必须是 1 到 65535 的数字"
|
||||
[[ "$CANVAS_IMAGE_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || fail "CANVAS_IMAGE_TAG 不是有效的 Docker 镜像标签"
|
||||
if [[ -n "$CANVAS_IMAGE_TAG" ]]; then
|
||||
[[ "$CANVAS_IMAGE_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || fail "CANVAS_IMAGE_TAG 不是有效的 Docker 镜像标签"
|
||||
[[ "$CANVAS_IMAGE_TAG" != "latest" ]] || fail "生产部署必须固定到具体 Release,不能使用 latest"
|
||||
fi
|
||||
}
|
||||
|
||||
install_packages() {
|
||||
@@ -106,12 +111,22 @@ prepare_environment() {
|
||||
chmod --reference=.env "$temporary_env"
|
||||
mv "$temporary_env" .env
|
||||
elif [[ -n "$configured_image_tag" ]]; then
|
||||
[[ "$configured_image_tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || fail ".env 中的 CANVAS_IMAGE_TAG 无效"
|
||||
[[ "$configured_image_tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ && "$configured_image_tag" != "latest" ]] || fail ".env 中的 CANVAS_IMAGE_TAG 必须固定为具体 Release"
|
||||
CANVAS_IMAGE_TAG="${configured_image_tag#v}"
|
||||
else
|
||||
fail "请通过 CANVAS_IMAGE_TAG 指定具体 Release,例如 v1.5.7.1"
|
||||
fi
|
||||
CANVAS_BACKEND_IMAGE="${IMAGE_REPOSITORY}-backend:${CANVAS_IMAGE_TAG}"
|
||||
CANVAS_WEB_IMAGE="${IMAGE_REPOSITORY}-web:${CANVAS_IMAGE_TAG}"
|
||||
set_env_value .env CANVAS_IMAGE_TAG "$CANVAS_IMAGE_TAG"
|
||||
set_env_value .env CANVAS_BACKEND_IMAGE "$CANVAS_BACKEND_IMAGE"
|
||||
set_env_value .env CANVAS_WEB_IMAGE "$CANVAS_WEB_IMAGE"
|
||||
return
|
||||
fi
|
||||
|
||||
[[ -n "$CANVAS_IMAGE_TAG" ]] || fail "首次安装必须通过 CANVAS_IMAGE_TAG 指定具体 Release,例如 v1.5.7.1"
|
||||
[[ "$CANVAS_IMAGE_TAG" != "latest" ]] || fail "生产部署必须固定到具体 Release,不能使用 latest"
|
||||
|
||||
step "生成 PostgreSQL 随机密码和部署配置"
|
||||
local database_password
|
||||
database_password="$(openssl rand -hex 32)"
|
||||
@@ -123,6 +138,8 @@ POSTGRES_PASSWORD=${database_password}
|
||||
DATABASE_URL=postgresql://open_ai_canvas:${database_password}@postgres:5432/open_ai_canvas?sslmode=disable
|
||||
CANVAS_HTTP_PORT=${CANVAS_HTTP_PORT}
|
||||
CANVAS_IMAGE_TAG=${CANVAS_IMAGE_TAG}
|
||||
CANVAS_BACKEND_IMAGE=${IMAGE_REPOSITORY}-backend:${CANVAS_IMAGE_TAG}
|
||||
CANVAS_WEB_IMAGE=${IMAGE_REPOSITORY}-web:${CANVAS_IMAGE_TAG}
|
||||
CANVAS_REGISTRATION_ENABLED=false
|
||||
CANVAS_ALLOW_PRIVATE_UPSTREAMS=false
|
||||
CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS=
|
||||
@@ -130,6 +147,22 @@ CANVAS_CORS_ORIGINS=
|
||||
EOF
|
||||
}
|
||||
|
||||
set_env_value() {
|
||||
local path="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
local temporary
|
||||
temporary="$(mktemp "${path}.XXXXXX")"
|
||||
awk -v key="$key" -v value="$value" '
|
||||
BEGIN { updated=0 }
|
||||
$0 ~ "^" key "=" { print key "=" value; updated=1; next }
|
||||
{ print }
|
||||
END { if (!updated) print key "=" value }
|
||||
' "$path" > "$temporary"
|
||||
chmod --reference="$path" "$temporary"
|
||||
mv "$temporary" "$path"
|
||||
}
|
||||
|
||||
download_compose() {
|
||||
step "下载 GHCR 镜像部署配置"
|
||||
local temporary_file
|
||||
@@ -139,10 +172,6 @@ download_compose() {
|
||||
}
|
||||
|
||||
install_host_updater() {
|
||||
if [[ "$CANVAS_IMAGE_TAG" == "latest" ]]; then
|
||||
printf '\n提示:CANVAS_IMAGE_TAG=latest,已跳过在线更新器安装。固定到具体发布版本后可再次运行本脚本。\n'
|
||||
return
|
||||
fi
|
||||
step "安装宿主机在线更新服务"
|
||||
local installer
|
||||
installer="$(mktemp)"
|
||||
@@ -156,6 +185,13 @@ start_services() {
|
||||
if ! docker compose --env-file .env -f "$COMPOSE_FILE" pull; then
|
||||
fail "GHCR 镜像拉取失败;如果容器包尚未公开,请通过 GHCR_USERNAME 和 GHCR_TOKEN 登录后重试"
|
||||
fi
|
||||
local backend_digest web_digest
|
||||
backend_digest="$(docker image inspect "$CANVAS_BACKEND_IMAGE" --format '{{range .RepoDigests}}{{println .}}{{end}}' | awk -v repository="${IMAGE_REPOSITORY}-backend" '$0 ~ "^" repository "@sha256:" { print; exit }')"
|
||||
web_digest="$(docker image inspect "$CANVAS_WEB_IMAGE" --format '{{range .RepoDigests}}{{println .}}{{end}}' | awk -v repository="${IMAGE_REPOSITORY}-web" '$0 ~ "^" repository "@sha256:" { print; exit }')"
|
||||
[[ "$backend_digest" =~ ^${IMAGE_REPOSITORY//\//\/}-backend@sha256:[a-f0-9]{64}$ ]] || fail "后端镜像未返回可验证的仓库 digest"
|
||||
[[ "$web_digest" =~ ^${IMAGE_REPOSITORY//\//\/}-web@sha256:[a-f0-9]{64}$ ]] || fail "Web 镜像未返回可验证的仓库 digest"
|
||||
set_env_value .env CANVAS_BACKEND_IMAGE "$backend_digest"
|
||||
set_env_value .env CANVAS_WEB_IMAGE "$web_digest"
|
||||
docker compose --env-file .env -f "$COMPOSE_FILE" up -d --remove-orphans --wait --wait-timeout 600
|
||||
}
|
||||
|
||||
@@ -179,8 +215,8 @@ main() {
|
||||
login_ghcr
|
||||
prepare_environment
|
||||
download_compose
|
||||
install_host_updater
|
||||
start_services
|
||||
install_host_updater
|
||||
print_result
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import "streamdown/styles.css";
|
||||
type AIMessageMarkdownProps = {
|
||||
children: string;
|
||||
isStreaming?: boolean;
|
||||
streamingAnimation?: "word" | "char" | "none";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -36,7 +37,7 @@ function buildComponents(isStreaming: boolean): Components {
|
||||
const staticComponents = buildComponents(false);
|
||||
const streamingComponents = buildComponents(true);
|
||||
|
||||
export const AIMessageMarkdown = memo(function AIMessageMarkdown({ children, isStreaming = false, className = "" }: AIMessageMarkdownProps) {
|
||||
export const AIMessageMarkdown = memo(function AIMessageMarkdown({ children, isStreaming = false, streamingAnimation = "word", className = "" }: AIMessageMarkdownProps) {
|
||||
if (!children.trim()) return null;
|
||||
return (
|
||||
<Streamdown
|
||||
@@ -44,7 +45,7 @@ export const AIMessageMarkdown = memo(function AIMessageMarkdown({ children, isS
|
||||
mode="streaming"
|
||||
dir="auto"
|
||||
isAnimating={isStreaming}
|
||||
animated={isStreaming ? { animation: "fadeIn", duration: 140, sep: "word", stagger: 8 } : false}
|
||||
animated={isStreaming && streamingAnimation !== "none" ? { animation: "fadeIn", duration: streamingAnimation === "char" ? 120 : 140, sep: streamingAnimation, stagger: streamingAnimation === "char" ? 16 : 8 } : false}
|
||||
parseIncompleteMarkdown
|
||||
skipHtml
|
||||
lineNumbers={false}
|
||||
|
||||
@@ -5,27 +5,60 @@ import { getAuthSession, type AuthSessionPayload } from "@/services/api/auth";
|
||||
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
|
||||
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics";
|
||||
|
||||
export function AuthSessionHydrator({ children }: { children: ReactNode }) {
|
||||
const hydrated = useUserStore((state) => state.hydrated);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const startedAt = performance.now();
|
||||
recordDiagnosticEvent({ category: "navigation", level: "info", code: "startup.auth_session_started", message: "开始恢复认证会话" });
|
||||
getAuthSession()
|
||||
.then(async (payload) => {
|
||||
if (cancelled) return;
|
||||
recordDiagnosticEvent({
|
||||
category: "navigation",
|
||||
level: "info",
|
||||
code: "startup.auth_session_ready",
|
||||
message: payload.user ? "认证会话已恢复" : "匿名会话已确认",
|
||||
durationMs: performance.now() - startedAt,
|
||||
});
|
||||
if (!payload.user) {
|
||||
applyAnonymousSession(payload);
|
||||
recordDiagnosticEvent({
|
||||
category: "navigation",
|
||||
level: "info",
|
||||
code: "startup.anonymous_ready",
|
||||
message: "匿名页面已解除启动阻塞",
|
||||
durationMs: performance.now() - startedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 账号数据、画布和素材持久化只属于已登录工作区,登录页不下载这些模块。
|
||||
const { applyUserSession } = await import("@/lib/user-session");
|
||||
if (cancelled) return;
|
||||
await applyUserSession(payload);
|
||||
recordDiagnosticEvent({
|
||||
category: "navigation",
|
||||
level: "info",
|
||||
code: "startup.workspace_ready",
|
||||
message: "登录工作区已解除启动阻塞",
|
||||
durationMs: performance.now() - startedAt,
|
||||
});
|
||||
preloadWorkspaceRoute(window.location.pathname);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) applyAnonymousSession({ user: null, logicalModels: [] });
|
||||
if (!cancelled) {
|
||||
applyAnonymousSession({ user: null });
|
||||
recordDiagnosticEvent({
|
||||
category: "navigation",
|
||||
level: "warning",
|
||||
code: "startup.auth_session_failed",
|
||||
message: "认证会话恢复失败,已降级为匿名页面",
|
||||
durationMs: performance.now() - startedAt,
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -100,6 +100,7 @@ export function AgentChatMessage({
|
||||
}) {
|
||||
const isUser = item.role === "user";
|
||||
const isSystem = item.role === "system";
|
||||
const displayedText = useTypewriterText(item.text, item.role === "assistant" && isStreaming);
|
||||
const color = item.role === "error" ? "#ef4444" : theme.node.text;
|
||||
if (item.reasoning) {
|
||||
return (
|
||||
@@ -183,8 +184,8 @@ export function AgentChatMessage({
|
||||
</span>
|
||||
) : null}
|
||||
{item.role === "assistant" ? (
|
||||
<AIMessageMarkdown className="text-left" isStreaming={isStreaming}>
|
||||
{item.text}
|
||||
<AIMessageMarkdown className="text-left" isStreaming={isStreaming} streamingAnimation="none">
|
||||
{displayedText}
|
||||
</AIMessageMarkdown>
|
||||
) : (
|
||||
<AgentMessageText text={item.text} references={references} />
|
||||
@@ -196,6 +197,71 @@ export function AgentChatMessage({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent SSE events contain text chunks. Keep the full text in the message
|
||||
* state, but reveal one code point at a time so a chunk never appears as a
|
||||
* whole block. The loop continues briefly after the stream ends to drain any
|
||||
* text that was buffered by the network.
|
||||
*/
|
||||
function useTypewriterText(targetText: string, shouldAnimate: boolean) {
|
||||
const targetRef = useRef(targetText);
|
||||
const visibleRef = useRef(shouldAnimate ? "" : targetText);
|
||||
const hasAnimatedRef = useRef(shouldAnimate);
|
||||
const runningRef = useRef(false);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const [visibleText, setVisibleText] = useState(visibleRef.current);
|
||||
|
||||
targetRef.current = targetText;
|
||||
|
||||
const startLoop = useCallback(() => {
|
||||
if (runningRef.current) return;
|
||||
runningRef.current = true;
|
||||
|
||||
const step = () => {
|
||||
const target = targetRef.current;
|
||||
const targetCharacters = Array.from(target);
|
||||
const visibleCharacters = Array.from(visibleRef.current);
|
||||
const visibleIsPrefix = target.startsWith(visibleRef.current);
|
||||
|
||||
if (!visibleIsPrefix || visibleCharacters.length > targetCharacters.length) {
|
||||
visibleRef.current = "";
|
||||
setVisibleText("");
|
||||
}
|
||||
|
||||
const currentCharacters = Array.from(visibleRef.current);
|
||||
if (currentCharacters.length >= targetCharacters.length) {
|
||||
runningRef.current = false;
|
||||
timerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextText = targetCharacters.slice(0, currentCharacters.length + 1).join("");
|
||||
visibleRef.current = nextText;
|
||||
setVisibleText(nextText);
|
||||
timerRef.current = window.setTimeout(step, 16);
|
||||
};
|
||||
|
||||
step();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAnimate) hasAnimatedRef.current = true;
|
||||
if (!hasAnimatedRef.current && !shouldAnimate) {
|
||||
visibleRef.current = targetText;
|
||||
setVisibleText(targetText);
|
||||
return;
|
||||
}
|
||||
startLoop();
|
||||
}, [shouldAnimate, startLoop, targetText]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
|
||||
runningRef.current = false;
|
||||
}, []);
|
||||
|
||||
return visibleText;
|
||||
}
|
||||
|
||||
function AgentMessageText({ text, references }: { text: string; references: CanvasResourceReference[] }) {
|
||||
const parts = splitAgentMessageMentions(text, references);
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,7 @@ export function useWorkspaceLogout() {
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
await logout();
|
||||
await applyUserSession({ user: null, logicalModels: [] });
|
||||
await applyUserSession({ user: null });
|
||||
message.success("已退出登录");
|
||||
navigate("/login", { replace: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import localforage from "localforage";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
|
||||
import { scopedStorageKey } from "@/lib/user-scope";
|
||||
import { isUserScopedPersistenceSuppressed, scopedStorageKey } from "@/lib/user-scope";
|
||||
|
||||
localforage.config({
|
||||
name: "infinite-canvas",
|
||||
@@ -24,10 +24,12 @@ export function localForageStorageForScope(scope?: string): StateStorage {
|
||||
},
|
||||
setItem: async (name, value) => {
|
||||
if (!browserStorageAvailable()) return;
|
||||
if (isUserScopedPersistenceSuppressed()) return;
|
||||
await localforage.setItem(keyFor(name), value);
|
||||
},
|
||||
removeItem: async (name) => {
|
||||
if (!browserStorageAvailable()) return;
|
||||
if (isUserScopedPersistenceSuppressed()) return;
|
||||
await localforage.removeItem(keyFor(name));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
const ACTIVE_USER_SCOPE_KEY = "infinite-canvas:active-user-scope";
|
||||
const GUEST_SCOPE = "guest";
|
||||
let userScopedPersistenceSuppressionDepth = 0;
|
||||
|
||||
export function withUserScopedPersistenceSuppressed<T>(operation: () => T) {
|
||||
userScopedPersistenceSuppressionDepth += 1;
|
||||
try {
|
||||
return operation();
|
||||
} finally {
|
||||
userScopedPersistenceSuppressionDepth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function isUserScopedPersistenceSuppressed() {
|
||||
return userScopedPersistenceSuppressionDepth > 0;
|
||||
}
|
||||
|
||||
export function getActiveUserScope() {
|
||||
if (typeof window === "undefined") return GUEST_SCOPE;
|
||||
@@ -22,10 +36,12 @@ export const scopedLocalStorage = {
|
||||
},
|
||||
setItem: (name: string, value: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (isUserScopedPersistenceSuppressed()) return;
|
||||
window.localStorage.setItem(scopedStorageKey(name), value);
|
||||
},
|
||||
removeItem: (name: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (isUserScopedPersistenceSuppressed()) return;
|
||||
window.localStorage.removeItem(scopedStorageKey(name));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/
|
||||
import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog } from "@/services/api/logical-models";
|
||||
import { localForageStorage } from "@/lib/localforage-storage";
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
|
||||
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { scopedLocalStorage, setActiveUserScope, withUserScopedPersistenceSuppressed } from "@/lib/user-scope";
|
||||
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore, withCanvasStorePersistenceSuppressed } from "@/stores/canvas/use-canvas-store";
|
||||
import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
|
||||
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
|
||||
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore, withAssetStorePersistenceSuppressed } from "@/stores/use-asset-store";
|
||||
import { CONFIG_STORE_KEY, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { CREATION_PREFERENCES_STORE_KEY, useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
|
||||
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
@@ -16,6 +16,7 @@ import { initializeRemoteUserDataSession, installRemoteUserDataAutoSync, resetRe
|
||||
import { clearResourceAccessCache } from "@/services/api/resources";
|
||||
import { clearResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle";
|
||||
import { recordDiagnosticEvent } from "@/services/diagnostics/client-diagnostics";
|
||||
|
||||
export async function switchUserStorageScope(userId?: string | null) {
|
||||
await withGenerationConsumersPaused(async () => {
|
||||
@@ -29,7 +30,10 @@ export async function switchUserStorageScope(userId?: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
let sessionGeneration = 0;
|
||||
|
||||
export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
const generation = ++sessionGeneration;
|
||||
const previousUserId = useUserStore.getState().user?.id || "";
|
||||
const nextUserId = payload.user?.id || "";
|
||||
useUserStore.getState().setHydrated(false);
|
||||
@@ -37,36 +41,68 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
// Query key 不携带用户 ID;身份变化时必须取消并清空旧账号请求,避免跨账号复用内存数据。
|
||||
if (previousUserId !== nextUserId) appQueryClient.clear();
|
||||
await switchUserStorageScope(payload.user?.id);
|
||||
const [persistedCanvas, persistedCanvasHistory, persistedAssets, persistedPlugins] = await Promise.all([
|
||||
localForageStorage.getItem(CANVAS_STORE_KEY),
|
||||
localForageStorage.getItem(CANVAS_HISTORY_STORE_KEY),
|
||||
localForageStorage.getItem(ASSET_STORE_KEY),
|
||||
localForageStorage.getItem(PLUGIN_STORE_KEY),
|
||||
]);
|
||||
const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY);
|
||||
const persistedCreationPreferences = scopedLocalStorage.getItem(CREATION_PREFERENCES_STORE_KEY);
|
||||
usePluginStore.setState({ hydrated: false, runtimeStatuses: {}, pluginStates: {} });
|
||||
// 切换存储 scope 时 user store 仍保留旧身份;此处只能先检查 generation,
|
||||
// 否则登录后的新用户会被误判为过期会话,hydrated 永远无法解除。
|
||||
if (!isCurrentGeneration(generation)) return;
|
||||
withCanvasStorePersistenceSuppressed(() => withAssetStorePersistenceSuppressed(() => withUserScopedPersistenceSuppressed(() => {
|
||||
resetUserScopedMemory();
|
||||
})));
|
||||
useUserStore.getState().setUser(payload.user);
|
||||
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
|
||||
useUserStore.getState().setDrawingEngine(payload.drawingEngine);
|
||||
useUserStore.getState().setFeatures(payload.features);
|
||||
await Promise.all([
|
||||
useCanvasStore.persist.rehydrate(),
|
||||
useCanvasHistoryStore.persist.rehydrate(),
|
||||
useAssetStore.persist.rehydrate(),
|
||||
useConfigStore.persist.rehydrate(),
|
||||
usePluginStore.persist.rehydrate(),
|
||||
useCreationPreferencesStore.persist.rehydrate(),
|
||||
]);
|
||||
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
|
||||
if (!persistedCanvas) useCanvasStore.setState({ projects: [] });
|
||||
if (!persistedCanvasHistory) useCanvasHistoryStore.setState({ deletedProjects: [] });
|
||||
if (!persistedAssets) useAssetStore.setState({ assets: [] });
|
||||
if (!persistedPlugins) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} });
|
||||
if (!persistedCreationPreferences) useCreationPreferencesStore.setState({ preferences: {} });
|
||||
installRemoteUserDataAutoSync();
|
||||
useUserStore.getState().setHydrated(true);
|
||||
void hydrateUserSessionData(payload, generation).catch((error) => {
|
||||
if (isCurrentSession(generation, nextUserId)) console.warn("用户工作区后台初始化失败,基础页面仍可继续", error);
|
||||
});
|
||||
} finally {
|
||||
if (useUserStore.getState().hydrated === false && isCurrentGeneration(generation)) useUserStore.getState().setHydrated(true);
|
||||
}
|
||||
}
|
||||
|
||||
function resetUserScopedMemory() {
|
||||
useCanvasStore.setState({ projects: [], hydrated: false });
|
||||
useCanvasHistoryStore.setState({ deletedProjects: [] });
|
||||
useAssetStore.setState({ assets: [], hydrated: false });
|
||||
useConfigStore.setState({ config: normalizeConfigSnapshot({ config: defaultConfig }).config });
|
||||
usePluginStore.setState({ hydrated: false, installations: [], runtimeStatuses: {}, pluginStates: {} });
|
||||
useCreationPreferencesStore.setState({ hydrated: false, preferences: {} });
|
||||
}
|
||||
|
||||
async function hydrateUserSessionData(payload: AuthSessionPayload, generation: number) {
|
||||
const startedAt = performance.now();
|
||||
const userId = payload.user?.id || "";
|
||||
const [canvas, canvasHistory, assets, plugins] = await Promise.allSettled([
|
||||
localForageStorage.getItem(CANVAS_STORE_KEY),
|
||||
localForageStorage.getItem(CANVAS_HISTORY_STORE_KEY),
|
||||
localForageStorage.getItem(ASSET_STORE_KEY),
|
||||
localForageStorage.getItem(PLUGIN_STORE_KEY),
|
||||
]);
|
||||
if (!isCurrentSession(generation, userId)) return;
|
||||
const persistedConfig = safeScopedStorageGet(CONFIG_STORE_KEY);
|
||||
const persistedCreationPreferences = safeScopedStorageGet(CREATION_PREFERENCES_STORE_KEY);
|
||||
await Promise.allSettled([
|
||||
useCanvasStore.persist.rehydrate(),
|
||||
useCanvasHistoryStore.persist.rehydrate(),
|
||||
useAssetStore.persist.rehydrate(),
|
||||
useConfigStore.persist.rehydrate(),
|
||||
usePluginStore.persist.rehydrate(),
|
||||
useCreationPreferencesStore.persist.rehydrate(),
|
||||
]);
|
||||
if (!isCurrentSession(generation, userId)) return;
|
||||
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
|
||||
if (!hasPersistedValue(canvas)) useCanvasStore.setState({ projects: [] });
|
||||
if (!hasPersistedValue(canvasHistory)) useCanvasHistoryStore.setState({ deletedProjects: [] });
|
||||
if (!hasPersistedValue(assets)) useAssetStore.setState({ assets: [] });
|
||||
if (!hasPersistedValue(plugins)) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} });
|
||||
if (!persistedCreationPreferences) useCreationPreferencesStore.setState({ preferences: {} });
|
||||
|
||||
try {
|
||||
const catalog = await getModelCatalog();
|
||||
if (!isCurrentSession(generation, userId)) return;
|
||||
if (!persistedConfig) {
|
||||
// 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。
|
||||
const catalog = await getModelCatalog();
|
||||
const initialSystemConfig = {
|
||||
...defaultConfig,
|
||||
channels: modelCatalogChannels(catalog),
|
||||
@@ -77,15 +113,50 @@ export async function applyUserSession(payload: AuthSessionPayload) {
|
||||
};
|
||||
useConfigStore.getState().replaceConfig(normalizeConfigSnapshot({ config: initialSystemConfig }).config);
|
||||
} else {
|
||||
const catalog = await getModelCatalog();
|
||||
useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog));
|
||||
}
|
||||
installRemoteUserDataAutoSync();
|
||||
if (payload.user?.id) {
|
||||
await initializeRemoteUserDataSession(payload.user.id);
|
||||
} else resetRemoteUserDataSync();
|
||||
} finally {
|
||||
useUserStore.getState().setHydrated(true);
|
||||
} catch (error) {
|
||||
if (isCurrentSession(generation, userId)) console.warn("模型目录后台刷新失败,保留本地配置", error);
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
try {
|
||||
await initializeRemoteUserDataSession(userId);
|
||||
} catch (error) {
|
||||
if (isCurrentSession(generation, userId)) console.warn("远端用户数据后台同步初始化失败", error);
|
||||
}
|
||||
} else {
|
||||
resetRemoteUserDataSync();
|
||||
}
|
||||
if (isCurrentSession(generation, userId)) {
|
||||
recordDiagnosticEvent({
|
||||
category: "navigation",
|
||||
level: "info",
|
||||
code: "startup.workspace_background_ready",
|
||||
message: "工作区后台状态初始化完成",
|
||||
durationMs: performance.now() - startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentSession(generation: number, userId: string) {
|
||||
return generation === sessionGeneration && (useUserStore.getState().user?.id || "") === userId;
|
||||
}
|
||||
|
||||
function isCurrentGeneration(generation: number) {
|
||||
return generation === sessionGeneration;
|
||||
}
|
||||
|
||||
function hasPersistedValue(result: PromiseSettledResult<unknown>) {
|
||||
return result.status === "fulfilled" && result.value !== null && result.value !== undefined;
|
||||
}
|
||||
|
||||
function safeScopedStorageGet(key: string) {
|
||||
try {
|
||||
return scopedLocalStorage.getItem(key);
|
||||
} catch (error) {
|
||||
console.warn("读取用户本地配置失败", { key, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,5 +11,6 @@ if (/^\/welcome\/?$/.test(window.location.pathname)) void import("./welcome-appl
|
||||
else {
|
||||
// The backend-free DEV lab must not make requests before AppProviders isolates it.
|
||||
const appearanceReady = isIsolatedDirectorRepro(import.meta.env.DEV, window.location.pathname) ? Promise.resolve() : bootstrapAppearance();
|
||||
void appearanceReady.then(() => import("./application"));
|
||||
void import("./application");
|
||||
void appearanceReady.catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { GenerationTask, TaskStatus } from "@/services/api/task-center";
|
||||
import type { CanvasDrawingEngineSetting } from "@/lib/canvas/canvas-drawing-engine";
|
||||
import type { FeatureAvailability } from "@/stores/use-user-store";
|
||||
import { http, apiBaseURL } from "@/services/api/request";
|
||||
import type { PublicLogicalModel } from "@/services/api/logical-models";
|
||||
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
|
||||
import type { VerificationPolicy } from "./verification";
|
||||
|
||||
@@ -42,7 +41,6 @@ export type AdminUser = LocalUser & {
|
||||
|
||||
export type AuthSessionPayload = {
|
||||
user: LocalUser | null;
|
||||
logicalModels?: PublicLogicalModel[];
|
||||
runtimeLimits?: RuntimeLimits;
|
||||
drawingEngine?: CanvasDrawingEngineSetting;
|
||||
features?: FeatureAvailability;
|
||||
|
||||
@@ -111,8 +111,9 @@ export function initializeClientDiagnostics() {
|
||||
export function setDiagnosticUserScope(userId: string) {
|
||||
const next = userId.trim();
|
||||
if (next === scopedUserId) return;
|
||||
const preserveStartupEvents = !scopedUserId && Boolean(next);
|
||||
scopedUserId = next;
|
||||
events.length = 0;
|
||||
if (!preserveStartupEvents) events.length = 0;
|
||||
activeTraceId = createDiagnosticId("trace");
|
||||
}
|
||||
|
||||
@@ -153,6 +154,7 @@ export function getDiagnosticRuntime() {
|
||||
return {
|
||||
appVersion: String(import.meta.env.VITE_APP_VERSION || "dev"),
|
||||
buildCommit: String(import.meta.env.VITE_BUILD_COMMIT || "unknown"),
|
||||
buildTime: String(import.meta.env.VITE_BUILD_TIME || "unknown"),
|
||||
browser: typeof navigator !== "undefined" ? navigator.userAgent : "",
|
||||
os: typeof navigator !== "undefined" ? navigator.platform : "",
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
|
||||
@@ -10,6 +10,7 @@ export type DiagnosticExportInput = {
|
||||
runtime: {
|
||||
appVersion?: string;
|
||||
buildCommit?: string;
|
||||
buildTime?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
timezone?: string;
|
||||
|
||||
@@ -96,7 +96,7 @@ function recordAssetStorageDocument(scope: string, document: AssetStorageDocumen
|
||||
});
|
||||
}
|
||||
|
||||
function withAssetStorePersistenceSuppressed<T>(operation: () => T) {
|
||||
export function withAssetStorePersistenceSuppressed<T>(operation: () => T) {
|
||||
suppressAssetStorePersistence += 1;
|
||||
try {
|
||||
return operation();
|
||||
|
||||
6
web/src/vite-env.d.ts
vendored
6
web/src/vite-env.d.ts
vendored
@@ -2,3 +2,9 @@
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
declare const __APP_CHANGELOG__: string;
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_VERSION?: string;
|
||||
readonly VITE_BUILD_COMMIT?: string;
|
||||
readonly VITE_BUILD_TIME?: string;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { defineConfig } from "vite";
|
||||
|
||||
const webDir = dirname(fileURLToPath(import.meta.url));
|
||||
const appVersion = process.env.CANVAS_BUILD_VERSION?.trim() || readFileSync(resolve(webDir, "../VERSION"), "utf8").trim();
|
||||
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";
|
||||
|
||||
@@ -15,6 +17,8 @@ export default defineConfig({
|
||||
__APP_VERSION__: JSON.stringify(appVersion),
|
||||
__APP_CHANGELOG__: JSON.stringify(appChangelog),
|
||||
"import.meta.env.VITE_APP_VERSION": JSON.stringify(appVersion),
|
||||
"import.meta.env.VITE_BUILD_COMMIT": JSON.stringify(buildCommit),
|
||||
"import.meta.env.VITE_BUILD_TIME": JSON.stringify(buildTime),
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user