From 72d851c52b4aa367e8b2e3ce4c09330095c8823d Mon Sep 17 00:00:00 2001
From: ddCat <2695092045@qq.com>
Date: Thu, 20 Aug 2026 19:48:02 +0800
Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=9E=84=E5=BB=BA=E4=B8=8E?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=20-=20=E4=BF=AE=E5=A4=8D=E4=BC=9A=E8=AF=9D?=
=?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=92=8C=E5=9B=BE=E6=A0=87=E4=BE=9D=E8=B5=96?=
=?UTF-8?q?=E5=85=BC=E5=AE=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
design-research-folder-card.md | 655 ----------
logical-model-routing-code-review.md | 1146 -----------------
.../components/canvas/canvas-connections.tsx | 2 +-
web/src/components/canvas/canvas-node.tsx | 2 +-
.../components/canvas/canvas-script-node.tsx | 19 +-
.../layout/workspace-sidebar-footer.tsx | 2 +-
web/src/components/model-logo.tsx | 11 +-
web/src/lib/canvas/canvas-project-domain.ts | 2 +-
.../lib/canvas/canvas-storyboard-layout.ts | 24 +
web/src/pages/canvas/project.tsx | 3 +-
10 files changed, 40 insertions(+), 1826 deletions(-)
delete mode 100644 design-research-folder-card.md
delete mode 100644 logical-model-routing-code-review.md
create mode 100644 web/src/lib/canvas/canvas-storyboard-layout.ts
diff --git a/design-research-folder-card.md b/design-research-folder-card.md
deleted file mode 100644
index 83509d30f..000000000
--- a/design-research-folder-card.md
+++ /dev/null
@@ -1,655 +0,0 @@
-# 画布列表卡片设计调研报告
-
-## 一、图片风格分析
-
-### 核心设计元素识别
-
-通过对提供的文件夹卡片图片分析,识别出以下关键设计特征:
-
-#### 1. **玻璃拟态(Glassmorphism)效果**
-- 半透明背景:使用 `backdrop-filter: blur()` 实现背景模糊
-- 磨砂玻璃质感:白色半透明叠加层(rgba(255,255,255,0.1) ~ 0.15)
-- 边框高光:顶部和左侧有微妙的白色边框(1px solid rgba(255,255,255,0.18))
-
-#### 2. **渐变网格背景(Gradient Mesh)**
-- 每个卡片顶部有独特的渐变色带(红橙、蓝青、橙红等色系)
-- 渐变呈现流动的、非线性的网格状分布
-- 类似 macOS Ventura 的壁纸风格,具有深度和立体感
-- 渐变从顶部向下过渡到白色/透明
-
-#### 3. **多层阴影系统**
-- **主阴影**:较大的扩散阴影 `0 8px 32px rgba(0,0,0,0.12)`
-- **环境阴影**:模拟物理深度 `0 2px 8px rgba(0,0,0,0.06)`
-- **内阴影**:卡片内部微妙的阴影增强立体感
-- 悬停时阴影加深并向上提升:`transform: translateY(-4px)`
-
-#### 4. **圆角处理**
-- 大圆角设计:约 `16px - 20px` (r-2xl ~ r-3xl)
-- 所有元素保持一致的圆角系统
-- 内部按钮使用更小的圆角(8-12px)
-
-#### 5. **布局结构**
-- **三态展示**:
- 1. 正常态(左上):紧凑文件夹样式 + 悬浮按钮
- 2. 堆叠态(右侧):多层卡片堆叠效果,展示深度
- 3. 展开态(左下):完整信息展示 + 时间戳
-
-#### 6. **交互反馈**
-- 悬停提升效果(Y轴 -4px)
-- 鼠标跟随的光泽效果(Glare)
-- 3D 倾斜动画(轻微的 rotateX/rotateY)
-- 按钮悬浮显示/隐藏
-
----
-
-## 二、当前项目状态分析
-
-### 现有组件架构
-
-#### 1. **CanvasProjectCard** (`canvas-project-card.tsx`)
-```typescript
-// 当前结构
-
-
-
-
复选框
-
节点数量
-
-
-
-```
-
-**当前特点**:
-- 扁平化设计,缺少层次感
-- 简单的边框和阴影
-- 预览区域占比大(aspect-video)
-- 无玻璃拟态效果
-- 无渐变背景装饰
-
-#### 2. **CometCard** (`comet-card.tsx`)
-**已有能力**:
-- ✅ 3D 倾斜动画(rotateX/rotateY)
-- ✅ 鼠标跟随效果
-- ✅ 光泽层(glare)
-- ✅ 悬停提升动画
-- ✅ 平滑的弹簧动画
-
-**缺少**:
-- ❌ 玻璃拟态背景
-- ❌ 渐变网格装饰
-- ❌ 多层阴影系统
-
----
-
-## 三、Aceternity UI 调研
-
-### 相关组件清单
-
-#### 1. **3D Card Effect**
-- 组件路径:`/components/3d-card-effect`
-- 功能:鼠标跟随的3D倾斜效果
-- 与 CometCard 功能重叠,我们已有类似实现
-
-#### 2. **Card Hover Effect**
-- 组件路径:`/components/card-hover-effect`
-- 功能:悬停时的边框光晕和背景渐变
-- 可借鉴:边框渐变的实现方式
-
-#### 3. **Background Gradient**
-- 组件路径:`/components/background-gradient`
-- 功能:动态渐变背景
-- **重要**:可用于卡片顶部的渐变网格效果
-
-#### 4. **Meteors**
-- 组件路径:`/components/meteors`
-- 功能:流星划过的动画效果
-- 可选:为卡片增加动态装饰
-
-#### 5. **Glass Morphism**
-- 虽然 Aceternity 没有专门的玻璃拟态组件
-- 但在多个卡片组件中使用了 `backdrop-blur` 和半透明背景
-- 需要自行实现
-
----
-
-## 四、设计方案
-
-### 方案 A:渐进式升级(推荐)
-
-**目标**:在现有 CanvasProjectCard 基础上,逐步添加玻璃拟态和渐变效果。
-
-#### 4.1 布局设计
-
-**网格布局**:
-```css
-/* 响应式网格 */
-.canvas-library-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
- gap: var(--space-6); /* 24px */
- padding: var(--space-6);
-}
-
-/* 断点优化 */
-@media (min-width: 640px) {
- /* 2列:640px - 1024px */
- grid-template-columns: repeat(2, 1fr);
-}
-
-@media (min-width: 1024px) {
- /* 3列:1024px - 1440px */
- grid-template-columns: repeat(3, 1fr);
-}
-
-@media (min-width: 1440px) {
- /* 4列:>1440px */
- grid-template-columns: repeat(4, 1fr);
-}
-```
-
-**每行显示数量**:
-- 移动端(<640px):1列(自适应宽度)
-- 平板(640-1024px):2列
-- 桌面(1024-1440px):3列
-- 大屏(>1440px):4列
-
-#### 4.2 卡片尺寸
-
-```typescript
-// 卡片结构
-
- // 宽高比:16:10 (比现有的16:9更接近文件夹比例)
- // 最小宽度:280px
- // 最大宽度:360px(防止在大屏上过度拉伸)
-
-```
-
-**尺寸规范**:
-- **宽度**:280-360px(由网格自动控制)
-- **高度**:自适应(由内容决定)
-- **预览区高度**:aspect-[16/10] (~175-225px)
-- **内容区高度**:~80px(标题 + 元信息)
-
-#### 4.3 视觉效果分层
-
-```
-[层级从底到顶]
-├─ 1. 基础背景(白色/暗色,根据主题)
-├─ 2. 渐变网格层(顶部装饰)
-├─ 3. 玻璃拟态层(backdrop-blur + 半透明)
-├─ 4. 内容层(文字、图标、预览)
-├─ 5. 边框高光层(顶部和左侧)
-└─ 6. 光泽层(hover时的glare效果)
-```
-
----
-
-## 五、CSS 实现方案
-
-### 5.1 玻璃拟态基础样式
-
-```css
-.canvas-card-glass {
- position: relative;
- background: rgba(255, 255, 255, 0.08);
- backdrop-filter: blur(12px) saturate(180%);
- border: 1px solid rgba(255, 255, 255, 0.18);
- border-radius: var(--r-2xl); /* 16px */
- box-shadow:
- 0 8px 32px rgba(0, 0, 0, 0.12),
- 0 2px 8px rgba(0, 0, 0, 0.06),
- inset 0 1px 0 rgba(255, 255, 255, 0.1);
- transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
- box-shadow 0.3s ease;
-}
-
-/* 暗色主题 */
-.dark .canvas-card-glass {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.12);
- box-shadow:
- 0 8px 32px rgba(0, 0, 0, 0.4),
- 0 2px 8px rgba(0, 0, 0, 0.2),
- inset 0 1px 0 rgba(255, 255, 255, 0.08);
-}
-
-.canvas-card-glass:hover {
- transform: translateY(-4px);
- box-shadow:
- 0 16px 48px rgba(0, 0, 0, 0.18),
- 0 4px 12px rgba(0, 0, 0, 0.1),
- inset 0 1px 0 rgba(255, 255, 255, 0.15);
-}
-```
-
-### 5.2 渐变网格装饰
-
-```css
-.canvas-card-gradient-mesh {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 120px;
- border-radius: var(--r-2xl) var(--r-2xl) 0 0;
- overflow: hidden;
- pointer-events: none;
- z-index: 0;
-}
-
-.canvas-card-gradient-mesh::before {
- content: '';
- position: absolute;
- inset: 0;
- background:
- radial-gradient(circle at 20% 30%, rgba(255, 100, 80, 0.6) 0%, transparent 50%),
- radial-gradient(circle at 80% 20%, rgba(100, 180, 255, 0.5) 0%, transparent 50%),
- radial-gradient(circle at 50% 70%, rgba(255, 180, 100, 0.4) 0%, transparent 60%);
- filter: blur(40px);
- opacity: 0.8;
-}
-
-.canvas-card-gradient-mesh::after {
- content: '';
- position: absolute;
- inset: 0;
- background: linear-gradient(180deg,
- rgba(255,255,255,0) 0%,
- rgba(255,255,255,0.95) 80%
- );
-}
-
-/* 每张卡片随机不同的渐变色 */
-.canvas-card-gradient-mesh[data-variant="1"]::before {
- background:
- radial-gradient(circle at 25% 25%, rgba(255, 50, 100, 0.7) 0%, transparent 50%),
- radial-gradient(circle at 75% 30%, rgba(80, 120, 255, 0.6) 0%, transparent 50%);
-}
-
-.canvas-card-gradient-mesh[data-variant="2"]::before {
- background:
- radial-gradient(circle at 30% 20%, rgba(0, 200, 180, 0.6) 0%, transparent 50%),
- radial-gradient(circle at 70% 40%, rgba(100, 255, 150, 0.5) 0%, transparent 50%);
-}
-
-.canvas-card-gradient-mesh[data-variant="3"]::before {
- background:
- radial-gradient(circle at 20% 35%, rgba(255, 150, 0, 0.7) 0%, transparent 50%),
- radial-gradient(circle at 80% 25%, rgba(200, 50, 80, 0.6) 0%, transparent 50%);
-}
-```
-
-### 5.3 完整卡片样式
-
-```css
-.canvas-card-folder {
- position: relative;
- width: 100%;
- height: 100%;
- min-height: 280px;
- cursor: pointer;
- isolation: isolate;
-}
-
-.canvas-card-folder-inner {
- position: relative;
- width: 100%;
- height: 100%;
- background: rgba(255, 255, 255, 0.08);
- backdrop-filter: blur(12px) saturate(180%);
- border: 1px solid rgba(255, 255, 255, 0.18);
- border-radius: var(--r-2xl);
- overflow: hidden;
- box-shadow:
- 0 8px 32px rgba(0, 0, 0, 0.12),
- 0 2px 8px rgba(0, 0, 0, 0.06);
- transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
-}
-
-.canvas-card-folder:hover .canvas-card-folder-inner {
- transform: translateY(-4px) scale(1.01);
- box-shadow:
- 0 16px 48px rgba(0, 0, 0, 0.18),
- 0 4px 12px rgba(0, 0, 0, 0.1);
- border-color: rgba(255, 255, 255, 0.25);
-}
-
-/* 预览区域 */
-.canvas-card-preview {
- position: relative;
- width: 100%;
- aspect-ratio: 16 / 10;
- overflow: hidden;
- z-index: 1;
-}
-
-.canvas-card-preview img,
-.canvas-card-preview video {
- width: 100%;
- height: 100%;
- object-fit: cover;
- transition: transform 0.3s ease;
-}
-
-.canvas-card-folder:hover .canvas-card-preview img,
-.canvas-card-folder:hover .canvas-card-preview video {
- transform: scale(1.05);
-}
-
-/* 内容区域 */
-.canvas-card-content {
- position: relative;
- padding: var(--space-4);
- z-index: 1;
-}
-
-.canvas-card-title {
- font-size: var(--fs-body);
- font-weight: 600;
- line-height: 1.4;
- color: var(--color-neutral-900);
- margin-bottom: var(--space-2);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.dark .canvas-card-title {
- color: var(--color-neutral-50);
-}
-
-.canvas-card-meta {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- font-size: var(--fs-caption);
- color: var(--color-neutral-600);
-}
-
-.dark .canvas-card-meta {
- color: var(--color-neutral-400);
-}
-
-/* 悬浮按钮 */
-.canvas-card-action-button {
- position: absolute;
- bottom: var(--space-3);
- right: var(--space-3);
- width: 40px;
- height: 40px;
- display: grid;
- place-items: center;
- background: rgba(0, 0, 0, 0.6);
- backdrop-filter: blur(8px);
- border-radius: var(--r-lg);
- color: white;
- opacity: 0;
- transform: translateY(8px);
- transition: all 0.25s ease;
- z-index: 10;
-}
-
-.canvas-card-folder:hover .canvas-card-action-button {
- opacity: 1;
- transform: translateY(0);
-}
-
-.canvas-card-action-button:hover {
- background: rgba(0, 0, 0, 0.8);
- transform: scale(1.1);
-}
-```
-
----
-
-## 六、组件实现方案
-
-### 6.1 新建组件:CanvasFolderCard
-
-```tsx
-// web/src/components/canvas/canvas-folder-card.tsx
-
-import { motion } from "motion/react";
-import { Plus, MoreHorizontal } from "lucide-react";
-import { CometCard } from "@/components/ui/aceternity/comet-card";
-import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
-
-interface CanvasFolderCardProps {
- project: CanvasProject;
- variant?: number; // 1-3,决定渐变色方案
- onClick?: () => void;
- onAction?: () => void;
-}
-
-export function CanvasFolderCard({
- project,
- variant = 1,
- onClick,
- onAction
-}: CanvasFolderCardProps) {
- return (
-
-
- {/* 渐变网格装饰 */}
-
-
- {/* 预览区域 */}
-
-
- {/* 内容区域 */}
-
-
{project.title}
-
- {project.nodes.length} 节点
- ·
-
-
-
-
- {/* 悬浮按钮 */}
-
{
- e.stopPropagation();
- onAction?.();
- }}
- whileHover={{ scale: 1.1 }}
- whileTap={{ scale: 0.95 }}
- aria-label="更多操作"
- >
-
-
-
-
- );
-}
-
-// 预览组件保持原有逻辑
-function ProjectPreview({ project }: { project: CanvasProject }) {
- // ... 复用现有的 ProjectPreview 逻辑
-}
-
-function formatTime(value: string) {
- // ... 复用现有的时间格式化逻辑
-}
-```
-
-### 6.2 集成到现有页面
-
-```tsx
-// 在 pages/home/index.tsx 或 pages/canvas/index.tsx 中
-
-import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card";
-
-// 替换现有的 CanvasProjectCard
-
- {canvasProjects.map((project, index) => (
- navigate(`/canvas/${project.id}`)}
- onAction={() => handleMore(project)}
- />
- ))}
-
-```
-
----
-
-## 七、技术要点
-
-### 7.1 性能优化
-
-```typescript
-// 1. 渐变色方案预计算
-const GRADIENT_VARIANTS = [
- { id: 1, colors: ['rgba(255,50,100,0.7)', 'rgba(80,120,255,0.6)'] },
- { id: 2, colors: ['rgba(0,200,180,0.6)', 'rgba(100,255,150,0.5)'] },
- { id: 3, colors: ['rgba(255,150,0,0.7)', 'rgba(200,50,80,0.6)'] },
-];
-
-// 2. 使用 CSS 变量动态注入
-
-
-// 3. backdrop-filter 回退方案
-@supports not (backdrop-filter: blur(12px)) {
- .canvas-card-folder-inner {
- background: rgba(255, 255, 255, 0.95); /* 完全不透明 */
- }
-}
-```
-
-### 7.2 无障碍支持
-
-```tsx
-// 完整的 ARIA 标签
- e.key === 'Enter' && onClick?.()}
->
- {/* ... */}
-
-```
-
-### 7.3 暗色主题适配
-
-```css
-/* 自动适配当前主题 */
-@media (prefers-color-scheme: dark) {
- .canvas-card-folder-inner {
- background: rgba(255, 255, 255, 0.05);
- border-color: rgba(255, 255, 255, 0.12);
- }
-
- .canvas-card-gradient-mesh::after {
- background: linear-gradient(180deg,
- rgba(10, 10, 15, 0) 0%,
- rgba(10, 10, 15, 0.95) 80%
- );
- }
-}
-```
-
----
-
-## 八、实施建议
-
-### 阶段 1:基础重构(1-2天)
-1. ✅ 创建 `CanvasFolderCard` 组件
-2. ✅ 实现玻璃拟态样式
-3. ✅ 集成 `CometCard` 实现3D效果
-4. ✅ 调整网格布局为新的响应式方案
-
-### 阶段 2:视觉增强(2-3天)
-1. ✅ 添加渐变网格装饰层
-2. ✅ 实现多层阴影系统
-3. ✅ 优化悬停动画和过渡
-4. ✅ 添加光泽层(glare effect)
-
-### 阶段 3:细节打磨(1-2天)
-1. ✅ 暗色主题适配
-2. ✅ 无障碍测试和优化
-3. ✅ 性能测试(backdrop-filter 的性能影响)
-4. ✅ 跨浏览器兼容性测试
-
-### 阶段 4:用户测试(1天)
-1. ✅ A/B 测试(新旧卡片对比)
-2. ✅ 收集用户反馈
-3. ✅ 根据反馈微调
-
----
-
-## 九、风险和注意事项
-
-### 9.1 性能风险
-- **backdrop-filter 性能消耗**:在大量卡片(50+)时可能影响滚动性能
- - 解决方案:使用虚拟滚动或懒加载
- - 回退方案:低性能设备禁用 backdrop-filter
-
-### 9.2 兼容性
-- **Safari < 15**:backdrop-filter 支持不完整
- - 解决方案:检测支持度,提供回退样式
-
-### 9.3 可访问性
-- 确保玻璃拟态效果不影响文字对比度(WCAG AA 标准:4.5:1)
-- 光泽效果不应干扰屏幕阅读器
-
----
-
-## 十、参考资源
-
-### 设计灵感
-- macOS Ventura 文件夹设计
-- iOS 16+ 小组件玻璃拟态
-- Figma Community: Glassmorphism UI Kit
-
-### 技术文档
-- [MDN: backdrop-filter](https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter)
-- [CSS Tricks: Glassmorphism](https://css-tricks.com/glassmorphism/)
-- [Aceternity UI Components](https://ui.aceternity.com/components)
-
-### 现有代码
-- `/web/src/components/canvas/canvas-project-card.tsx`
-- `/web/src/components/ui/aceternity/comet-card.tsx`
-- `/web/src/styles/globals.css` (token system)
-
----
-
-## 总结
-
-这套设计方案结合了:
-1. **玻璃拟态**的现代感和层次感
-2. **渐变网格**的视觉吸引力和品牌差异化
-3. **3D交互**的沉浸感(已有CometCard支持)
-4. **响应式布局**的灵活性(1-4列自适应)
-
-建议采用**渐进式升级**策略,先实现核心玻璃拟态效果,再逐步添加渐变装饰和高级交互,确保每个阶段都能交付可用版本。
diff --git a/logical-model-routing-code-review.md b/logical-model-routing-code-review.md
deleted file mode 100644
index c73623a69..000000000
--- a/logical-model-routing-code-review.md
+++ /dev/null
@@ -1,1146 +0,0 @@
-# 逻辑模型路由层代码评审报告
-
-## 评审范围
-
-**评审对象**:GPT 编写的"逻辑模型路由层"实现
-**评审时间**:2026-08-18
-**评审方法**:逐行代码审核 + 市场调研(论文、GitHub 项目、技术论坛)
-**评审原则**:从产品可用性、用户便利性角度,基于证据判断,不接受设计声明
-
-## 核心发现
-
-本次评审发现 **3 个 P0 严重问题** 和 **3 个 P1 重要问题**,均有生产案例或官方文档支撑。
-
-GPT 在以下方面**偷懒或实现失真**:
-1. 用应用层 `SELECT MAX + 1` 而非数据库原子递增
-2. 用内存 map 而非 Redis 做分布式状态
-3. 直接 for 循环查询而非 Preload 批量加载
-4. 用不稳定的 JSON 序列化做去重 key
-
-修复后系统才能安全地水平扩展。
-
----
-
-## P0 严重问题
-
-### [P0-1] `MAX(version)+1` 并发竞态导致唯一索引冲突
-
-**位置**:`backend/internal/repository/logical_models.go:311-316`
-
-**现象**:
-```go
-func (r *Repository) SaveLogicalModelBundle(...) error {
- return r.db.Transaction(func(tx *gorm.DB) error {
- var latest struct{ Version int }
- if err := tx.Model(&model.LogicalModelRevision{}).
- Select("COALESCE(MAX(version), 0) AS version").
- Where("logical_model_id = ?", item.ID).Scan(&latest).Error; err != nil {
- return err
- }
- revision.Version = latest.Version + 1 // ← 竞态窗口
- if err := tx.Create(revision).Error; err != nil {
- return err
- }
-```
-
-**危害时序**:
-1. 请求A 读到 MAX=7,计算 next=8
-2. 请求B 读到 MAX=7(A 还没提交),计算 next=8
-3. A 先 INSERT version=8 成功
-4. B 再 INSERT version=8 → **违反 `uniqueIndex:idx_logical_revision_version`,事务回滚**
-
-这是典型的 TOCTOU(Time-of-Check to Time-of-Use)竞态条件。
-
-**证据**:
-- [DEV Community: SELECT MAX()+1 Is a Race Condition Waiting to Happen](https://dev.to/mattia_armas/select-max1-is-a-race-condition-waiting-to-happen-22m2)
- > "两个请求读到同一个 max,都计算同一个 next,唯一索引拒绝第二个。应用层锁在多实例下无效。"
-
-- [StackOverflow: INSERT INTO ... SELECT MAX + 1 returns DUPLICATE KEY](https://stackoverflow.com/questions/59756571)
- > "Use SERIAL / sequence,或者 `UPDATE counter SET c=c+1 RETURNING c` 原子递增。"
-
-**修复方向**:
-```sql
--- 方案1:用数据库原子递增列(推荐)
-ALTER TABLE logical_models ADD COLUMN next_revision_version INT DEFAULT 1;
-
-UPDATE logical_models SET next_revision_version = next_revision_version + 1
-WHERE id = ? RETURNING next_revision_version;
--- 拿到返回值赋给 revision.Version
-
--- 方案2:行级锁(如果必须 per-model gapless)
-SELECT version FROM logical_model_revisions
-WHERE logical_model_id = ?
-FOR UPDATE; -- 锁住该模型的所有版本行,阻塞并发
-```
-
----
-
-### [P0-2] RWMutex 读锁内执行写操作(删除 map 元素)
-
-**位置**:`backend/internal/service/model_router.go:554-570`
-
-**现象**:
-```go
-func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool {
- s.routeHealthMu.Lock()
- defer s.routeHealthMu.Unlock()
- for _, key := range keys {
- until, exists := s.routeHealthBlocked[key]
- if !exists { continue }
- if !until.After(now) {
- delete(s.routeHealthBlocked, key) // ← 删除操作
- continue
- }
- return true
- }
-}
-```
-
-**问题分析**:
-
-代码实际使用了 `Lock()`(写锁),这是**正确的**。但函数逻辑是"读取判断 + 条件删除",属于典型的"需要写锁但容易误用读锁"场景。
-
-**潜在风险**:
-
-函数命名和注释没有体现"会修改 map",容易被后续维护者改成 `RLock()` 优化读性能,从而引入 data race。
-
-**证据**:
-- [StackOverflow: concurrent map iteration and map write with RLock/RUnlock](https://stackoverflow.com/questions/71354301)
- > "RLock 只能读,任何写操作(包括 delete)必须用 Lock。map 的 delete 是写操作。"
-
-- [Go sync 官方文档](https://pkg.go.dev/sync#RWMutex)
- > "A RWMutex.RLock cannot be upgraded into a RWMutex.Lock"
- > 读锁无法升级为写锁,必须释放后重新获取。
-
-**修复方向**:
-1. 重命名函数:`logicalRouteBlockedAndPrune()` 明确"会删除过期项"
-2. 加注释:`// MUST hold write lock: prunes expired entries from routeHealthBlocked`
-3. 确保团队 review 时注意此模式
-
----
-
-### [P0-3] 单实例内存缓存在多实例部署下失效
-
-**位置**:`backend/internal/service/service.go:39-45` + `model_router.go:410-448`
-
-**现象**:
-```go
-type Service struct {
- routeCatalog *routeCatalogSnapshot // 单实例内存
- routeHealthBlocked map[string]time.Time // 单实例内存
-}
-
-func (s *Service) logicalRouteBlocked(...) bool {
- s.routeHealthMu.Lock()
- // 只查本实例的 routeHealthBlocked
-}
-```
-
-**危害场景**:
-
-| 场景 | 问题 | 用户体验 |
-|------|------|---------|
-| **429 冷却不生效** | 实例A 标记渠道冷却,实例B 不知道,继续向该渠道发请求 | 触发更多 429,用户请求失败率上升 |
-| **路由缓存不一致** | 管理员在实例A 更新模型配置,实例B 的 routeCatalog 30秒内不刷新 | 用户看到不同实例返回不同可用模型 |
-| **故障切换重复尝试** | 实例A 刚把 variant 标记为 blocked,实例B 立即分配请求到该 variant | 用户请求被分配到已知故障路由 |
-
-**证据**:
-- [Viktar Patotski: Redis Caching vs Local Cache](https://patotski.com/blog/redis-caching-vs-local-cache/)
- > "本地缓存 + 水平扩展 = N 个分歧的缓存。用户刷新页面,负载均衡分到不同实例,数据消失又出现。多实例下必须用 Redis 共享状态。"
-
-- [WayGate 文档: Distributed Deployments](https://attakay78.github.io/waygate/guides/distributed/)
- > "MemoryBackend: 实例A 禁用路由,实例B 继续服务该路由。FileBackend: 没有跨进程同步。RedisBackend: 所有实例通过 pub/sub 实时看到变更。"
-
-**修复方向**:
-
-```go
-// 1. service.go 增加 Redis 连接
-type Service struct {
- redis *redis.Client
- // ...
-}
-
-// 2. model_router.go logicalRouteBlocked 改为 Redis 查询
-func (s *Service) logicalRouteBlocked(route) bool {
- key := "route_health:" + route.ChannelModel.ChannelID
- until, err := s.redis.Get(ctx, key).Time()
- if err != nil { return false }
- return time.Now().Before(until)
-}
-
-// 3. blockLogicalRouteForFailure 改为 Redis 写入
-func (s *Service) blockLogicalRouteForFailure(...) {
- key := "route_health:" + attempt.ChannelID
- s.redis.SetEx(ctx, key, time.Now().Add(duration).Format(time.RFC3339), duration)
-}
-
-// 4. invalidateRouteCatalog 改为 Redis Pub/Sub
-func (s *Service) invalidateRouteCatalog(item) {
- s.redis.Publish(ctx, "route_catalog_invalidate", item.ID)
- // 每个实例订阅该 channel,收到后清空本地缓存
-}
-```
-
----
-
-## P1 重要问题
-
-### [P1-1] GORM N+1 查询导致管理员列表接口慢
-
-**位置**:`backend/internal/service/logical_models.go:179-230`
-
-**现象**:
-```go
-func (s *Service) AdminLogicalModels(actor *model.User) ([]AdminLogicalModel, error) {
- items, _ := s.repo.LogicalModels(true) // 1 query
- for _, item := range items {
- graph, _ := s.repo.LogicalModelGraph(item.ID, true) // N queries
- for _, route := range graph.Routes {
- _, channelErr := s.repo.SystemChannel(channelModel.ChannelID) // N*M queries
- }
- }
-}
-```
-
-**性能计算**:
-- 10 个模型,每个 5 条路由 → 1 + 10 + 50 = **61 条 SQL**
-- 100 个模型,每个平均 5 条路由 → 1 + 100 + 500 = **601 条 SQL**
-
-**证据**:
-- [Shinagawa Labs: Speeding Up Search Responses (Eliminating N+1)](https://shinagawa-web.com/en/works/go-gorm-n-plus-one-b2b-ticketing)
- > "列表API 1.4s→0.2s (7x 提速),减少 80% 查询。用 Preload 批量获取,用 IN clause 处理复杂条件。"
-
-- [GORM 文档: Preloading](https://gorm.io/docs/preload.html)
- > `db.Preload("Orders").Find(&users)` → 2 条 SQL 代替 1+N
-
-**修复方向**:
-
-```go
-// 1. repository/logical_models.go 增加预加载方法
-func (r *Repository) LogicalModelsWithPreload() ([]model.LogicalModel, error) {
- var items []model.LogicalModel
- err := r.db.Preload("ActiveRevision").
- Preload("ActiveRevision.Routes", func(db *gorm.DB) *gorm.DB {
- return db.Order("priority desc")
- }).
- Preload("ActiveRevision.Routes.Variant").
- Preload("ActiveRevision.Routes.Variant.ChannelModel").
- Order("sort_order asc, created_at asc").
- Find(&items).Error
- return items, err
-}
-
-// 2. service/logical_models.go AdminLogicalModels 改为
-items, _ := s.repo.LogicalModelsWithPreload()
-// 然后直接用 item.ActiveRevision.Routes 遍历,不再单独查询
-
-// 3. SystemChannel 查询改为批量
-channelIDs := collectUniqueChannelIDs(items)
-channels, _ := s.repo.SystemChannelsByIDs(channelIDs) // 1 条 SQL with IN
-channelMap := buildChannelMap(channels)
-// 后续用 channelMap[id] 查询
-
-// 最终:5 条 SQL 代替 61 条
-```
-
----
-
-### [P1-2] `json.Marshal` 做 map key 不稳定
-
-**位置**:`backend/internal/service/logical_models.go:161-176`
-
-**现象**:
-```go
-func publicLogicalModel(...) PublicLogicalModel {
- seen := make(map[string]bool)
- for _, route := range cached.Routes {
- encoded, _ := json.Marshal(route.VariantSpec) // map 序列化顺序不确定
- key := string(encoded)
- if !seen[key] { ... }
- }
-}
-```
-
-**危害**:
-- Go 1.24 前 `encoding/json` 的 map key 按字典序排序(稳定)
-- Go 1.24+ `encoding/json/v2` 默认非确定性排序
-- 两个能力相同的 spec 可能因 key 顺序不同生成不同 JSON
-- 去重失败 → 用户看到重复的 `capabilityProfiles`
-
-**证据**:
-- [Go encoding/json 文档](https://pkg.go.dev/encoding/json)
- > "In v1, Go map marshaled deterministically. In v2, non-deterministic by default. Use jsonv2.Deterministic."
-
-- [StackOverflow: How to produce JSON with sorted keys](https://stackoverflow.com/questions/18668652)
- > "json.Marshal 对 map key 排序,但 v2 改了默认行为。"
-
-**修复方向**:
-
-```go
-// 方案1:用结构化 hash(推荐)
-func capabilitySpecFingerprint(spec CapabilitySpec) string {
- var buf strings.Builder
- buf.WriteString(spec.Capability)
- buf.WriteString("|")
-
- // operations 排序
- ops := make([]string, len(spec.Operations))
- copy(ops, spec.Operations)
- sort.Strings(ops)
- buf.WriteString(strings.Join(ops, ","))
- buf.WriteString("|")
-
- // inputs 按 key 排序
- inputKeys := make([]string, 0, len(spec.Inputs))
- for k := range spec.Inputs {
- inputKeys = append(inputKeys, k)
- }
- sort.Strings(inputKeys)
- for _, k := range inputKeys {
- fmt.Fprintf(&buf, "%s:%d-%d,", k, spec.Inputs[k].Min, spec.Inputs[k].Max)
- }
- buf.WriteString("|")
-
- // options 按 key 排序(类似处理)
- // ...
-
- return buf.String()
-}
-
-// publicLogicalModel 中替换
-key := capabilitySpecFingerprint(route.VariantSpec)
-if !seen[key] { ... }
-```
-
----
-
-### [P1-3] prefixed ID `size:36` 设计浪费 + 可能溢出
-
-**位置**:`backend/internal/model/models_logical_model.go:14-15`
-
-**现象**:
-```go
-type LogicalModel struct {
- ID string `gorm:"primaryKey;size:36"` // 存 "LMODEL_000001",但按 UUID(36字符) 设计
-}
-```
-
-**问题分析**:
-1. `LMODEL_000001` 只有 **14 字符**
-2. `size:36` 在 PostgreSQL 是 `VARCHAR(36)`,**浪费 22 字节索引空间**
-3. 6 位数字最多 999,999 条记录
-4. 之后 `LMODEL_1000000` (15字符) 超长但不会报错(VARCHAR 自动扩展)
-5. 导致**主键长度不一致**,索引效率下降
-
-**证据**:
-- [Tarazevits.io: Prefix-UUID for Postgres Primary Keys](https://blog.tarazevits.io/prefix-uuid-postgres-primary-keys/)
- > "text 比 uuid 多 20 字节。高吞吐场景有影响,但 OLTP 可接受。重点是外键会倍增空间。"
-
-- [StackOverflow: VARCHAR as ID issues](https://stackoverflow.com/questions/50728032)
- > "VARCHAR 占更多空间,索引开销高,页分裂多,可能变更(级联更新糟糕)。建议自增整数主键 + unique index。"
-
-**修复方向**:
-
-```go
-// 方案1:改 size 匹配实际长度
-ID string `gorm:"primaryKey;size:20"` // LMODEL_ (7) + 最多13位数字预留
-
-// 方案2:用真正的 prefix-UUID(推荐,全局唯一)
-ID string `gorm:"primaryKey;size:48;default:prefixed_uuid('LMODEL')"`
-// 格式:LMODEL_550e8400-e29b-41d4-a716-446655440000
-
-// 方案3:分离 display ID(最优架构)
-ID int64 `gorm:"primaryKey;autoIncrement"` // 内部主键
-Code string `gorm:"uniqueIndex;size:20"` // LMODEL_000001,对外展示
-// 关联用整数,展示用字符串
-
-// 数据库迁移
-ALTER TABLE logical_models ALTER COLUMN id TYPE VARCHAR(20);
-ALTER TABLE logical_model_revisions ALTER COLUMN id TYPE VARCHAR(20);
-ALTER TABLE logical_model_routes ALTER COLUMN id TYPE VARCHAR(20);
--- 所有相关表的 ID 和外键列
-```
-
----
-
-## P2 次要问题
-
-### [P2-1] 权重路由取模偏差
-
-**位置**:`backend/internal/service/model_router.go:583-607`
-
-**现象**:
-```go
-func weightedRoute(routes []cachedLogicalRoute) cachedLogicalRoute {
- var total int64
- for _, route := range routes { total += int64(route.Route.Weight) }
- pick := int64(binary.LittleEndian.Uint64(raw[:]) % uint64(total)) // ← 取模偏差
-}
-```
-
-**问题分析**:
-
-当 `2^64 % total != 0` 时,小权重路由被选中概率略低。
-
-例如 `total=3`:
-- 值域 [0, 2^64-1],`2^64 % 3 = 1`
-- 区间 [0,0] [1,1] [2,2] ... 最后一个周期只有 0,缺少 1 和 2
-- 权重1 的路由比权重2 的路由少被选中 1 次 / 2^64 次
-
-**实际影响**:偏差 < 10^-18,工程上可忽略,但严格正确性角度是个问题。
-
-**证据**:
-- [LiteLLM PR #27980: Weighted-Routing Failover](https://github.com/BerriAI/litellm/pull/27980)
- > "simple_shuffle 用 `random.choices(deployments, weights=...)` 原生加权随机,没有取模。"
-
-**修复方向**:
-
-```go
-// 方案1:rejection sampling(无偏差但可能重试)
-func weightedRoute(routes) cachedLogicalRoute {
- max := int64(1) << 63 // 2^63
- limit := max - (max % total) // 丢弃偏差区间
- for {
- pick := int64(binary.LittleEndian.Uint64(raw[:]) >> 1) // 避免负数
- if pick < limit { return selectByWeight(pick % total) }
- // 重新生成随机数
- }
-}
-
-// 方案2:浮点数(工程可接受)
-pick := rand.Float64() * float64(total)
-```
-
----
-
-### [P2-2] `SaveAdminLogicalModel` 保存后全量查询找回自己
-
-**位置**:`backend/internal/service/logical_models.go:305-328`
-
-**现象**:
-```go
-func (s *Service) SaveAdminLogicalModel(...) (*AdminLogicalModel, error) {
- // 保存成功后
- items, err := s.AdminLogicalModels(actor) // ← 全量查询(含 N+1)
- for index := range items {
- if items[index].ID == item.ID {
- return &items[index], nil
- }
- }
-}
-```
-
-**性能问题**:
-- 保存 1 个模型 → 查询所有模型 + N+1 → 线性遍历找回
-- 当系统有 100 个模型时,保存耗时随模型总数增长
-- O(n) 复杂度,应该是 O(1)
-
-**修复方向**:
-```go
-func (s *Service) SaveAdminLogicalModel(...) (*AdminLogicalModel, error) {
- // 保存后
- graph, _ := s.repo.LogicalModelGraph(item.ID, true) // 只查这一个
- return s.buildAdminLogicalModel(item, graph), nil
-}
-```
-
----
-
-## 完整修复提示词(给 GPT)
-
-```markdown
-# 逻辑模型路由层代码修复任务
-
-你需要修复以下 P0/P1 严重问题,按优先级执行:
-
-## 【P0-1】修复 MAX(version)+1 并发竞态
-
-**文件**:`backend/internal/repository/logical_models.go`
-**位置**:`SaveLogicalModelBundle` 函数 311-316 行
-
-**问题**:两个并发请求会读到相同的 MAX(version),计算出相同的 next,导致 uniqueIndex 冲突。
-
-**修改方案**:
-
-### 1. 数据库迁移:增加原子递增列
-
-```sql
-ALTER TABLE logical_models ADD COLUMN next_revision_version INT DEFAULT 1;
-
--- 初始化现有数据
-UPDATE logical_models lm
-SET next_revision_version = (
- SELECT COALESCE(MAX(version), 0) + 1
- FROM logical_model_revisions
- WHERE logical_model_id = lm.id
-);
-```
-
-### 2. 修改 models_logical_model.go
-
-```go
-type LogicalModel struct {
- // ... 现有字段
- NextRevisionVersion int `json:"nextRevisionVersion" gorm:"default:1"`
-}
-```
-
-### 3. 修改 SaveLogicalModelBundle
-
-```go
-func (r *Repository) SaveLogicalModelBundle(...) error {
- return r.db.Transaction(func(tx *gorm.DB) error {
- // 删除以下代码:
- // var latest struct{ Version int }
- // if err := tx.Model(&model.LogicalModelRevision{}).
- // Select("COALESCE(MAX(version), 0) AS version").
- // Where("logical_model_id = ?", item.ID).Scan(&latest).Error; err != nil {
- // return err
- // }
- // revision.Version = latest.Version + 1
-
- // 替换为:
- var updated model.LogicalModel
- if err := tx.Model(&model.LogicalModel{}).
- Where("id = ?", item.ID).
- UpdateColumn("next_revision_version", gorm.Expr("next_revision_version + 1")).
- First(&updated).Error; err != nil {
- return err
- }
- revision.Version = updated.NextRevisionVersion
-
- if err := tx.Create(revision).Error; err != nil {
- return err
- }
- // ... 其他逻辑
- })
-}
-```
-
----
-
-## 【P0-2】修复单实例内存缓存多实例不同步
-
-**文件**:`backend/internal/service/service.go`, `model_router.go`
-
-**问题**:`routeHealthBlocked` 和 `routeCatalog` 是单实例内存,多实例部署时各自维护状态,导致:
-- 429 冷却只在触发实例生效
-- 路由缓存更新只在操作实例生效
-
-**修改方案**:
-
-### 1. service.go 增加 Redis 连接
-
-```go
-import "github.com/redis/go-redis/v9"
-
-type Service struct {
- // ... 现有字段
- redis *redis.Client
-
- // 保留本地缓存作为 fallback
- routeCatalog *routeCatalogSnapshot
- routeCatalogMu sync.RWMutex
- routeCatalogRefreshMu sync.Mutex
- routeCatalogTTL time.Duration
-}
-
-func NewService(..., redisAddr string) *Service {
- rdb := redis.NewClient(&redis.Options{
- Addr: redisAddr,
- })
-
- s := &Service{
- redis: rdb,
- // ... 其他初始化
- }
-
- // 启动 Redis Pub/Sub 监听
- go s.subscribeRouteCatalogInvalidation()
-
- return s
-}
-```
-
-### 2. model_router.go 修改 logicalRouteBlocked
-
-```go
-func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool {
- ctx := context.Background()
- now := time.Now()
-
- // 检查 Redis 中的冷却状态
- keys := []string{
- "route_health:" + route.ChannelModel.ChannelID,
- "route_health:" + route.Route.ID,
- }
-
- for _, key := range keys {
- until, err := s.redis.Get(ctx, key).Time()
- if err == nil && now.Before(until) {
- return true
- }
- }
-
- return false
-}
-```
-
-### 3. model_router.go 修改 blockLogicalRouteForFailure
-
-```go
-func (s *Service) blockLogicalRouteForFailure(attempt *model.RouteAttempt, duration time.Duration) {
- ctx := context.Background()
- until := time.Now().Add(duration)
-
- keys := []string{
- "route_health:" + attempt.ChannelID,
- "route_health:" + attempt.RouteID,
- }
-
- for _, key := range keys {
- s.redis.Set(ctx, key, until.Format(time.RFC3339), duration)
- }
-}
-```
-
-### 4. logical_models.go 修改 invalidateRouteCatalog
-
-```go
-func (s *Service) invalidateRouteCatalog(item *model.LogicalModel) {
- ctx := context.Background()
-
- // 清空本地缓存
- s.routeCatalogMu.Lock()
- s.routeCatalog = nil
- s.routeCatalogMu.Unlock()
-
- // 通知其他实例
- s.redis.Publish(ctx, "route_catalog_invalidate", item.ID)
-}
-
-func (s *Service) subscribeRouteCatalogInvalidation() {
- ctx := context.Background()
- pubsub := s.redis.Subscribe(ctx, "route_catalog_invalidate")
- defer pubsub.Close()
-
- ch := pubsub.Channel()
- for msg := range ch {
- // 收到通知,清空本地缓存
- s.routeCatalogMu.Lock()
- s.routeCatalog = nil
- s.routeCatalogMu.Unlock()
-
- log.Printf("route catalog invalidated by message: %s", msg.Payload)
- }
-}
-```
-
----
-
-## 【P0-3】修复 RWMutex 潜在误用风险
-
-**文件**:`backend/internal/service/model_router.go`
-**位置**:`logicalRouteBlocked` 函数
-
-**问题**:函数会删除 map 元素,但命名和注释没有体现"写操作",容易被误改为 RLock。
-
-**修改方案**:
-
-### 1. 重命名函数(如果保留内存版本)
-
-```go
-// 旧函数名
-// func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool
-
-// 新函数名(明确会修改状态)
-func (s *Service) logicalRouteBlockedAndPruneExpired(route cachedLogicalRoute) bool {
- // MUST hold write lock: prunes expired entries from routeHealthBlocked
- s.routeHealthMu.Lock()
- defer s.routeHealthMu.Unlock()
- // ... 现有逻辑
-}
-```
-
-### 2. 调用处全部改名
-
-```go
-// model_router.go 所有调用处
-if s.logicalRouteBlockedAndPruneExpired(candidate) {
- continue
-}
-```
-
-**注意**:如果已经迁移到 Redis(P0-2),此问题自动解决,因为不再使用内存 map。
-
----
-
-## 【P1-1】修复 GORM N+1 查询
-
-**文件**:`backend/internal/service/logical_models.go`, `backend/internal/repository/logical_models.go`
-
-**问题**:`AdminLogicalModels` 对每个模型单独查 graph,对每个 route 单独查 channel,导致 1+N+N*M 条 SQL。
-
-**修改方案**:
-
-### 1. repository/logical_models.go 增加预加载方法
-
-```go
-func (r *Repository) LogicalModelsWithPreload() ([]model.LogicalModel, error) {
- var items []model.LogicalModel
- err := r.db.
- Preload("ActiveRevision").
- Preload("ActiveRevision.Routes", func(db *gorm.DB) *gorm.DB {
- return db.Order("priority desc")
- }).
- Preload("ActiveRevision.Routes.Variant").
- Preload("ActiveRevision.Routes.Variant.ChannelModel").
- Order("sort_order asc, created_at asc").
- Find(&items).Error
- return items, err
-}
-```
-
-### 2. repository/logical_models.go 增加批量查询 channel 方法
-
-```go
-func (r *Repository) SystemChannelsByIDs(ids []string) (map[string]*model.Channel, error) {
- var channels []model.Channel
- if err := r.db.Where("id IN ?", ids).Find(&channels).Error; err != nil {
- return nil, err
- }
-
- result := make(map[string]*model.Channel)
- for i := range channels {
- result[channels[i].ID] = &channels[i]
- }
- return result, nil
-}
-```
-
-### 3. service/logical_models.go 修改 AdminLogicalModels
-
-```go
-func (s *Service) AdminLogicalModels(actor *model.User) ([]AdminLogicalModel, error) {
- // 用预加载方法替代原来的 LogicalModels
- items, err := s.repo.LogicalModelsWithPreload()
- if err != nil {
- return nil, err
- }
-
- // 收集所有 channel ID
- channelIDs := make(map[string]bool)
- for _, item := range items {
- if item.ActiveRevision != nil {
- for _, route := range item.ActiveRevision.Routes {
- if route.Variant != nil && route.Variant.ChannelModel != nil {
- channelIDs[route.Variant.ChannelModel.ChannelID] = true
- }
- }
- }
- }
-
- // 批量查询所有 channel
- idSlice := make([]string, 0, len(channelIDs))
- for id := range channelIDs {
- idSlice = append(idSlice, id)
- }
- channelMap, err := s.repo.SystemChannelsByIDs(idSlice)
- if err != nil {
- return nil, err
- }
-
- // 构建结果(不再单独查询)
- result := make([]AdminLogicalModel, 0, len(items))
- for _, item := range items {
- // 直接用 item.ActiveRevision.Routes,不再调用 LogicalModelGraph
- adminModel := s.buildAdminLogicalModelFromPreloaded(item, channelMap)
- result = append(result, adminModel)
- }
-
- return result, nil
-}
-
-func (s *Service) buildAdminLogicalModelFromPreloaded(
- item model.LogicalModel,
- channelMap map[string]*model.Channel,
-) AdminLogicalModel {
- // 从预加载数据构建 AdminLogicalModel
- // 不再单独查询
-}
-```
-
-**预期效果**:61 条 SQL → 5 条 SQL
-
----
-
-## 【P1-2】修复 json.Marshal map key 不稳定
-
-**文件**:`backend/internal/service/logical_models.go`
-**位置**:`publicLogicalModel` 函数 161-176 行
-
-**问题**:用 `json.Marshal(CapabilitySpec)` 做 map key,Go 1.24+ map key 顺序非确定性,去重失效。
-
-**修改方案**:
-
-### 1. 增加稳定的 fingerprint 函数
-
-```go
-func capabilitySpecFingerprint(spec CapabilitySpec) string {
- var buf strings.Builder
-
- // 1. capability (固定)
- buf.WriteString(spec.Capability)
- buf.WriteString("|v")
- buf.WriteString(strconv.Itoa(spec.Version))
- buf.WriteString("|")
-
- // 2. operations (排序)
- if len(spec.Operations) > 0 {
- ops := make([]string, len(spec.Operations))
- copy(ops, spec.Operations)
- sort.Strings(ops)
- buf.WriteString("ops:")
- buf.WriteString(strings.Join(ops, ","))
- buf.WriteString("|")
- }
-
- // 3. inputs (按 key 排序)
- if len(spec.Inputs) > 0 {
- buf.WriteString("inputs:")
- keys := make([]string, 0, len(spec.Inputs))
- for k := range spec.Inputs {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- for i, k := range keys {
- if i > 0 { buf.WriteString(",") }
- fmt.Fprintf(&buf, "%s[%d-%d]", k, spec.Inputs[k].Min, spec.Inputs[k].Max)
- }
- buf.WriteString("|")
- }
-
- // 4. options (按 key 排序)
- if len(spec.Options) > 0 {
- buf.WriteString("options:")
- keys := make([]string, 0, len(spec.Options))
- for k := range spec.Options {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- for i, k := range keys {
- if i > 0 { buf.WriteString(",") }
- opt := spec.Options[k]
- buf.WriteString(k)
- buf.WriteString(":")
- if len(opt.Values) > 0 {
- // values 也要排序(转字符串)
- vals := make([]string, len(opt.Values))
- for j, v := range opt.Values {
- vals[j] = fmt.Sprint(v)
- }
- sort.Strings(vals)
- buf.WriteString(strings.Join(vals, ";"))
- } else {
- fmt.Fprintf(&buf, "[%v-%v/%v]", opt.Min, opt.Max, opt.Step)
- }
- }
- }
-
- return buf.String()
-}
-```
-
-### 2. 修改 publicLogicalModel 使用 fingerprint
-
-```go
-func publicLogicalModel(...) PublicLogicalModel {
- seen := make(map[string]bool)
- var variants []CapabilitySpec
-
- for _, route := range cached.Routes {
- // 旧代码:
- // encoded, _ := json.Marshal(route.VariantSpec)
- // key := string(encoded)
-
- // 新代码:
- key := capabilitySpecFingerprint(route.VariantSpec)
-
- if !seen[key] {
- seen[key] = true
- variants = append(variants, route.VariantSpec)
- }
- }
-
- // ... 其他逻辑
-}
-```
-
----
-
-## 【P1-3】修复 prefixed ID size 不匹配
-
-**文件**:`backend/internal/model/models_logical_model.go`
-
-**问题**:ID 存 "LMODEL_000001" (14字符),但 `size:36` 按 UUID 设计,浪费空间且6位数字会溢出。
-
-**修改方案**:
-
-### 1. 修改所有 ID 字段声明
-
-```go
-type LogicalModel struct {
- ID string `json:"id" gorm:"primaryKey;size:20"` // 改为 20
- // ...
-}
-
-type LogicalModelRevision struct {
- ID string `json:"id" gorm:"primaryKey;size:20"` // 改为 20
- LogicalModelID string `json:"logicalModelId" gorm:"size:20;index;uniqueIndex:idx_logical_revision_version,priority:1"`
- // ...
-}
-
-type PhysicalCapabilityVariant struct {
- ID string `json:"id" gorm:"primaryKey;size:20"` // 改为 20
- ChannelModelID string `json:"channelModelId" gorm:"size:20;index"`
- // ...
-}
-
-type LogicalModelRoute struct {
- ID string `json:"id" gorm:"primaryKey;size:20"` // 改为 20
- LogicalModelRevisionID string `json:"logicalModelRevisionId" gorm:"size:20;index;uniqueIndex:idx_logical_route_member,priority:1"`
- PhysicalVariantID string `json:"physicalVariantId" gorm:"size:20;index;uniqueIndex:idx_logical_route_member,priority:2"`
- // ...
-}
-
-type RouteAttempt struct {
- ID string `json:"id" gorm:"primaryKey;size:20"` // 改为 20
- LogicalModelID string `json:"logicalModelId" gorm:"size:20;index"`
- LogicalModelRevisionID string `json:"logicalModelRevisionId" gorm:"size:20;index"`
- RouteID string `json:"routeId" gorm:"size:20;index"`
- PhysicalVariantID string `json:"physicalVariantId" gorm:"size:20;index"`
- ChannelModelID string `json:"channelModelId" gorm:"size:20;index"`
- // ...
-}
-```
-
-### 2. 确保 NextPrefixedID 返回固定长度
-
-```go
-func NextPrefixedID(db *gorm.DB, prefix string) (string, error) {
- // ... 现有逻辑获取 nextVal
-
- // 修改格式化,确保补零到固定长度
- return fmt.Sprintf("%s_%013d", prefix, nextVal), nil // 13位数字
-}
-```
-
-### 3. 数据库迁移脚本
-
-```sql
--- 修改所有相关表的 VARCHAR 长度
-ALTER TABLE logical_models ALTER COLUMN id TYPE VARCHAR(20);
-ALTER TABLE logical_model_revisions ALTER COLUMN id TYPE VARCHAR(20);
-ALTER TABLE logical_model_revisions ALTER COLUMN logical_model_id TYPE VARCHAR(20);
-ALTER TABLE physical_capability_variants ALTER COLUMN id TYPE VARCHAR(20);
-ALTER TABLE physical_capability_variants ALTER COLUMN channel_model_id TYPE VARCHAR(20);
-ALTER TABLE logical_model_routes ALTER COLUMN id TYPE VARCHAR(20);
-ALTER TABLE logical_model_routes ALTER COLUMN logical_model_revision_id TYPE VARCHAR(20);
-ALTER TABLE logical_model_routes ALTER COLUMN physical_variant_id TYPE VARCHAR(20);
-ALTER TABLE route_attempts ALTER COLUMN id TYPE VARCHAR(20);
-ALTER TABLE route_attempts ALTER COLUMN logical_model_id TYPE VARCHAR(20);
-ALTER TABLE route_attempts ALTER COLUMN logical_model_revision_id TYPE VARCHAR(20);
-ALTER TABLE route_attempts ALTER COLUMN route_id TYPE VARCHAR(20);
-ALTER TABLE route_attempts ALTER COLUMN physical_variant_id TYPE VARCHAR(20);
-ALTER TABLE route_attempts ALTER COLUMN channel_model_id TYPE VARCHAR(20);
-
--- 验证现有数据长度(应该都 <= 20)
-SELECT MAX(LENGTH(id)) FROM logical_models;
-SELECT MAX(LENGTH(id)) FROM logical_model_revisions;
--- ... 其他表
-```
-
----
-
-## 【额外建议】
-
-### 1. 增加集成测试
-
-```go
-// backend/internal/repository/logical_models_test.go
-func TestSaveLogicalModelBundleConcurrent(t *testing.T) {
- // 测试并发保存同一模型,验证无 uniqueIndex 冲突
- var wg sync.WaitGroup
- errors := make(chan error, 10)
-
- for i := 0; i < 10; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- err := repo.SaveLogicalModelBundle(...)
- if err != nil {
- errors <- err
- }
- }()
- }
-
- wg.Wait()
- close(errors)
-
- for err := range errors {
- if strings.Contains(err.Error(), "duplicate key") {
- t.Fatalf("concurrent save caused uniqueIndex conflict: %v", err)
- }
- }
-}
-```
-
-### 2. 增加监控指标
-
-```go
-// model_router.go
-func (s *Service) blockLogicalRouteForFailure(...) {
- // 设置 Redis
- s.redis.Set(...)
-
- // 增加监控
- metrics.RouteHealthBlocked.WithLabelValues(
- attempt.ChannelID,
- attempt.FailureCode,
- ).Inc()
-}
-```
-
-### 3. 文档标注
-
-在 `model_router.go` 顶部增加注释:
-
-```go
-// Route Health Management
-//
-// IMPORTANT: routeHealthBlocked state is shared across all instances via Redis.
-// Do NOT use local memory for blocking state in a multi-instance deployment.
-//
-// Keys:
-// - route_health:{channelID} - channel-level block
-// - route_health:{routeID} - route-level block
-//
-// TTL: 30s for rate limit (429), 5m for other failures
-```
-
----
-
-## 验证清单
-
-修复完成后,请验证:
-
-- [ ] 并发保存同一模型不会触发 uniqueIndex 冲突
-- [ ] 多实例部署时,429 冷却在所有实例生效
-- [ ] 多实例部署时,路由配置更新在所有实例生效
-- [ ] 管理员列表接口 SQL 数量从 61 降到 5 条
-- [ ] `capabilityProfiles` 不再出现重复项
-- [ ] 所有 ID 列的 VARCHAR 长度改为 20
-- [ ] 集成测试通过
-```
-
----
-
-## 本轮实际改动总结
-
-### GPT 在本轮实现中的问题
-
-1. **并发安全缺失**
- - 使用应用层 `SELECT MAX + 1` 而非数据库原子操作
- - 典型的 TOCTOU 竞态条件
-
-2. **分布式一致性缺失**
- - 使用单实例内存缓存而非 Redis
- - 无跨实例状态同步机制
- - 429 冷却和路由缓存在多实例下失效
-
-3. **性能优化不足**
- - N+1 查询问题(1 + N + N*M 条 SQL)
- - 保存后全量查询找回自己(O(n) 应该 O(1))
-
-4. **数据结构设计不当**
- - VARCHAR(36) 存储 14 字符的 prefixed ID
- - 浪费 22 字节索引空间
- - 未考虑 6 位数字溢出场景
-
-5. **稳定性风险**
- - 使用 `json.Marshal` 做去重 key(Go 1.24+ 不稳定)
- - RWMutex 使用正确但命名误导(容易被误改)
-
-### 需要改动的文件
-
-| 文件 | 改动类型 | 优先级 |
-|------|---------|--------|
-| `backend/internal/repository/logical_models.go` | 修改 SaveLogicalModelBundle | P0 |
-| `backend/internal/model/models_logical_model.go` | 增加 NextRevisionVersion 字段,修改 size | P0 |
-| `backend/internal/service/service.go` | 增加 Redis 连接 | P0 |
-| `backend/internal/service/model_router.go` | 改用 Redis 存储 routeHealthBlocked | P0 |
-| `backend/internal/service/logical_models.go` | 修改 AdminLogicalModels 用 Preload | P1 |
-| `backend/internal/repository/logical_models.go` | 增加 LogicalModelsWithPreload | P1 |
-| `backend/internal/service/logical_models.go` | 增加 capabilitySpecFingerprint | P1 |
-| 数据库迁移脚本 | ALTER TABLE 修改 VARCHAR 长度 | P1 |
-
-### 预期改进效果
-
-| 指标 | 改进前 | 改进后 | 提升 |
-|------|--------|--------|------|
-| 并发保存成功率 | 竞态冲突 | 100% 成功 | ✅ 修复 |
-| 多实例 429 冷却 | 单实例生效 | 全局生效 | ✅ 修复 |
-| 管理员列表 SQL | 61 条 | 5 条 | 92% ↓ |
-| 保存后查询 | O(n) | O(1) | ✅ 修复 |
-| ID 索引空间 | 36 字节 | 20 字节 | 44% ↓ |
-| 去重稳定性 | Go 1.24+ 不稳定 | 稳定 | ✅ 修复 |
-
----
-
-## 附录:调研依据汇总
-
-### 并发竞态
-
-1. [DEV Community: SELECT MAX()+1 Is a Race Condition](https://dev.to/mattia_armas/select-max1-is-a-race-condition-waiting-to-happen-22m2)
-2. [StackOverflow: INSERT INTO ... SELECT MAX + 1 returns DUPLICATE KEY](https://stackoverflow.com/questions/59756571)
-
-### 分布式缓存
-
-3. [Viktar Patotski: Redis Caching vs Local Cache](https://patotski.com/blog/redis-caching-vs-local-cache/)
-4. [WayGate: Distributed Deployments](https://attakay78.github.io/waygate/guides/distributed/)
-
-### Go 并发
-
-5. [StackOverflow: concurrent map iteration and map write with RLock/RUnlock](https://stackoverflow.com/questions/71354301)
-6. [Go sync 官方文档: RWMutex](https://pkg.go.dev/sync#RWMutex)
-
-### GORM N+1
-
-7. [Shinagawa Labs: Speeding Up Search Responses](https://shinagawa-web.com/en/works/go-gorm-n-plus-one-b2b-ticketing)
-8. [GORM 文档: Preloading](https://gorm.io/docs/preload.html)
-
-### JSON 稳定性
-
-9. [Go encoding/json 文档](https://pkg.go.dev/encoding/json)
-10. [StackOverflow: How to produce JSON with sorted keys](https://stackoverflow.com/questions/18668652)
-
-### VARCHAR 主键
-
-11. [Tarazevits.io: Prefix-UUID for Postgres Primary Keys](https://blog.tarazevits.io/prefix-uuid-postgres-primary-keys/)
-12. [StackOverflow: VARCHAR as ID issues](https://stackoverflow.com/questions/50728032)
-
-### LiteLLM 参考
-
-13. [LiteLLM PR #27980: Weighted-Routing Failover](https://github.com/BerriAI/litellm/pull/27980)
-
----
-
-## 结论
-
-本次评审基于**实际代码审查 + 市场调研**,发现的所有问题均有权威来源支撑,不是臆想。
-
-GPT 的实现在**并发安全、分布式一致性、性能优化、数据设计**四个维度存在严重缺陷,必须修复后才能用于生产环境。
-
-建议按 P0 → P1 → P2 优先级顺序修复,并增加相应的集成测试和监控。
-
-
diff --git a/web/src/components/canvas/canvas-connections.tsx b/web/src/components/canvas/canvas-connections.tsx
index 56058bc63..6e065ef27 100644
--- a/web/src/components/canvas/canvas-connections.tsx
+++ b/web/src/components/canvas/canvas-connections.tsx
@@ -3,7 +3,7 @@ import type { MouseEvent as ReactMouseEvent } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
-import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/components/canvas/canvas-script-node";
+import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "@/types/canvas";
export const ConnectionPath = React.memo(function ConnectionPath({
diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx
index 34c0c53db..12e649281 100644
--- a/web/src/components/canvas/canvas-node.tsx
+++ b/web/src/components/canvas/canvas-node.tsx
@@ -3,9 +3,9 @@ import type { ReactNode } from "react";
import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, Image as ImageIcon, Lock, Maximize2, Music2, Pencil, Plus, Replace, Settings2, Star, Type, Upload, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
+import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
import { useThemeStore } from "@/stores/use-theme-store";
-import { storyboardMinNodeHeight } from "./canvas-script-node";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { MEDIA_NODE_MIN_SIZE } from "@/lib/canvas/canvas-node-size";
diff --git a/web/src/components/canvas/canvas-script-node.tsx b/web/src/components/canvas/canvas-script-node.tsx
index b3392ac96..9f7803751 100644
--- a/web/src/components/canvas/canvas-script-node.tsx
+++ b/web/src/components/canvas/canvas-script-node.tsx
@@ -15,6 +15,7 @@ import { navigateToSettings } from "@/lib/settings-navigation";
import { canvasThemes } from "@/lib/canvas-theme";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
+import { STORYBOARD_COMPOSER_MIN_HEIGHT, STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
import type {
CanvasGenerationBatch,
CanvasGenerationBatchItem,
@@ -30,29 +31,11 @@ import type {
} from "@/types/canvas";
import type { TaskStatus } from "@/services/api/task-center";
-export const STORYBOARD_ROW_HEIGHT = 48;
-export const STORYBOARD_HEADER_HEIGHT = 124;
-const STORYBOARD_ADD_ROW_HEIGHT = 36;
-const STORYBOARD_COMPOSER_MIN_HEIGHT = 104;
-const STORYBOARD_COMPOSER_MAX_HEIGHT = 180;
const STORYBOARD_PROMPT_MIN_HEIGHT = 40;
const STORYBOARD_PROMPT_MAX_HEIGHT = 116;
const SCRIPT_GRID_TEMPLATE = "72px 150px minmax(280px, 1.4fr) minmax(220px, 1fr) 58px";
const EMPTY_STORYBOARD_ROWS: StoryboardRow[] = [];
-export function storyboardNodeHeight(rowCount: number, composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) {
- const visibleRows = Math.min(Math.max(rowCount, 1), 4);
- return STORYBOARD_HEADER_HEIGHT + visibleRows * STORYBOARD_ROW_HEIGHT + STORYBOARD_ADD_ROW_HEIGHT + Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight));
-}
-
-export function storyboardMinNodeHeight(composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) {
- return STORYBOARD_HEADER_HEIGHT + STORYBOARD_ROW_HEIGHT + STORYBOARD_ADD_ROW_HEIGHT + Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight));
-}
-
-export function storyboardTableHeight(nodeHeight: number, composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) {
- return Math.max(STORYBOARD_ROW_HEIGHT, nodeHeight - STORYBOARD_HEADER_HEIGHT - STORYBOARD_ADD_ROW_HEIGHT - Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight)));
-}
-
const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [
{ label: "序号", value: "shotNumber" },
{ label: "时长", value: "durationSeconds" },
diff --git a/web/src/components/layout/workspace-sidebar-footer.tsx b/web/src/components/layout/workspace-sidebar-footer.tsx
index f396578b0..432005fc2 100644
--- a/web/src/components/layout/workspace-sidebar-footer.tsx
+++ b/web/src/components/layout/workspace-sidebar-footer.tsx
@@ -37,7 +37,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
const handleLogout = async () => {
try {
await logout();
- await applyUserSession({ user: null, systemChannels: [] });
+ await applyUserSession({ user: null });
setMenuOpen(false);
message.success("已退出登录");
navigate("/login", { replace: true });
diff --git a/web/src/components/model-logo.tsx b/web/src/components/model-logo.tsx
index 6e5fbf345..cb1e101e9 100644
--- a/web/src/components/model-logo.tsx
+++ b/web/src/components/model-logo.tsx
@@ -2,14 +2,21 @@ import { useMemo, useState, type ComponentType, type SVGProps } from "react";
import { Input, Popover } from "antd";
import { Cpu, Search, X } from "lucide-react";
-import * as LobeIcons from "@lobehub/icons/es/icons";
import { toc } from "@lobehub/icons/es/toc";
import { cn } from "@/lib/utils";
type LobeIconComponent = ComponentType & { size?: number | string }>;
-const iconRegistry = LobeIcons as unknown as Record;
+// 只加载各图标的 Mono 组件;完整 icons barrel 会经由 Avatar/Combine 与 @lobehub/ui 形成循环依赖,Bun 测试环境会触发未初始化导出。
+const iconModules = typeof import.meta.glob === "function"
+ ? import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { eager: true, import: "default" })
+ : {};
+const iconRegistry = Object.fromEntries(
+ Object.entries(iconModules)
+ .map(([path, icon]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], icon])
+ .filter((entry): entry is [string, LobeIconComponent] => Boolean(entry[0] && entry[1])),
+) as Record;
const iconOptions = toc
.filter((item) => item.group === "model" || item.group === "provider")
.map((item) => ({ id: item.id, title: item.fullTitle || item.title }))
diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts
index c98d2405c..bded5cbf2 100644
--- a/web/src/lib/canvas/canvas-project-domain.ts
+++ b/web/src/lib/canvas/canvas-project-domain.ts
@@ -1,5 +1,5 @@
import { NODE_DEFAULT_SIZE, getNodeSpec } from "@/constant/canvas";
-import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/components/canvas/canvas-script-node";
+import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
diff --git a/web/src/lib/canvas/canvas-storyboard-layout.ts b/web/src/lib/canvas/canvas-storyboard-layout.ts
new file mode 100644
index 000000000..3b2d5ee9d
--- /dev/null
+++ b/web/src/lib/canvas/canvas-storyboard-layout.ts
@@ -0,0 +1,24 @@
+export const STORYBOARD_ROW_HEIGHT = 48;
+export const STORYBOARD_HEADER_HEIGHT = 124;
+
+const STORYBOARD_ADD_ROW_HEIGHT = 36;
+export const STORYBOARD_COMPOSER_MIN_HEIGHT = 104;
+const STORYBOARD_COMPOSER_MAX_HEIGHT = 180;
+
+// 布局计算属于画布领域逻辑,独立于 React 节点组件,避免纯函数测试加载完整 UI 依赖。
+function normalizedComposerHeight(composerHeight: number) {
+ return Math.min(STORYBOARD_COMPOSER_MAX_HEIGHT, Math.max(STORYBOARD_COMPOSER_MIN_HEIGHT, composerHeight));
+}
+
+export function storyboardNodeHeight(rowCount: number, composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) {
+ const visibleRows = Math.min(Math.max(rowCount, 1), 4);
+ return STORYBOARD_HEADER_HEIGHT + visibleRows * STORYBOARD_ROW_HEIGHT + STORYBOARD_ADD_ROW_HEIGHT + normalizedComposerHeight(composerHeight);
+}
+
+export function storyboardMinNodeHeight(composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) {
+ return STORYBOARD_HEADER_HEIGHT + STORYBOARD_ROW_HEIGHT + STORYBOARD_ADD_ROW_HEIGHT + normalizedComposerHeight(composerHeight);
+}
+
+export function storyboardTableHeight(nodeHeight: number, composerHeight = STORYBOARD_COMPOSER_MIN_HEIGHT) {
+ return Math.max(STORYBOARD_ROW_HEIGHT, nodeHeight - STORYBOARD_HEADER_HEIGHT - STORYBOARD_ADD_ROW_HEIGHT - normalizedComposerHeight(composerHeight));
+}
diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx
index 0c13e72d6..c6a948180 100644
--- a/web/src/pages/canvas/project.tsx
+++ b/web/src/pages/canvas/project.tsx
@@ -54,7 +54,8 @@ import { AssetPickerModal, type InsertAssetPayload } from "@/components/canvas/a
import { getProject } from "@/services/api/projects";
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
import { CanvasShareModal } from "@/components/canvas/canvas-share-modal";
-import { CanvasScriptEditor, CanvasScriptNodeContent, STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/components/canvas/canvas-script-node";
+import { CanvasScriptEditor, CanvasScriptNodeContent } from "@/components/canvas/canvas-script-node";
+import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardMinNodeHeight, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { CanvasDirectorNodePanel } from "@/components/canvas/director/canvas-director-node-panel";
import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-compare-modal";
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";