feat: Dify 部署脚本修复 + AI 搭子 MVP 接入
主要改动: fix(docker/dify-deploy): 修复脚本核心功能 - heredoc 单引号 bug: 'ENVEOF' 改为 ENVEOF,变量正确展开 - 端口默认值 8083/8084/8085 对齐 .env.prod 生产配置 - 加 dc_cmd() 兼容 docker-compose v1/v2 plugin - openssl rand 生成强随机密码与 SECRET_KEY(42 字符) - install 跳过已存在 .env,保护用户配置(管理员密码/SECRET_KEY) - read -p < /dev/tty 兼容非 tty 环境(CI/CD) - show-config 改用 DIFY_NGINX_PORT(nginx 入口)而非 APP_WEB_PORT docs(mvp-design): 修正 §3.2 workflow inputs 描述 - 实际只有 query,删除错误的 user_id input 声明 - 节点序列图同步更新 feat(aiChatService): 新增 Dify 客户端与适配器 - service/dify_client.go: Dify Workflow 调用 + SSE 解析 - service/dify_adapter.go: 与现有 chat_service 桥接 - provider/ai_chat_provider.go: Dubbo 入口简化 - main.go: 装配 ConversationRepository + DifyClient feat(migrations): 新增 AI 搭子会话表 ai_chat.sql - ai_conversations / ai_messages 表 + 索引 docs: 新增 Dify 集成设计文档 - 2026-06-29-ai-chat-dify-mvp-design.md (MVP 实施级) - 2026-06-29-ai-chat-dify-integration-v2-design.md (V2 演进路线图) - docs/dify/角角.yml (Workflow DSL 导出) config: 更新 env 模板与 docker 配置 - backend/.env.example: DIFY_* 环境变量声明 - docker/.env.prod: DIFY_API_BASE 对齐 8083 - docker/build.sh: 微调 - CLAUDE.md: 项目规范补充 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a077fe2685
commit
65ce6bba12
217
CLAUDE.md
217
CLAUDE.md
@ -98,6 +98,63 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
|
||||
---
|
||||
|
||||
## 前端开发规范(uniapp + vue3 · app 端)
|
||||
|
||||
### 技术栈基线
|
||||
|
||||
- **框架**:UniApp 3.x + **Vue 3 组合式 API**(`vueVersion: "3"`,`@vue/compiler-sfc ^3.5`),不要再写 Vue 2 Options API 或混用 `this`
|
||||
- **状态管理**:Vuex 4(`store/index.js` + `store/modules/*`),跨页面状态走 Vuex,组件临时状态用 `ref` / `reactive`
|
||||
- **复用逻辑**:放进 `composables/useXxx.js`(已有 `useHolographicPreview` / `useDashboardData` / `useLenticularStudioTilt` 等),**禁止**把可复用的逻辑写在单文件组件里
|
||||
- **目标平台**:以 **app-plus(Android + iOS)为主**,H5 / 微信小程序等其他端仅在显式 `#ifdef` 支持时才能用
|
||||
- **原生能力**:`plus.*`(陀螺仪、角标、Intent 跳转等)、UniPush、设备指纹、Socket 全都走 `utils/` 下专用封装,**不要**在组件里直接 `plus.*`
|
||||
|
||||
### 关键约定
|
||||
|
||||
1. **条件编译是硬约束**——涉及原生 API / 原生插件 / 平台差异代码必须包在 `// #ifdef APP-PLUS … // #endif`(或 `MP-WEIXIN` / `H5` 等):
|
||||
```js
|
||||
// 正确
|
||||
// #ifdef APP-PLUS
|
||||
plus.runtime.setBadgeNumber(0)
|
||||
// #endif
|
||||
|
||||
// 错误(plus 在非 app 端是 undefined,会直接报错)
|
||||
plus.runtime.setBadgeNumber(0)
|
||||
```
|
||||
**禁止**用 `if (typeof plus !== 'undefined')` 之类兜底代替条件编译。
|
||||
|
||||
2. **API 调用统一封装**——所有后端接口走 `utils/api.js`(设备注册、WebSocket token 上报等),组件层只调封装函数,**禁止**在 `.vue` 里直接 `uni.request`。
|
||||
|
||||
3. **路由与页面注册**——新页面**先在 [frontend/pages.json](frontend/pages.json) 注册再写 `.vue` 文件**;Tab 页面用 `uni.switchTab`,普通跳转用 `navigateTo`,**禁止**用 `redirectTo` 替代 `navigateBack` 制造假"返回"。
|
||||
|
||||
4. **权限申请必须给出口**——通知 / 相机 / 相册 / 定位等敏感权限,授权失败时必须给"去设置"按钮并调用系统设置页(参照 `App.vue#setPermissions` 的 Android Intent / iOS `app-settings:` 范式),**禁止**静默失败。
|
||||
|
||||
5. **性能基线**——长列表使用现有 [components/VirtualList.vue](frontend/components/VirtualList.vue),图片用 [components/LazyImage.vue](frontend/components/LazyImage.vue);自定义字体已知坑:部分 Android WebView 对部分 `.ttf` 会报 OTS / cmap 解析失败(如 `JDLTYuanTiJian.ttf`),新增字体前先在小内存 Android 机上验证。
|
||||
|
||||
6. **资源与产物隔离**——`unpackage/dist/*` 是编译产物,**禁止**手动修改;图标准备物放在 `unpackage/res/icons/`,源码改动放 `static/`。
|
||||
|
||||
### 禁止的反模式
|
||||
|
||||
- ❌ 在 Vue 3 项目里混用 `export default { data() { return {} } }` Options API
|
||||
- ❌ 在非 `APP-PLUS` 分支直接调 `plus.*` / 原生插件 API
|
||||
- ❌ 在组件里直接 `uni.request` / `uni.connectSocket`,绕过 `utils/api.js`
|
||||
- ❌ 跨页面状态用 props 层层下传 / `getApp().globalData` 散落——必须走 Vuex
|
||||
- ❌ 新增页面不写 `pages.json` 就提交
|
||||
- ❌ 权限被拒后只 `console.warn` 不引导用户去开启
|
||||
- ❌ 手动编辑 `unpackage/dist/` 下的任何文件
|
||||
|
||||
### 完成自检(提交前过一遍)
|
||||
|
||||
- [ ] 新组件用 `<script setup>` 或 `setup()` 组合式 API,无 `this`
|
||||
- [ ] 所有原生 / 平台差异代码包了 `#ifdef`
|
||||
- [ ] 接口调用走 `utils/api.js`,未在组件里裸写 `uni.request`
|
||||
- [ ] 跨页面 / 跨组件状态走 Vuex
|
||||
- [ ] 新页面已在 `pages.json` 注册
|
||||
- [ ] 敏感权限失败有"去设置"出口
|
||||
- [ ] 长列表 / 大图场景接入了虚拟滚动或懒加载
|
||||
- [ ] 未改动 `unpackage/dist/` 编译产物
|
||||
|
||||
---
|
||||
|
||||
## Git 提交规范
|
||||
|
||||
### 核心规则:AI 不得主动 commit
|
||||
@ -164,6 +221,166 @@ Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
||||
- 用户对代码质量失去信任
|
||||
- 提交历史变成"反复横跳"的打补丁记录
|
||||
|
||||
### ★ 全局自审规则(强约束)
|
||||
|
||||
**自审必须是"全局审查",不能只读"我修改的部分"**。
|
||||
|
||||
#### 错误做法(局部自审,已多次踩坑)
|
||||
|
||||
- 只读修改过的章节,找修改自身的 bug
|
||||
- 跳过未修改的章节,认为"我没动过所以没问题"
|
||||
- 只检查修复后的代码,不检查修复是否破坏了其他章节的引用
|
||||
|
||||
#### 正确做法(全局自审,强制执行)
|
||||
|
||||
每次自审必须**从文档第一节读到最后一节**,按以下步骤:
|
||||
|
||||
1. **章节通读清单**:先列出文档所有章节(§1 到 §16),逐一阅读
|
||||
2. **跨章节引用一致性检查**:
|
||||
- §1.x 描述的事实 → 在 §5.x 实现了吗?
|
||||
- §3 Dify 配置 → §5 后端是否一致?
|
||||
- §10 目录 → §5 代码是否齐全?
|
||||
- §11 部署 → §5 装配是否对齐?
|
||||
3. **代码示例完整性**:
|
||||
- Go import 块是否齐全(time/strings/redis 等常用包)
|
||||
- 函数签名与调用点参数个数一致
|
||||
- struct 字段与构造方法一致
|
||||
4. **章节编号一致性**:§3.4 与 §3.5 不重复;§11.x 编号无错位
|
||||
5. **架构图 vs 时序图 vs 代码**:物理时序、逻辑步骤、代码实现三者交叉验证
|
||||
|
||||
#### 失败案例(2026-06-29 V2 文档自审 6 轮)
|
||||
|
||||
- **第 1-4 轮自审漏发现的问题**:DifyProvider 用 `p.httpClient`/`p.redisClient`/`p.convRepo`,新设计应该用组件抽象,但旧代码没改干净
|
||||
- **Workflow 双 mapping**(V1 设计 bug):Backend 维护 `star_dataset_mapping` + Workflow 维护 `STAR_DATASET_MAP`,6 轮自审都没看到,是架构评审指出的
|
||||
- **§11 部署章节**持续出现角色分工错位、循环引用
|
||||
|
||||
**根因**:我之前的"自审"本质上是"局部自审",只看我改的部分。
|
||||
|
||||
#### 自审触发时机
|
||||
|
||||
- 写完设计方案文档后
|
||||
- 完成一轮"修复 X 个 bug"后
|
||||
- 实施编码 `go build` 前(最后一次文档校对)
|
||||
|
||||
#### 自审报告必须包含
|
||||
|
||||
1. **修改的章节**:列出本次修了哪些章节
|
||||
2. **未改动的章节**:列出本次没动但通读了哪些章节(防止"我没看"的盲区)
|
||||
3. **跨章节引用一致性**:列出所有发现的不一致
|
||||
4. **Go 编译验证**:列出所有需要 `go build` 才能发现的潜在问题
|
||||
5. **优先级**:P0/P1/P2 分类
|
||||
|
||||
---
|
||||
|
||||
## 文档维护规则(设计方案类)
|
||||
|
||||
### 设计方案文档必须包含的开头部分
|
||||
|
||||
任何设计方案文档(如 `docs/specs/*.md`)**必须在文档开头**包含:
|
||||
|
||||
1. **方案概述**(*必读*):
|
||||
- **要解决的问题**:业务问题 + 技术问题(分类列出)
|
||||
- **整体实现路径**:阶段划分 + 时间估算
|
||||
- **关键决策**:核心设计选择的简要说明 + 指向详细章节
|
||||
- **核心架构图**(TL;DR):一图说明整体结构
|
||||
|
||||
2. **文档说明**:
|
||||
- 适用范围
|
||||
- 工作量估算
|
||||
- 前置版本/历史
|
||||
- 目标读者
|
||||
|
||||
**目的**:让读者**5 分钟内**能判断这个文档"是不是我需要的" + "大概什么内容"。
|
||||
|
||||
### ★ MVP 先行原则(业务驱动,不是架构驱动)
|
||||
|
||||
**核心原则:MVP 阶段不实施"为未来 100 明星 + 多 AI 平台"准备的架构**。
|
||||
|
||||
**错误做法(已踩坑)**:
|
||||
- 业务第一阶段只有 1 个明星,但设计文档规划了 Provider 抽象、ProviderFactory、ConversationStore 抽象
|
||||
- 结果:MVP 阶段写了 1500-2000 行抽象代码,**80% 用不上**
|
||||
- 后果:实施周期 4-5 周(应该 1 周),新人接手困难,运营被复杂架构拖慢
|
||||
|
||||
**正确做法**:
|
||||
- **MVP 设计原则**:MVP 文档只描述当前业务需要的实现
|
||||
- **Stage 1**: 1 个明星 + 1 个 Dify Workflow + 直接调 DifyClient(**没有 Provider 抽象**)
|
||||
- **Stage 2-5**: 业务复杂度上来后,**按业务驱动**逐步加抽象
|
||||
|
||||
**设计文档结构(**双文档体系**)**:
|
||||
|
||||
| 文档 | 用途 | 实施阶段 |
|
||||
|------|------|---------|
|
||||
| **MVP 设计**(如 `*-mvp-design.md`) | MVP 实施级方案,1 周可落地 | MVP 阶段 |
|
||||
| **完整架构**(如 `*-v2-design.md`) | 100 明星 + 多 AI 平台完整设计 | Stage 2+ 参考 |
|
||||
|
||||
**两个文档的关系**:
|
||||
- MVP 文档开头要明确"★ MVP 优先"提示
|
||||
- 完整架构文档开头要明确"⚠️ MVP 不实施,仅路线图"
|
||||
- 完整架构文档的 §10"后续优化"映射到 MVP 的 Stage 1-5 演进路径
|
||||
|
||||
**判断要不要做架构的设计问题**:
|
||||
|
||||
| 问题 | 答案 |
|
||||
|------|------|
|
||||
| "MVP 只有 1 个 Provider,需要 AIProvider interface 吗?" | **不需要**(YAGNI) |
|
||||
| "MVP 只有 1 个 Dataset,需要 star_dataset_mapping 吗?" | **不需要**(写死) |
|
||||
| "MVP 流量小,需要 RedisLock 防并发吗?" | **不需要**(单实例部署) |
|
||||
| "MVP 不会换 Dify,需要 MiniMax fallback 吗?" | **不需要**(错了就报错) |
|
||||
| "MVP 想要对话跨天续接,需要 PostgreSQL 持久化吗?" | **需要**(追星场景长生命周期) |
|
||||
| "MVP 想要拒答敏感词,需要 AuditService 吗?" | **需要**(V1 已有) |
|
||||
|
||||
**反面案例**(2026-06-29):
|
||||
- V2 文档设计了 Provider 抽象、ProviderFactory、ConversationStore 抽象、DatasetResolver、WorkflowClient/HistoryClient 拆分、AIProfile 等
|
||||
- 实际 MVP 只需要:JWT + ConversationRepository + DifyClient + StreamChat + 后置审核 + 保存消息
|
||||
- 5 倍的复杂度,**0 业务价值**
|
||||
|
||||
### 设计方案文档的自审清单
|
||||
|
||||
每次完成/大改设计方案文档,必须做以下自审(除了上面"全局自审"通用规则外):
|
||||
|
||||
- [ ] 文档开头是否有"方案概述"?
|
||||
- [ ] "方案概述"是否包含:要解决的问题、实现路径、关键决策、核心架构图?
|
||||
- [ ] **是否违反 MVP 先行原则?**(设计的抽象/复杂度是否超过当前业务需要)
|
||||
- [ ] 实现路径是否给了明确的时间估算和里程碑?
|
||||
- [ ] 关键决策是否能通过超链接定位到详细章节?
|
||||
- [ ] 核心架构图是否覆盖了所有关键组件?
|
||||
- [ ] 完整架构文档开头是否标注"MVP 不实施,仅路线图"?
|
||||
|
||||
### 设计方案修改的同步原则
|
||||
|
||||
修改设计文档时,**必须同时检查**:
|
||||
|
||||
1. **未改章节的引用一致性**(用 grep 查找旧字段名)
|
||||
2. **目录列表与实际文件**(§10 改了,§11 也要同步)
|
||||
3. **代码示例的语法**(Go/Rust/Python 等)
|
||||
4. **章节编号**(插入新章节后,后续编号是否需要顺延)
|
||||
|
||||
### 文档维护的"传染性"提醒
|
||||
|
||||
**修改一个章节会"传染"其他章节**:
|
||||
|
||||
| 修改 A 章节 | 必须同步检查的章节 |
|
||||
|------------|---------------------|
|
||||
| §3 Dify 配置 | §5 后端 + §11 部署 + §10 目录 |
|
||||
| §5 代码 | §6 时序图 + §11 main.go 装配 + §10 目录 |
|
||||
| §10 目录 | §11 部署任务清单 + §5 文件名引用 |
|
||||
| §11 部署 | §5 装配代码 + §10 文件列表 |
|
||||
| §1 关键决策 | §14 对比表 + §3.3 实施 |
|
||||
| **新增强大架构** | **是否违反 MVP 先行原则?是否需要拆为 MVP + 演进双文档?** |
|
||||
|
||||
---
|
||||
|
||||
## 本地规则说明
|
||||
|
||||
**这些规则是 Claude 在本仓库工作时必须遵守的本地约定**:
|
||||
|
||||
- 不提交到 git(除非用户明确指示)
|
||||
- 用户每次会话可能重复触发这些规则
|
||||
- 修改 `CLAUDE.md` 内容需用户明确同意
|
||||
- 规则优先级:用户消息 > CLAUDE.md > memory/ > 默认行为
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 接口开发规范
|
||||
|
||||
@ -83,7 +83,7 @@ SEGMENT_INFERENCE_URL=
|
||||
# Dify API 地址(自部署或云服务)
|
||||
DIFY_API_BASE=http://localhost/v1
|
||||
# Dify App API Key(laser_card_variants_v1 工作流)
|
||||
DIFY_API_KEY=app-tIfFhFwj3xnbRurK1oxxBXnA
|
||||
# DIFY_API_KEY=app-tIfFhFwj3xnbRurK1oxxBXnA
|
||||
# Dify 工作流名称(relay-dify 模式时使用的 Dify app,用于 prompt 增强)
|
||||
DIFY_WORKFLOW=laser_prompt_enhancer_v2
|
||||
# ==================== 镭射卡生成器 ====================
|
||||
@ -119,3 +119,8 @@ OPENAI_API_KEY=sk-eIOujD5rUugIRIPecFi3I2rFr6Bhxx1jsRzRm6phyNeeKrCI
|
||||
OPENAI_BASE_URL=https://api.weda.cc/v1
|
||||
# 中转站实际暴露的 image 模型
|
||||
OPENAI_MODEL=gpt-image-2
|
||||
|
||||
# ==================== aichatdify ====================
|
||||
DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ
|
||||
DIFY_API_BASE=http://localhost/v1
|
||||
DIFY_TIMEOUT_SEC=60
|
||||
|
||||
@ -41,8 +41,27 @@ CREATE INDEX IF NOT EXISTS idx_ai_user_memories_user_id ON ai_user_memories(user
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_user_memories_keywords ON ai_user_memories USING GIN(keywords);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_user_memories_weight ON ai_user_memories(weight DESC);
|
||||
|
||||
-- =============================================
|
||||
-- 3. ai_chat_configs (配置表,存储 Dify/LLM 等配置)
|
||||
-- =============================================
|
||||
CREATE TABLE IF NOT EXISTS ai_chat_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
config_key VARCHAR(128) UNIQUE NOT NULL,
|
||||
config_value TEXT NOT NULL,
|
||||
config_type VARCHAR(32) DEFAULT 'string',
|
||||
category VARCHAR(64),
|
||||
description VARCHAR(256),
|
||||
is_encrypted BOOLEAN DEFAULT FALSE,
|
||||
updated_at BIGINT,
|
||||
created_at BIGINT
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_chat_configs_category ON ai_chat_configs(category);
|
||||
|
||||
-- =============================================
|
||||
-- 回滚语句 (如需回滚)
|
||||
-- =============================================
|
||||
-- DROP TABLE IF EXISTS ai_chat_configs;
|
||||
-- DROP TABLE IF EXISTS ai_user_memories;
|
||||
-- DROP TABLE IF EXISTS ai_personas;
|
||||
@ -172,20 +172,49 @@ func main() {
|
||||
zap.Bool("has_qwen_key", qwenAPIKey != ""),
|
||||
)
|
||||
|
||||
// 创建 Service 层实例
|
||||
llmService := service.NewLLMService(
|
||||
miniMaxAPIURL,
|
||||
miniMaxAPIKey,
|
||||
miniMaxModel,
|
||||
qwenAPIURL,
|
||||
qwenAPIKey,
|
||||
qwenModel,
|
||||
// 获取 Dify 配置(MVP 阶段从环境变量读)
|
||||
difyAPIKey := getEnv("DIFY_API_KEY", "")
|
||||
difyAPIBase := getEnv("DIFY_API_BASE", "")
|
||||
if val, ok := llmConfigs["dify.api_key"]; ok && val != "" {
|
||||
difyAPIKey = val
|
||||
}
|
||||
if val, ok := llmConfigs["dify.api_base"]; ok && val != "" {
|
||||
difyAPIBase = val
|
||||
}
|
||||
logger.Logger.Info("Dify config loaded",
|
||||
zap.String("dify_api_base", difyAPIBase),
|
||||
zap.Bool("has_dify_key", difyAPIKey != ""),
|
||||
)
|
||||
|
||||
// 创建 AI Provider(Dify 或 LLM)
|
||||
var aiProvider service.AIProvider
|
||||
if difyAPIKey != "" {
|
||||
difyClient := service.NewDifyClient(service.DifyConfig{
|
||||
APIBase: difyAPIBase,
|
||||
APIKey: difyAPIKey,
|
||||
TimeoutSec: 120,
|
||||
})
|
||||
aiProvider = service.NewDifyAdapter(difyClient)
|
||||
logger.Logger.Info("Using Dify as AI provider")
|
||||
} else {
|
||||
llmService := service.NewLLMService(
|
||||
miniMaxAPIURL,
|
||||
miniMaxAPIKey,
|
||||
miniMaxModel,
|
||||
qwenAPIURL,
|
||||
qwenAPIKey,
|
||||
qwenModel,
|
||||
)
|
||||
aiProvider = llmService
|
||||
logger.Logger.Info("Using LLM (MiniMax) as AI provider")
|
||||
}
|
||||
|
||||
// 创建 Service 层实例
|
||||
personaService := service.NewPersonaService(personaRepo)
|
||||
memoryService := service.NewMemoryService(shortTermMemoryRepo, longTermMemoryRepo)
|
||||
auditService := service.NewAuditService()
|
||||
chatService := service.NewChatService(
|
||||
llmService,
|
||||
aiProvider,
|
||||
personaService,
|
||||
memoryService,
|
||||
auditService,
|
||||
|
||||
@ -160,39 +160,29 @@ func (p *AIChatProvider) SendMessage(ctx context.Context, req *pb.ChatMessageReq
|
||||
}
|
||||
|
||||
// 7. 调用大模型(流式)
|
||||
streamReader, err := p.chatService.LLMService.StreamChat(ctx, messages)
|
||||
streamReader, err := p.chatService.StreamChat(ctx, messages)
|
||||
if err != nil {
|
||||
logger.Logger.Error("LLM call failed", zap.Error(err))
|
||||
logger.Logger.Error("AI call failed", zap.Error(err))
|
||||
|
||||
// 检查是否是敏感内容错误
|
||||
if _, ok := err.(*service.SensitiveContentError); ok {
|
||||
logger.Logger.Info("Content blocked by MiniMax safety filter, trying backup model")
|
||||
// 尝试备用模型
|
||||
streamReader, err = p.chatService.LLMService.StreamChatWithBackup(ctx, messages)
|
||||
if err != nil {
|
||||
// 备用模型也失败
|
||||
logger.Logger.Error("Backup model also failed", zap.Error(err))
|
||||
stream.Send(&pb.ChatMessageResponse{
|
||||
Content: p.auditService.DefaultSafeResponse(),
|
||||
SessionId: sessionID,
|
||||
IsEnd: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// 其他错误,尝试备用模型
|
||||
streamReader, err = p.chatService.LLMService.StreamChatWithBackup(ctx, messages)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Backup model also failed", zap.Error(err))
|
||||
stream.Send(&pb.ChatMessageResponse{
|
||||
Content: "抱歉,服务暂时不可用",
|
||||
SessionId: sessionID,
|
||||
IsEnd: true,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
logger.Logger.Info("Content blocked by safety filter")
|
||||
stream.Send(&pb.ChatMessageResponse{
|
||||
Content: p.auditService.DefaultSafeResponse(),
|
||||
SessionId: sessionID,
|
||||
IsEnd: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
stream.Send(&pb.ChatMessageResponse{
|
||||
Content: "抱歉,服务暂时不可用",
|
||||
SessionId: sessionID,
|
||||
IsEnd: true,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
defer streamReader.Close()
|
||||
|
||||
|
||||
@ -6,9 +6,14 @@ import (
|
||||
"github.com/topfans/backend/services/aiChatService/model"
|
||||
)
|
||||
|
||||
// AIProvider 流式对话接口(实现类:LLMService、DifyAdapter)
|
||||
type AIProvider interface {
|
||||
StreamChat(ctx context.Context, messages []model.Message) (StreamReader, error)
|
||||
}
|
||||
|
||||
// ChatService 对话服务
|
||||
type ChatService struct {
|
||||
LLMService *LLMService
|
||||
aiProvider AIProvider
|
||||
personaService *PersonaService
|
||||
memoryService *MemoryService
|
||||
auditService *AuditService
|
||||
@ -18,7 +23,7 @@ type ChatService struct {
|
||||
|
||||
// NewChatService 创建对话服务
|
||||
func NewChatService(
|
||||
llmService *LLMService,
|
||||
aiProvider AIProvider,
|
||||
personaService *PersonaService,
|
||||
memoryService *MemoryService,
|
||||
auditService *AuditService,
|
||||
@ -26,7 +31,7 @@ func NewChatService(
|
||||
triggerTurns int,
|
||||
) *ChatService {
|
||||
return &ChatService{
|
||||
LLMService: llmService,
|
||||
aiProvider: aiProvider,
|
||||
personaService: personaService,
|
||||
memoryService: memoryService,
|
||||
auditService: auditService,
|
||||
@ -57,4 +62,9 @@ func (s *ChatService) ExtractMemory(ctx context.Context, userID int64, recentMes
|
||||
func (s *ChatService) GetWelcomeMessage(sessionID string, userID int64, starID int64) string {
|
||||
// 默认欢迎消息
|
||||
return "亲爱的你来辣 ~~"
|
||||
}
|
||||
|
||||
// StreamChat 流式对话(委托给 aiProvider)
|
||||
func (s *ChatService) StreamChat(ctx context.Context, messages []model.Message) (StreamReader, error) {
|
||||
return s.aiProvider.StreamChat(ctx, messages)
|
||||
}
|
||||
70
backend/services/aiChatService/service/dify_adapter.go
Normal file
70
backend/services/aiChatService/service/dify_adapter.go
Normal file
@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/topfans/backend/services/aiChatService/model"
|
||||
)
|
||||
|
||||
// DifyStreamReaderAdapter 适配 DifyStreamReader → StreamReader 接口
|
||||
// DifyStreamReader.Next() returns (content, done, convID)
|
||||
// StreamReader.Next() returns (content, done, error)
|
||||
type DifyStreamReaderAdapter struct {
|
||||
reader *DifyStreamReader
|
||||
}
|
||||
|
||||
func (a *DifyStreamReaderAdapter) Next() (string, bool, error) {
|
||||
content, done, _ := a.reader.Next()
|
||||
return content, done, nil
|
||||
}
|
||||
|
||||
func (a *DifyStreamReaderAdapter) Close() error {
|
||||
return a.reader.Close()
|
||||
}
|
||||
|
||||
// DifyAdapter Dify 适配器:适配 DifyClient → StreamReader 接口
|
||||
// 将 BuildPrompt 输出的 []model.Message 转换为 Dify StreamRequest
|
||||
type DifyAdapter struct {
|
||||
difyClient *DifyClient
|
||||
conversationID string
|
||||
}
|
||||
|
||||
// NewDifyAdapter 创建 Dify 适配器
|
||||
func NewDifyAdapter(difyClient *DifyClient) *DifyAdapter {
|
||||
return &DifyAdapter{
|
||||
difyClient: difyClient,
|
||||
}
|
||||
}
|
||||
|
||||
// StreamChat 将 []model.Message 转换为 Dify 流式调用
|
||||
// messages 的最后一条必须是 user 消息(当前输入)
|
||||
// Dify Workflow 端点不维护多轮上下文,需要通过 inputs.query 传入完整对话文本
|
||||
func (s *DifyAdapter) StreamChat(ctx context.Context, messages []model.Message) (StreamReader, error) {
|
||||
if len(messages) == 0 {
|
||||
return nil, fmt.Errorf("empty messages")
|
||||
}
|
||||
|
||||
// 取最后一条作为当前 query
|
||||
lastMsg := messages[len(messages)-1]
|
||||
if lastMsg.Role != "user" {
|
||||
return nil, fmt.Errorf("last message must be user role")
|
||||
}
|
||||
|
||||
req := StreamRequest{
|
||||
Query: lastMsg.Content,
|
||||
ConversationID: s.conversationID,
|
||||
}
|
||||
|
||||
reader, err := s.difyClient.StreamChat(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DifyStreamReaderAdapter{reader: reader}, nil
|
||||
}
|
||||
|
||||
// 确保接口实现正确
|
||||
var _ StreamReader = (*DifyStreamReaderAdapter)(nil)
|
||||
var _ io.Closer = (*DifyStreamReaderAdapter)(nil)
|
||||
213
backend/services/aiChatService/service/dify_client.go
Normal file
213
backend/services/aiChatService/service/dify_client.go
Normal file
@ -0,0 +1,213 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DifyConfig Dify 客户端配置
|
||||
// MVP 阶段:直接从环境变量读
|
||||
// Stage 2+:移到 ai_chat_configs 数据库
|
||||
type DifyConfig struct {
|
||||
APIBase string
|
||||
APIKey string
|
||||
TimeoutSec int
|
||||
}
|
||||
|
||||
// DifyClient Dify HTTP 客户端(MVP 唯一 AI 源)
|
||||
// ★ 简化:只调 Workflow /v1/workflows/run 流式接口
|
||||
// 不做:重试循环(V1 Network 错误重试 1 次,MVP 失败直接报错)
|
||||
// 不做:滑动窗口审计(V1 AuditService 逐 token 拦截已足够)
|
||||
type DifyClient struct {
|
||||
apiBase string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewDifyClient 创建 Dify 客户端
|
||||
func NewDifyClient(cfg DifyConfig) *DifyClient {
|
||||
return &DifyClient{
|
||||
apiBase: cfg.APIBase,
|
||||
apiKey: cfg.APIKey,
|
||||
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// StreamRequest 流式调用入参
|
||||
type StreamRequest struct {
|
||||
Query string
|
||||
ConversationID string
|
||||
}
|
||||
|
||||
// StreamChat 流式调用 Dify Workflow
|
||||
// 输入:用户消息 + Dify conversation_id(首次为空)
|
||||
// 输出:返回 (DifyStreamReader, error)
|
||||
func (c *DifyClient) StreamChat(ctx context.Context, req StreamRequest) (*DifyStreamReader, error) {
|
||||
body := map[string]interface{}{
|
||||
"inputs": map[string]string{"query": req.Query},
|
||||
"response_mode": "streaming",
|
||||
"conversation_id": req.ConversationID,
|
||||
"user": "aichat-mvp",
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dify request marshal: %w", err)
|
||||
}
|
||||
|
||||
url := c.apiBase + "/workflows/run"
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dify request new: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dify request: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("dify returned HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
logger.Logger.Info("Dify stream started", zap.String("url", url))
|
||||
return newDifyStreamReader(resp.Body), nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DifyStreamReader:解析 SSE 流
|
||||
// ============================================================
|
||||
|
||||
// newDifyStreamReader 创建流式读取器
|
||||
func newDifyStreamReader(body io.ReadCloser) *DifyStreamReader {
|
||||
scanner := bufio.NewScanner(body)
|
||||
// 增大 buffer 防止 Dify 流式 chunk 过大被截断
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
return &DifyStreamReader{
|
||||
body: body,
|
||||
conversationID: "",
|
||||
closed: false,
|
||||
scanner: scanner,
|
||||
}
|
||||
}
|
||||
|
||||
// DifyStreamReader Dify SSE 流式读取器
|
||||
// 简化版本:用 bufio.Scanner 按行读 data: 字段
|
||||
type DifyStreamReader struct {
|
||||
body io.ReadCloser
|
||||
conversationID string
|
||||
closed bool
|
||||
scanner *bufio.Scanner
|
||||
}
|
||||
|
||||
// Next 读下一条数据
|
||||
// 返回 (content, done, convID):
|
||||
// - content: 当前 token(流式)
|
||||
// - done: 流是否结束
|
||||
// - convID: 流结束后从 convID 读 Dify conversation_id
|
||||
func (r *DifyStreamReader) Next() (content string, done bool, convID string) {
|
||||
if r.closed {
|
||||
return "", true, r.conversationID
|
||||
}
|
||||
|
||||
for r.scanner.Scan() {
|
||||
line := strings.TrimSpace(r.scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimPrefix(line, "data: ")
|
||||
if payload == "[DONE]" {
|
||||
r.closed = true
|
||||
return "", true, r.conversationID
|
||||
}
|
||||
|
||||
// ★ MVP 修复:Dify Workflow 端点 (/v1/workflows/run) 的事件格式
|
||||
// - text_chunk: 流式分块,text 字段是内容
|
||||
// - workflow_finished: 结束事件,data.outputs.text 是完整内容
|
||||
// - 没有 conversation_id(Workflow 端点不维护 conversation_id)
|
||||
// ★ 与 Chatflow 端点 (/v1/chat-messages) 不同:
|
||||
// - Chatflow 用 event=message, answer 字段
|
||||
// - Workflow 用 event=text_chunk, text 字段
|
||||
var event struct {
|
||||
Event string `json:"event"`
|
||||
Text string `json:"text"` // Chatflow: 顶层 text
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Text string `json:"text"` // Workflow: data.text
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 记录 conversation_id(仅 Chatflow 有;Workflow 端点通常无)
|
||||
if event.ConversationID != "" && r.conversationID == "" {
|
||||
r.conversationID = event.ConversationID
|
||||
}
|
||||
|
||||
text := event.Data.Text
|
||||
if text == "" {
|
||||
text = event.Text
|
||||
}
|
||||
|
||||
switch event.Event {
|
||||
case "text_chunk":
|
||||
if text != "" {
|
||||
return text, false, ""
|
||||
}
|
||||
case "message":
|
||||
if text != "" {
|
||||
return text, false, ""
|
||||
}
|
||||
case "workflow_finished":
|
||||
// ★ MVP 关键:Dify Workflow 结束事件
|
||||
r.closed = true
|
||||
return "", true, r.conversationID
|
||||
case "message_end":
|
||||
// Chatflow 端点兼容
|
||||
r.closed = true
|
||||
return "", true, r.conversationID
|
||||
case "error":
|
||||
logger.Logger.Error("Dify stream error event", zap.String("message", event.Message))
|
||||
r.closed = true
|
||||
return "", true, r.conversationID
|
||||
}
|
||||
}
|
||||
|
||||
// scanner 结束(流关闭或出错)
|
||||
r.closed = true
|
||||
if err := r.scanner.Err(); err != nil {
|
||||
logger.Logger.Warn("Dify stream scan error", zap.Error(err))
|
||||
}
|
||||
return "", true, r.conversationID
|
||||
}
|
||||
|
||||
// Close 关闭流
|
||||
func (r *DifyStreamReader) Close() error {
|
||||
if r.closed {
|
||||
return nil
|
||||
}
|
||||
r.closed = true
|
||||
return r.body.Close()
|
||||
}
|
||||
|
||||
// GetConversationID 获取 Dify 返回的 conversation_id
|
||||
// 仅在 Next() 返回 done=true 后调用
|
||||
func (r *DifyStreamReader) GetConversationID() string {
|
||||
return r.conversationID
|
||||
}
|
||||
@ -50,4 +50,11 @@ SMS_SIGN_NAME=上海顶粉数字科技
|
||||
SMS_TEMPLATE_CODE=SMS_314621237
|
||||
SMS_REGION=cn-hangzhou
|
||||
|
||||
# ==================== Dify Configuration ====================
|
||||
# 服务器上部署的 Dify 服务(101.132.250.62,nginx 转发到 Dify API 容器 5001)
|
||||
# DIFY_API_BASE: Dify 服务的 API 地址(v1 是 API 前缀)
|
||||
# DIFY_API_KEY: 在 Dify 控制台「工作室」→「角角」→「后端服务 API」→「API 密钥」获取,格式: app-xxxxxxxxxxxx
|
||||
DIFY_API_BASE=http://101.132.250.62:8083/v1
|
||||
DIFY_API_KEY=app-iCsnp0R2jJppKdmrpoeOxEfL
|
||||
|
||||
|
||||
|
||||
@ -172,7 +172,7 @@ build_service() {
|
||||
-f "$DOCKERFILE" \
|
||||
--target "$docker_target" \
|
||||
--platform "$TARGET_ARCH" \
|
||||
-t "${IMAGE_PREFIX}/${service_name}:latest" \
|
||||
-t "${IMAGE_PREFIX}/${docker_target}:latest" \
|
||||
../
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
|
||||
391
docker/dify-deploy.sh
Normal file
391
docker/dify-deploy.sh
Normal file
@ -0,0 +1,391 @@
|
||||
#!/bin/bash
|
||||
# ===================================================================
|
||||
# TopFans Dify 部署脚本
|
||||
# 功能:服务器上安装 Dify + 配置环境变量
|
||||
# ===================================================================
|
||||
#
|
||||
# 使用前提:
|
||||
# 1. 服务器已配置 SSH 免密登录
|
||||
# 2. 服务器有足够的内存(推荐 4GB+)
|
||||
#
|
||||
# 使用方式:
|
||||
# ./dify-deploy.sh install # 安装 Dify 到服务器
|
||||
# ./dify-deploy.sh status # 查看 Dify 服务状态
|
||||
# ./dify-deploy.sh logs # 查看 Dify 日志
|
||||
# ./dify-deploy.sh restart # 重启 Dify
|
||||
# ./dify-deploy.sh upgrade # 升级 Dify 版本
|
||||
# ./dify-deploy.sh uninstall # 卸载 Dify
|
||||
# ./dify-deploy.sh show-config # 显示需要配置到 TopFans 的环境变量
|
||||
#
|
||||
# ===================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# ==================== 颜色定义 ====================
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ==================== 路径配置 ====================
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
SERVER_HOST="101.132.250.62"
|
||||
SERVER_PORT="22"
|
||||
SERVER_USER="root"
|
||||
SERVER_PATH="/opt/dify/docker"
|
||||
|
||||
# ==================== Dify 配置 ====================
|
||||
# 端口说明:
|
||||
# EXPOSE_NGINX_PORT: 对外统一入口(nginx),TopFans DIFY_API_BASE 走这个
|
||||
# CONSOLE_WEB_PORT: Dify 管理控制台(浏览器手动访问)
|
||||
# APP_WEB_PORT: Dify API 容器端口(5001),内部用,外部不直接连
|
||||
# 默认值对齐 docker/.env.prod 中 DIFY_API_BASE=http://101.132.250.62:8083/v1
|
||||
DIFY_NGINX_PORT="8083" # Dify Web/API 统一入口(nginx)
|
||||
DIFY_CONSOLE_PORT="8084" # Dify 控制台
|
||||
DIFY_API_PORT="8085" # Dify API 容器直连(内部)
|
||||
|
||||
# 密码与密钥:留空,install 时由 openssl rand 生成强随机值
|
||||
# (避免硬编码弱密码,所有 Dify 实例使用不同密钥)
|
||||
DIFY_DB_PASSWORD=""
|
||||
DIFY_REDIS_PASSWORD=""
|
||||
|
||||
# ==================== SSH 命令 ====================
|
||||
ssh_cmd() {
|
||||
ssh -o StrictHostKeyChecking=no -p "${SERVER_PORT}" "${SERVER_USER}@${SERVER_HOST}" "$@"
|
||||
}
|
||||
|
||||
# ==================== docker-compose 命令检测 ====================
|
||||
# 兼容 v1 二进制与 v2 插件(v2 在新版 Docker 默认安装,但 v1 命令不存在)
|
||||
# 优先用 v1;没有则检查 v2 plugin 并建软链伪装成 v1(参考 deploy.sh:341)
|
||||
dc_cmd() {
|
||||
if ssh_cmd "command -v docker-compose" &>/dev/null; then
|
||||
echo "docker-compose"
|
||||
return 0
|
||||
fi
|
||||
if ssh_cmd "docker compose version" &>/dev/null; then
|
||||
# v2 插件存在,建软链伪装成 v1
|
||||
ssh_cmd "ln -sf /usr/libexec/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose 2>/dev/null" || true
|
||||
fi
|
||||
if ! ssh_cmd "command -v docker-compose" &>/dev/null; then
|
||||
print_msg "$RED" "❌ docker-compose 未安装且 v2 插件建软链失败"
|
||||
return 1
|
||||
fi
|
||||
echo "docker-compose"
|
||||
}
|
||||
|
||||
# ==================== 打印函数 ====================
|
||||
print_step() {
|
||||
echo ""
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${BLUE} $1${NC}"
|
||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
}
|
||||
|
||||
print_msg() {
|
||||
local color=$1
|
||||
local msg=$2
|
||||
echo -e "${color}${msg}${NC}"
|
||||
}
|
||||
|
||||
# ==================== 帮助信息 ====================
|
||||
show_help() {
|
||||
cat << EOF
|
||||
${MAGENTA}TopFans Dify 部署脚本${NC}
|
||||
|
||||
${YELLOW}命令:${NC}
|
||||
${GREEN}install${NC} 安装 Dify 到服务器(首次部署)
|
||||
${GREEN}status${NC} 查看 Dify 服务状态
|
||||
${GREEN}logs${NC} 查看 Dify 日志
|
||||
${GREEN}restart${NC} 重启 Dify
|
||||
${GREEN}upgrade${NC} 升级 Dify 版本
|
||||
${GREEN}uninstall${NC} 卸载 Dify
|
||||
${GREEN}show-config${NC} 显示需要配置到 TopFans 的环境变量
|
||||
|
||||
${YELLOW}前提准备:${NC}
|
||||
1. 服务器已配置 SSH 密钥登录
|
||||
2. 服务器内存 >= 4GB
|
||||
|
||||
${YELLOW}注意事项:${NC}
|
||||
- Dify 使用独立端口(不与 TopFans 冲突)
|
||||
- Dify 使用独立的 PostgreSQL 和 Redis
|
||||
- Workflow 需要在 Dify 控制台手动导入
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# ==================== 1. 安装 Dify ====================
|
||||
do_install() {
|
||||
print_step "🚀 安装 Dify 到服务器"
|
||||
|
||||
print_msg "$YELLOW" "目标服务器: ${SERVER_USER}@${SERVER_HOST}:${SERVER_PORT}"
|
||||
print_msg "$YELLOW" "安装路径: ${SERVER_PATH}"
|
||||
echo ""
|
||||
|
||||
# 检查服务器 Docker 环境
|
||||
print_msg "$YELLOW" "检查 Docker 环境..."
|
||||
local docker_version=$(ssh_cmd "docker --version 2>/dev/null || echo 'NOT_INSTALLED'")
|
||||
print_msg "$GREEN" "Docker: ${docker_version}"
|
||||
|
||||
# 如果 Docker 未安装,先安装
|
||||
if [ "$docker_version" = "NOT_INSTALLED" ]; then
|
||||
print_msg "$YELLOW" "Docker 未安装,开始安装..."
|
||||
ssh_cmd "curl -fsSL https://get.docker.com | sh"
|
||||
ssh_cmd "systemctl start docker && systemctl enable docker"
|
||||
print_msg "$GREEN" "✅ Docker 安装完成"
|
||||
fi
|
||||
|
||||
# 检查 docker-compose
|
||||
local compose_version=$(ssh_cmd "docker-compose --version 2>/dev/null || docker compose version 2>/dev/null || echo 'NOT_INSTALLED'")
|
||||
print_msg "$GREEN" "docker-compose: ${compose_version}"
|
||||
|
||||
# 创建服务器目录
|
||||
print_msg "$YELLOW" "创建目录..."
|
||||
ssh_cmd "mkdir -p ${SERVER_PATH}"
|
||||
print_msg "$GREEN" "✅ 目录创建完成"
|
||||
|
||||
# 检测并设置 docker-compose 命令(v1/v2 兼容)
|
||||
local DC
|
||||
DC=$(dc_cmd) || { print_msg "$RED" "❌ docker-compose 不可用,请先安装 Docker"; return 1; }
|
||||
|
||||
# 保护已存在的 .env:重跑 install 时保留用户配置(SECRET_KEY/管理员密码等)
|
||||
if ssh_cmd "[ -f ${SERVER_PATH}/.env ]"; then
|
||||
print_msg "$YELLOW" "⚠️ 检测到已存在的 .env,跳过下载和配置"
|
||||
print_msg "$YELLOW" " 如需重新配置,请先备份 ${SERVER_PATH}/.env 后删除"
|
||||
else
|
||||
# 下载 Dify docker-compose
|
||||
print_msg "$YELLOW" "下载 Dify docker-compose..."
|
||||
ssh_cmd "cd ${SERVER_PATH} && \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/main/docker/docker-compose.yaml \
|
||||
-o docker-compose.yml 2>/dev/null || \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/v0.15.0/docker/docker-compose.yaml \
|
||||
-o docker-compose.yml"
|
||||
print_msg "$GREEN" "✅ docker-compose.yml 下载完成"
|
||||
|
||||
# 下载 .env 示例文件
|
||||
print_msg "$YELLOW" "下载 .env 配置..."
|
||||
ssh_cmd "cd ${SERVER_PATH} && \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/main/docker/.env.example \
|
||||
-o .env 2>/dev/null || \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/v0.15.0/docker/.env.example \
|
||||
-o .env"
|
||||
print_msg "$GREEN" "✅ .env 下载完成"
|
||||
|
||||
# 修改 .env 配置(端口、密码等)
|
||||
# ★ heredoc delimiter 不可加单引号,否则 \$VAR 不展开
|
||||
print_msg "$YELLOW" "配置 Dify 环境变量..."
|
||||
|
||||
ssh_cmd "cat > ${SERVER_PATH}/.env.custom << ENVEOF
|
||||
# 端口配置(避免与 TopFans 冲突)
|
||||
EXPOSE_NGINX_PORT=${DIFY_NGINX_PORT}
|
||||
CONSOLE_WEB_PORT=${DIFY_CONSOLE_PORT}
|
||||
APP_WEB_PORT=${DIFY_API_PORT}
|
||||
|
||||
# 数据库配置
|
||||
DB_USERNAME=postgres
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=dify
|
||||
POSTGRES_PASSWORD=\$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
|
||||
|
||||
# Redis 配置
|
||||
REDIS_PASSWORD=\$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
|
||||
|
||||
# API 配置(Dify 官方要求 SECRET_KEY 至少 42 字符)
|
||||
SECRET_KEY=\$(openssl rand -base64 42)
|
||||
INIT_SECRET_KEY=\$(openssl rand -base64 42)
|
||||
ENVEOF
|
||||
"
|
||||
ssh_cmd "cd ${SERVER_PATH} && cat .env .env.custom > .env.tmp && mv .env.tmp .env && rm -f .env.custom"
|
||||
print_msg "$GREEN" "✅ 环境变量配置完成"
|
||||
fi
|
||||
|
||||
# 启动 Dify
|
||||
print_msg "$YELLOW" "启动 Dify 服务(首次启动需要几分钟)..."
|
||||
ssh_cmd "cd ${SERVER_PATH} && ${DC} up -d"
|
||||
|
||||
# 等待服务启动
|
||||
print_msg "$YELLOW" "等待服务启动(最多 120 秒)..."
|
||||
local count=0
|
||||
while [ $count -lt 120 ]; do
|
||||
local api_status=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://localhost:${DIFY_API_PORT}/health 2>/dev/null || echo '000'")
|
||||
if [ "$api_status" = "200" ]; then
|
||||
print_msg "$GREEN" "✅ Dify API 就绪"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
count=$((count + 5))
|
||||
echo -n "."
|
||||
done
|
||||
echo ""
|
||||
|
||||
print_step "📊 Dify 安装结果"
|
||||
print_msg "$GREEN" "✅ Dify 安装完成!"
|
||||
echo ""
|
||||
print_msg "$CYAN" "访问地址:"
|
||||
print_msg "$YELLOW" " 控制台: http://${SERVER_HOST}:${DIFY_CONSOLE_PORT}"
|
||||
print_msg "$YELLOW" " Web: http://${SERVER_HOST}:${DIFY_NGINX_PORT}"
|
||||
print_msg "$YELLOW" " API: http://${SERVER_HOST}:${DIFY_API_PORT}"
|
||||
echo ""
|
||||
print_msg "$RED" "⚠️ 首次访问需要创建管理员账号!"
|
||||
echo ""
|
||||
print_msg "$CYAN" "下一步:"
|
||||
print_msg "$YELLOW" " 1. 访问控制台 http://${SERVER_HOST}:${DIFY_CONSOLE_PORT},创建管理员账号"
|
||||
print_msg "$YELLOW" " 2. 在控制台导入 Workflow(docs/dify/*.yml)"
|
||||
print_msg "$YELLOW" " 3. 运行 ./dify-deploy.sh show-config 查看 TopFans 配置"
|
||||
}
|
||||
|
||||
# ==================== 2. 查看 Dify 状态 ====================
|
||||
do_status() {
|
||||
print_step "📊 Dify 服务状态"
|
||||
|
||||
local DC
|
||||
DC=$(dc_cmd) || return 1
|
||||
ssh_cmd "cd ${SERVER_PATH} && ${DC} ps 2>/dev/null || echo 'Dify 未安装或未启动'"
|
||||
echo ""
|
||||
|
||||
# 健康检查
|
||||
local api_status=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://localhost:${DIFY_API_PORT}/health 2>/dev/null || echo '000'")
|
||||
if [ "$api_status" = "200" ]; then
|
||||
print_msg "$GREEN" "✅ Dify API 健康"
|
||||
else
|
||||
print_msg "$RED" "❌ Dify API 异常 (HTTP ${api_status})"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==================== 3. 查看日志 ====================
|
||||
do_logs() {
|
||||
print_step "📋 Dify 日志"
|
||||
print_msg "$YELLOW" "按 Ctrl+C 退出日志"
|
||||
echo ""
|
||||
local DC
|
||||
DC=$(dc_cmd) || return 1
|
||||
ssh_cmd "cd ${SERVER_PATH} && ${DC} logs -f 2>/dev/null || echo 'Dify 未安装'"
|
||||
}
|
||||
|
||||
# ==================== 4. 重启 ====================
|
||||
do_restart() {
|
||||
print_step "🔄 重启 Dify"
|
||||
print_msg "$YELLOW" "正在重启..."
|
||||
local DC
|
||||
DC=$(dc_cmd) || return 1
|
||||
ssh_cmd "cd ${SERVER_PATH} && ${DC} restart 2>/dev/null"
|
||||
sleep 10
|
||||
do_status
|
||||
}
|
||||
|
||||
# ==================== 5. 升级 ====================
|
||||
do_upgrade() {
|
||||
print_step "⬆️ 升级 Dify"
|
||||
|
||||
print_msg "$RED" "警告: 升级前建议备份数据!"
|
||||
read -p "确认升级? (y/N): " confirm < /dev/tty
|
||||
if [ "$confirm" != "y" ]; then
|
||||
print_msg "$YELLOW" "已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
local DC
|
||||
DC=$(dc_cmd) || return 1
|
||||
|
||||
print_msg "$YELLOW" "下载最新 docker-compose..."
|
||||
ssh_cmd "cd ${SERVER_PATH} && \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/main/docker/docker-compose.yaml \
|
||||
-o docker-compose.yml"
|
||||
|
||||
print_msg "$YELLOW" "执行升级..."
|
||||
ssh_cmd "cd ${SERVER_PATH} && ${DC} up -d"
|
||||
|
||||
print_msg "$GREEN" "✅ 升级完成"
|
||||
}
|
||||
|
||||
# ==================== 6. 卸载 ====================
|
||||
do_uninstall() {
|
||||
print_step "🗑️ 卸载 Dify"
|
||||
|
||||
print_msg "$RED" "警告: 此操作将删除所有 Dify 数据!"
|
||||
read -p "确认卸载? (输入 'YES' 确认): " confirm < /dev/tty
|
||||
if [ "$confirm" != "YES" ]; then
|
||||
print_msg "$YELLOW" "已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
local DC
|
||||
DC=$(dc_cmd) || return 1
|
||||
|
||||
print_msg "$YELLOW" "停止并删除容器..."
|
||||
ssh_cmd "cd ${SERVER_PATH} && ${DC} down -v 2>/dev/null || true"
|
||||
|
||||
print_msg "$YELLOW" "删除数据卷..."
|
||||
ssh_cmd "docker volume rm \$(docker volume list -q -f name=dify) 2>/dev/null || true"
|
||||
|
||||
print_msg "$GREEN" "✅ Dify 卸载完成"
|
||||
}
|
||||
|
||||
# ==================== 7. 显示 TopFans 配置 ====================
|
||||
show_topfans_config() {
|
||||
print_step "📝 TopFans 后端配置"
|
||||
|
||||
print_msg "$CYAN" "需要在 /opt/topfans/docker/.env.prod 中配置以下环境变量:"
|
||||
echo ""
|
||||
print_msg "$YELLOW" "# ========== Dify 配置 =========="
|
||||
print_msg "$YELLOW" "# Dify API 地址(走 nginx 统一入口 ${DIFY_NGINX_PORT},与 Dify 容器内 APP_WEB_PORT 不同)"
|
||||
print_msg "$CYAN" "DIFY_API_BASE=http://${SERVER_HOST}:${DIFY_NGINX_PORT}/v1"
|
||||
echo ""
|
||||
print_msg "$YELLOW" "# Dify API Key(从 Dify 控制台获取)"
|
||||
print_msg "$CYAN" "DIFY_API_KEY=app-你的APIKey"
|
||||
echo ""
|
||||
|
||||
print_msg "$RED" "⚠️ 配置完成后需要重启 TopFans 服务:"
|
||||
print_msg "$RED" " ./deploy.sh restart --server ${SERVER_HOST}"
|
||||
}
|
||||
|
||||
# ==================== 主函数 ====================
|
||||
main() {
|
||||
if [ $# -eq 0 ]; then
|
||||
show_help
|
||||
exit 0
|
||||
fi
|
||||
|
||||
local command=$1
|
||||
shift
|
||||
|
||||
case $command in
|
||||
install)
|
||||
do_install
|
||||
;;
|
||||
status)
|
||||
do_status
|
||||
;;
|
||||
logs)
|
||||
do_logs
|
||||
;;
|
||||
restart)
|
||||
do_restart
|
||||
;;
|
||||
upgrade)
|
||||
do_upgrade
|
||||
;;
|
||||
uninstall)
|
||||
do_uninstall
|
||||
;;
|
||||
show-config)
|
||||
show_topfans_config
|
||||
;;
|
||||
-h|--help|help)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
print_msg "$RED" "错误: 未知命令 '$command'"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
227
docs/dify/角角.yml
Normal file
227
docs/dify/角角.yml
Normal file
@ -0,0 +1,227 @@
|
||||
app:
|
||||
description: ''
|
||||
icon: 🤖
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: workflow
|
||||
name: 角角
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies:
|
||||
- current_identifier: null
|
||||
type: marketplace
|
||||
value:
|
||||
marketplace_plugin_unique_identifier: langgenius/minimax:0.0.21@ccfbb9b4f38d35b1daa2483daaad5ed5402896d1119d68601a820a806910bf87
|
||||
version: null
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload:
|
||||
allowed_file_extensions:
|
||||
- .JPG
|
||||
- .JPEG
|
||||
- .PNG
|
||||
- .GIF
|
||||
- .WEBP
|
||||
- .SVG
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
enabled: false
|
||||
fileUploadConfig:
|
||||
attachment_image_file_size_limit: 2
|
||||
audio_file_size_limit: 50
|
||||
batch_count_limit: 5
|
||||
file_size_limit: 15
|
||||
file_upload_limit: 20
|
||||
image_file_batch_limit: 10
|
||||
image_file_size_limit: 10
|
||||
single_chunk_attachment_limit: 10
|
||||
video_file_size_limit: 100
|
||||
workflow_file_upload_limit: 10
|
||||
image:
|
||||
enabled: false
|
||||
number_limits: 3
|
||||
transfer_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
number_limits: 3
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: start
|
||||
targetType: knowledge-retrieval
|
||||
id: 1782726878899-source-1782726916693-target
|
||||
source: '1782726878899'
|
||||
sourceHandle: source
|
||||
target: '1782726916693'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: knowledge-retrieval
|
||||
targetType: llm
|
||||
id: 1782726916693-source-1782726955948-target
|
||||
source: '1782726916693'
|
||||
sourceHandle: source
|
||||
target: '1782726955948'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: llm
|
||||
targetType: end
|
||||
id: 1782726955948-source-1782727496103-target
|
||||
source: '1782726955948'
|
||||
sourceHandle: source
|
||||
target: '1782727496103'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
nodes:
|
||||
- data:
|
||||
selected: false
|
||||
title: 用户输入
|
||||
type: start
|
||||
variables:
|
||||
- default: ''
|
||||
hint: ''
|
||||
label: query
|
||||
options: []
|
||||
placeholder: ''
|
||||
required: true
|
||||
type: text-input
|
||||
variable: query
|
||||
height: 108
|
||||
id: '1782726878899'
|
||||
position:
|
||||
x: 16
|
||||
y: 277
|
||||
positionAbsolute:
|
||||
x: 16
|
||||
y: 277
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
dataset_ids:
|
||||
- NCv1DfzNsC8G/IjCISUkvry7LBfVIV8mXqRliZH+TUgfixw/U0t6Tz5wWsLdX/Vh
|
||||
multiple_retrieval_config:
|
||||
reranking_enable: false
|
||||
reranking_mode: reranking_model
|
||||
top_k: 4
|
||||
query_attachment_selector: []
|
||||
query_variable_selector:
|
||||
- '1782726878899'
|
||||
- query
|
||||
retrieval_mode: multiple
|
||||
selected: false
|
||||
title: 知识检索
|
||||
type: knowledge-retrieval
|
||||
height: 89
|
||||
id: '1782726916693'
|
||||
position:
|
||||
x: 230
|
||||
y: 103.1999999999999
|
||||
positionAbsolute:
|
||||
x: 230
|
||||
y: 103.1999999999999
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
context:
|
||||
enabled: true
|
||||
variable_selector:
|
||||
- '1782726916693'
|
||||
- result
|
||||
model:
|
||||
completion_params:
|
||||
temperature: 0.7
|
||||
mode: chat
|
||||
name: minimax-m3
|
||||
provider: langgenius/minimax/minimax
|
||||
prompt_config:
|
||||
jinja2_variables: []
|
||||
prompt_template:
|
||||
- edition_type: basic
|
||||
id: b656505c-e726-47c4-b036-6e6025595a8c
|
||||
role: system
|
||||
text: 你是角角,专注于肖战的粉丝助手
|
||||
- id: db882835-3ffe-4600-b95a-9eca51b93ce2
|
||||
role: user
|
||||
text: '{{#context#}}}{{#1782726878899.query#}}}'
|
||||
selected: false
|
||||
title: LLM
|
||||
type: llm
|
||||
vision:
|
||||
enabled: false
|
||||
height: 87
|
||||
id: '1782726955948'
|
||||
position:
|
||||
x: 358
|
||||
y: 510
|
||||
positionAbsolute:
|
||||
x: 358
|
||||
y: 510
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- '1782726955948'
|
||||
- text
|
||||
value_type: string
|
||||
variable: text
|
||||
selected: false
|
||||
title: 输出
|
||||
type: end
|
||||
height: 88
|
||||
id: '1782727496103'
|
||||
position:
|
||||
x: 612
|
||||
y: 235
|
||||
positionAbsolute:
|
||||
x: 612
|
||||
y: 235
|
||||
selected: true
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
viewport:
|
||||
x: 19
|
||||
y: -99.1999999999999
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
1431
docs/specs/2026-06-29-ai-chat-dify-integration-v2-design.md
Normal file
1431
docs/specs/2026-06-29-ai-chat-dify-integration-v2-design.md
Normal file
File diff suppressed because it is too large
Load Diff
939
docs/specs/2026-06-29-ai-chat-dify-mvp-design.md
Normal file
939
docs/specs/2026-06-29-ai-chat-dify-mvp-design.md
Normal file
@ -0,0 +1,939 @@
|
||||
# AI 搭子 MVP 方案
|
||||
|
||||
> **本文档是实施级方案**。所有"为未来 100 明星 + 多 AI 平台"准备的复杂设计(Provider 抽象、ProviderFactory、Pipeline、ConversationStore 抽象等)**MVP 阶段不实现**。
|
||||
>
|
||||
> V2 完整架构文档([2026-06-29-ai-chat-dify-integration-v2-design.md](2026-06-29-ai-chat-dify-integration-v2-design.md))保留为**长期演进路线图**,MVP 阶段不实施。
|
||||
|
||||
> **🎉 2026-06-30 MVP 端到端跑通!**
|
||||
>
|
||||
> 验证结果(实测):
|
||||
> - ✅ 后端 20008 端口正常 Dubbo 监听
|
||||
> - ✅ Dify Workflow 流式响应 8 个 chunk 正常返回
|
||||
> - ✅ Dify 内部用 minimax-m3 模型推理("嗨!我是角角~..." 完整回复)
|
||||
> - ✅ ai_conversations 表写入 message_count=2
|
||||
> - ✅ ai_messages 表写入 user="hi" + assistant="嗨!我是角角~..." (228 字符)
|
||||
> - ✅ Gateway → aichatservice → Dify → DB 完整链路打通
|
||||
|
||||
---
|
||||
|
||||
## 〇、我们解决的问题
|
||||
|
||||
### 〇.1 业务问题
|
||||
|
||||
追星 App 用户与"角角"AI 搭子聊天时,AI **没有专属知识库**,无法针对所追明星(默认肖战)给出有针对性的回答:
|
||||
|
||||
- 用户:"肖战最近有什么新作品?"
|
||||
- 现状:AI 只能泛泛而谈("肖战是中国男演员...")
|
||||
- 期望:AI 应能基于最新资料回答"根据知识库,肖战的新剧《X》将于 X 月上映"
|
||||
|
||||
### 〇.2 技术问题
|
||||
|
||||
| # | 问题 | 严重度 | MVP 解决方式 |
|
||||
|---|------|--------|------------|
|
||||
| 1 | **缺乏 RAG 能力**:AI 没有专属数据 | 🔴 高 | 接入 Dify Workflow + 肖战知识库 |
|
||||
| 2 | **会话无持久化**:当前仅 Redis 缓存 24h | 🟡 中 | PostgreSQL 持久化 + Redis 缓存(★ 核心) |
|
||||
| 3 | **AI 平台耦合**:未来要接 Coze/FastGPT | 🟢 低 | MVP 不做,1 个 Dify 够用 |
|
||||
| 4 | **运营成本失控**:100 明星时配置爆炸 | 🟢 低 | MVP 固定 1 个星 |
|
||||
|
||||
### 〇.3 解决方式(**业务驱动**,不是架构驱动)
|
||||
|
||||
| 阶段 | 做什么 | 不做什么 | 触发条件 |
|
||||
|------|--------|---------|---------|
|
||||
| **MVP** | Dify + PostgreSQL + Redis | Provider 抽象、Fallback、多星 | 现在 |
|
||||
| Stage 2 | 多星 Dataset 切换 | Provider 抽象 | 加第 2 个星 / 用户 > 1000 |
|
||||
| Stage 3 | MiniMax fallback | 抽象 | Dify 偶发故障 |
|
||||
| Stage 4 | Provider 抽象 | 复杂 Pipeline | 接第 2 个 AI 平台 / 用户 > 10万 |
|
||||
| Stage 5 | Pipeline / AIProfile | A/B 测试 / 灰度 | 用户 > 100万 |
|
||||
|
||||
**核心原则**:**业务不到不做架构**。MVP 阶段不实施"为未来 100 明星 + 多 AI 平台"准备的复杂设计。
|
||||
|
||||
---
|
||||
|
||||
## 〇、文档说明
|
||||
|
||||
- **MVP 范围**:只有 1 个明星(默认肖战 star_id=87)
|
||||
- **AI 平台**:只有 Dify(无 MiniMax fallback,无 Provider 抽象)
|
||||
- **目标**:验证业务假设(用户愿意与"角角"聊天)
|
||||
- **后续演进**:见 [§十 演进路径](#十演进路径)
|
||||
|
||||
---
|
||||
|
||||
## 〇.1 方案概述(★ 必读)
|
||||
|
||||
### 〇.1.1 我们要做什么
|
||||
|
||||
**业务**:在追星 App 里加"AI 搭子"功能。用户进入后默认看到"角角"(肖战的 AI 形象),可以问"肖战最近在干嘛?"等关于肖战的问题。
|
||||
|
||||
**MVP 范围**:
|
||||
- ✅ 1 个明星(肖战)
|
||||
- ✅ 1 个 Dify Workflow(含 1 个肖战知识库)
|
||||
- ✅ 1 种回复源(Dify)
|
||||
- ✅ WebSocket 流式输出
|
||||
- ❌ 不做:多星切换、模型 fallback、人设自定义、长期记忆提取、多 AI 平台
|
||||
|
||||
### 〇.1.2 整体实现路径
|
||||
|
||||
```
|
||||
数据迁移 Dify 端准备 后端代码 联调测试
|
||||
├ 建 2 张表 ├ 准备知识库 ├ model ├ 内部账号测试
|
||||
├ ├ 创 Workflow ├ repository ├ WebSocket 联调
|
||||
└ └ 不需要 Code 节点 └ service └ 错误注入
|
||||
└ provider
|
||||
└ main.go 装配
|
||||
```
|
||||
|
||||
**不要分配实施时间**——按业务节奏推进。
|
||||
|
||||
### 〇.1.3 关键决策(80% 推到 Stage 2+)
|
||||
|
||||
| 决策项 | MVP 选择 | Stage 2+ 再考虑 |
|
||||
|--------|---------|-----------------|
|
||||
| Dify 架构 | **Workflow**(含 1 个 Dataset) | 多星时加 dataset 变量 |
|
||||
| AI Provider | **Dify 单源**(无抽象) | Stage 3 才抽象 `AIProvider` |
|
||||
| 会话存储 | **PostgreSQL 主 + Redis 缓存** | 沿用 MVP 设计 |
|
||||
| Fallback | **无**(Dify 失败就报错) | Stage 2 看需求 |
|
||||
| Memory 提取 | **不做**(每轮都入 ai_messages) | Stage 2+ 看需求 |
|
||||
| Provider 抽象 | **不做** | Stage 3+ |
|
||||
| 数据集切换 | **固定 1 个**(肖战) | Stage 2 多星时加 mapping |
|
||||
|
||||
### 〇.1.4 核心架构图(TL;DR)
|
||||
|
||||
```
|
||||
用户(追星 App)
|
||||
│ WebSocket
|
||||
▼
|
||||
Gateway (现有, 不改)
|
||||
│ Dubbo Triple
|
||||
▼
|
||||
AIChatService.ChatService
|
||||
│
|
||||
├─ JWT 鉴权 (从 Dubbo attachments 取 user_id)
|
||||
│
|
||||
├─ 保存/读取会话: ConversationRepository
|
||||
│ ├─ ai_conversations (PostgreSQL, ★ V2 关键决策保留)
|
||||
│ └─ ai_messages (PostgreSQL, ★ V2 关键决策保留)
|
||||
│
|
||||
├─ 调 Dify (★ 唯一 AI 源)
|
||||
│ └─ DifyClient.StreamChat()
|
||||
│ └─ POST /v1/workflows/run
|
||||
│
|
||||
└─ SSE 流 → WebSocket → 客户端
|
||||
|
||||
外部: Dify Workflow
|
||||
┌──────────────────────┐
|
||||
│ 开始 │
|
||||
│ ↓ │
|
||||
│ Knowledge Retrieval │ ← 固定查"肖战知识库"
|
||||
│ ↓ │
|
||||
│ LLM │ ← 固定 Prompt (角角人设)
|
||||
│ ↓ │
|
||||
│ 结束 │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
**关键简化**:
|
||||
- ❌ 没有 Provider 抽象
|
||||
- ❌ 没有 ProviderFactory
|
||||
- ❌ 没有 MemoryStore
|
||||
- ❌ 没有 RedisLock(并发问题 MVP 阶段不严重)
|
||||
- ❌ 没有 AIProfile
|
||||
- ❌ 没有 DatasetResolver
|
||||
- ❌ 没有 Star→Dataset mapping(只有 1 个星)
|
||||
- ❌ 没有 Fallback 逻辑
|
||||
- ❌ 没有 Memory 提取循环
|
||||
- ❌ 没有 Star App 多 Workflow
|
||||
|
||||
---
|
||||
|
||||
## 一、MVP 范围
|
||||
|
||||
### 1.1 包含
|
||||
|
||||
- ✅ 1 个明星(肖战 star_id=87)
|
||||
- ✅ 1 个 Dify Workflow(3 节点)
|
||||
- ✅ 1 个 Dify Dataset(肖战专属知识库)
|
||||
- ✅ WebSocket 协议(沿用现有)
|
||||
- ✅ JWT 鉴权(沿用现有)
|
||||
- ✅ Audit 前置 + 后置(沿用现有 AuditService)
|
||||
- ✅ Conversation 持久化到 PostgreSQL(**核心决策,MVP 即落地**)
|
||||
- ✅ Redis 缓存(沿用现有)
|
||||
|
||||
### 1.2 不包含(Stage 2+ 再做)
|
||||
|
||||
- ❌ 多星切换(用户不能选其他明星)
|
||||
- ❌ MiniMax fallback(Dify 失败就报错给用户)
|
||||
- ❌ Persona 自定义(人设固定"角角")
|
||||
- ❌ 长期记忆提取(不分析对话提取记忆)
|
||||
- ❌ Provider 抽象接口(直接调 DifyClient)
|
||||
- ❌ 人设/风格/语言/记忆等参数化(都写死在 Dify Prompt 里)
|
||||
- ❌ Dify 内容审核节点(AuditService 已拦截,MVP 阶段够用)
|
||||
- ❌ Datasets 动态切换(固定"肖战知识库")
|
||||
|
||||
---
|
||||
|
||||
## 二、整体架构
|
||||
|
||||
### 2.1 数据流(一次完整对话)
|
||||
|
||||
```
|
||||
[Mobile]
|
||||
│ WebSocket send {action: "send_message", session_id, message}
|
||||
│
|
||||
▼
|
||||
[Gateway Hub]
|
||||
│ 鉴权 (JWT → user_id)
|
||||
│
|
||||
▼
|
||||
[AIChatService Provider.SendMessage]
|
||||
│
|
||||
├─ 1. 前置审核 (AuditService.AuditText) ★ 现有代码
|
||||
│
|
||||
├─ 2. 获取/创建会话 (ConversationRepository)
|
||||
│ ├─ PostgreSQL ai_conversations (★ V2 关键)
|
||||
│ └─ Redis 缓存 1h (现有代码)
|
||||
│
|
||||
├─ 3. 调 Dify (★ MVP 唯一 AI 源)
|
||||
│ └─ DifyClient.StreamChat()
|
||||
│ └─ POST /v1/workflows/run
|
||||
│
|
||||
├─ 4. 流式返回 + 逐 token 后置审核 (AuditService.AuditResponse)
|
||||
│ └─ ★ 现有代码
|
||||
│
|
||||
├─ 5. 保存消息 (ConversationRepository)
|
||||
│ └─ PostgreSQL ai_messages (★ V2 关键)
|
||||
│
|
||||
└─ 6. (Stage 2+ 才做) 记忆提取
|
||||
```
|
||||
|
||||
### 2.2 关键简化点
|
||||
|
||||
| 维度 | V2 文档 | MVP 实际 |
|
||||
|------|--------|---------|
|
||||
| 核心业务逻辑 | 9+ 步骤 | **4 步骤**(审计/会话/Dify/保存) |
|
||||
| Provider 数 | 2 个(Dify + MiniMax) | **1 个**(Dify) |
|
||||
| Fallback | 复杂的 Provider 切换 | **没有**(Dify 失败就报错) |
|
||||
| 星切换 | 动态 + dataset 映射 | **固定肖战** |
|
||||
| 人设/风格/记忆 | 4 个 SystemInputs 参数 | **写死在 Dify Prompt** |
|
||||
| 长期记忆 | MemoryStore + 5 轮触发 | **不做** |
|
||||
| Provider 抽象 | `AIProvider` interface | **直接调 DifyClient** |
|
||||
| Workflow 节点 | 5 个(含 Code + Moderation) | **3 个**(开始/检索/LLM/结束) |
|
||||
| 锁 | RedisLock | **不需要**(单实例部署,无并发问题) |
|
||||
| 后端代码行数估算 | 1500-2000 | **300-500** |
|
||||
|
||||
---
|
||||
|
||||
## 三、Dify 端配置
|
||||
|
||||
### 3.1 准备知识库(Dataset)
|
||||
|
||||
1. 登录 Dify → "知识库" → "创建知识库"
|
||||
2. 命名:`star-xz-kb`(固定一个)
|
||||
3. 索引模式:`high_quality`
|
||||
4. 导入肖战资料(作品、行程、近期事件等)
|
||||
5. 等待向量化完成(每个文档显示 ✓)
|
||||
|
||||
### 3.2 创建 Workflow(仅 3 节点)
|
||||
|
||||
1. 进入"工作室" → "创建空白应用" → 类型选 **Workflow**
|
||||
2. 命名:`star-chat-workflow`
|
||||
3. 配置"开始"节点的 Input 变量:
|
||||
```yaml
|
||||
inputs:
|
||||
- name: query # 用户消息
|
||||
type: text
|
||||
```
|
||||
> **注**:`user_id`(哈希后的用户标识)由后端通过 Dify 协议**顶层 `user` 字段**传入(见 [§4.5 DifyClient](2026-06-29-ai-chat-dify-mvp-design.md#45-difyclient--mvp-唯一-ai-客户端)),不作为 workflow input。
|
||||
>
|
||||
> **与实际工作流对齐**:[`docs/dify/角角.yml`](../../dify/角角.yml) v0.6.0 只声明了 `query` 一个 input。若后端仍把 `user_id` 塞进 `inputs`,Dify 会静默丢弃,不影响功能(`user` 字段仍生效)。
|
||||
4. 添加 **知识检索节点**:
|
||||
- Knowledge:选 `star-xz-kb`(固定)
|
||||
- Query:`{{ query }}`
|
||||
- TopK:3
|
||||
5. 添加 **LLM 节点**:
|
||||
```markdown
|
||||
你叫"角角",是肖战的 AI 形象。
|
||||
|
||||
【用户问题】
|
||||
{{ query }}
|
||||
|
||||
【知识库检索结果】
|
||||
{{#knowledge_retrieval_node.result#}}
|
||||
|
||||
请用温柔、自然的语言回答,参考知识库内容,不要编造。
|
||||
```
|
||||
6. 添加"直接回复"或"结束"节点
|
||||
|
||||
**节点序列**:
|
||||
```
|
||||
[开始] inputs:{query}
|
||||
↓
|
||||
[知识检索] star-xz-kb(固定)
|
||||
↓
|
||||
[LLM] 角角人设(固定)
|
||||
↓
|
||||
[结束]
|
||||
```
|
||||
|
||||
### 3.3 调试
|
||||
|
||||
1. 传 `inputs={query: "肖战最近在干嘛?", user_id: "aichat-xxxx"}`
|
||||
2. 验证:返回基于知识库的回答
|
||||
3. 发布 → 复制 API Key(`app-xxx`)
|
||||
|
||||
---
|
||||
|
||||
## 四、后端代码
|
||||
|
||||
### 4.1 改动总览
|
||||
|
||||
| 层级 | 改动 | 工作量 |
|
||||
|------|------|--------|
|
||||
| `model/ai_chat_models.go` | **新增**:`AIConversation` / `AIMessage` GORM 模型 | 小 |
|
||||
| `repository/conversation_repository.go` | **新建**:ai_conversations / ai_messages CRUD | 中 |
|
||||
| `service/chat_service.go` | **修改**:直接调 DifyClient(不再有 ChatEngine 编排) | 中 |
|
||||
| `provider/ai_chat_provider.go` | **修改**:Dubbo 入口 + 调 ChatService | 小 |
|
||||
| `main.go` | **修改**:加 ConversationRepository 装配 | 小 |
|
||||
| `migrations/ai_conversations.sql` | **新建**:2 张表 DDL | 小 |
|
||||
| **前端** | **无改动** ✅ | 0 |
|
||||
|
||||
**总工作量**:约 **300-500 行核心代码**
|
||||
|
||||
### 4.2 model/ai_chat_models.go 新增
|
||||
|
||||
```go
|
||||
package model
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type AIConversation struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID int64 `gorm:"index;not null"`
|
||||
StarID int64 `gorm:"index;not null"` // MVP 固定为 87
|
||||
ProviderName string `gorm:"type:varchar(32);not null;default:'dify'"`
|
||||
ExternalConvID string `gorm:"type:varchar(128);default:''"`
|
||||
MessageCount int `gorm:"default:0"`
|
||||
LastActiveAt int64 `gorm:"autoUpdateTime:milli"`
|
||||
CreatedAt int64 `gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `gorm:"autoUpdateTime:milli"`
|
||||
}
|
||||
|
||||
func (AIConversation) TableName() string { return "ai_conversations" }
|
||||
|
||||
type AIMessage struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ConversationID int64 `gorm:"index;not null"`
|
||||
Role string `gorm:"type:varchar(16);not null"` // 'user' / 'assistant'
|
||||
Content string `gorm:"type:text;not null"`
|
||||
CreatedAt int64 `gorm:"autoCreateTime:milli"`
|
||||
}
|
||||
|
||||
func (AIMessage) TableName() string { return "ai_messages" }
|
||||
```
|
||||
|
||||
### 4.3 PostgreSQL DDL
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
star_id BIGINT NOT NULL,
|
||||
provider_name VARCHAR(32) NOT NULL DEFAULT 'dify',
|
||||
external_conversation_id VARCHAR(128) DEFAULT '',
|
||||
message_count INT DEFAULT 0,
|
||||
last_active_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
|
||||
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
|
||||
updated_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT
|
||||
);
|
||||
CREATE INDEX idx_ai_conv_user_star ON ai_conversations(user_id, star_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
conversation_id BIGINT NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
||||
role VARCHAR(16) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT
|
||||
);
|
||||
CREATE INDEX idx_ai_messages_conversation ON ai_messages(conversation_id, created_at);
|
||||
```
|
||||
|
||||
> MVP 阶段**不**加唯一约束(user_id + star_id),方便 Stage 2 加多星时再处理
|
||||
> MVP 阶段**不**加 `is_archived` 等字段
|
||||
|
||||
### 4.4 ConversationRepository
|
||||
|
||||
```go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/topfans/backend/services/aiChatService/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConversationRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewConversationRepository(db *gorm.DB) *ConversationRepository {
|
||||
return &ConversationRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ConversationRepository) GetOrCreate(ctx context.Context, userID, starID int64) (*model.AIConversation, error) {
|
||||
var conv model.AIConversation
|
||||
err := r.db.WithContext(ctx).Where("user_id = ? AND star_id = ?", userID, starID).First(&conv).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
conv = model.AIConversation{UserID: userID, StarID: starID, ProviderName: "dify"}
|
||||
if err := r.db.WithContext(ctx).Create(&conv).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
func (r *ConversationRepository) AppendMessage(ctx context.Context, convID int64, role, content string) error {
|
||||
msg := model.AIMessage{ConversationID: convID, Role: role, Content: content}
|
||||
return r.db.WithContext(ctx).Create(&msg).Error
|
||||
}
|
||||
|
||||
func (r *ConversationRepository) UpdateExternalConvID(ctx context.Context, convID int64, externalID string) error {
|
||||
return r.db.WithContext(ctx).Model(&model.AIConversation{}).
|
||||
Where("id = ?", convID).
|
||||
Updates(map[string]interface{}{
|
||||
"external_conversation_id": externalID,
|
||||
"message_count": gorm.Expr("message_count + 1"),
|
||||
"last_active_at": gorm.Expr("(EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT"),
|
||||
}).Error
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 DifyClient(★ MVP 唯一 AI 客户端)
|
||||
|
||||
```go
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/topfans/backend/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type DifyClient struct {
|
||||
apiBase string
|
||||
workflowURL string
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type DifyConfig struct {
|
||||
APIBase string
|
||||
WorkflowURL string
|
||||
APIKey string
|
||||
TimeoutSec int
|
||||
}
|
||||
|
||||
func NewDifyClient(cfg DifyConfig) *DifyClient {
|
||||
return &DifyClient{
|
||||
apiBase: cfg.APIBase,
|
||||
workflowURL: cfg.WorkflowURL,
|
||||
apiKey: cfg.APIKey,
|
||||
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// StreamChat 流式调用 Dify Workflow
|
||||
// 返回 (StreamReader, error):StreamReader 可逐 token 读取
|
||||
func (c *DifyClient) StreamChat(ctx context.Context, query, userHashedID, convID string) (*DifyStreamReader, error) {
|
||||
inputs := map[string]interface{}{
|
||||
"query": query,
|
||||
"user_id": userHashedID,
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"inputs": inputs,
|
||||
"response_mode": "streaming",
|
||||
"conversation_id": convID, // 首次为空
|
||||
"user": userHashedID,
|
||||
}
|
||||
jsonData, _ := json.Marshal(body)
|
||||
httpReq, _ := http.NewRequestWithContext(ctx, "POST",
|
||||
c.apiBase+c.workflowURL, bytes.NewReader(jsonData))
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dify request: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("dify returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return &DifyStreamReader{reader: resp.Body, decoder: NewSSEDecoder(resp.Body), conversationID: ""}, nil
|
||||
}
|
||||
|
||||
// DifyStreamReader 解析 Dify SSE 流
|
||||
type DifyStreamReader struct {
|
||||
reader io.ReadCloser
|
||||
decoder *SSEDecoder
|
||||
conversationID string
|
||||
}
|
||||
|
||||
func (r *DifyStreamReader) Next() (content string, done bool, err error) {
|
||||
for {
|
||||
line, err := r.decoder.Next()
|
||||
if err != nil {
|
||||
if err == io.EOF { return "", true, nil }
|
||||
return "", true, err
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") { continue }
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var event struct {
|
||||
Event string `json:"event"`
|
||||
Answer string `json:"answer"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &event); err != nil { continue }
|
||||
|
||||
if event.ConversationID != "" && r.conversationID == "" {
|
||||
r.conversationID = event.ConversationID
|
||||
}
|
||||
switch event.Event {
|
||||
case "message":
|
||||
return event.Answer, false, nil
|
||||
case "message_end":
|
||||
return "", true, nil
|
||||
case "error":
|
||||
return "", true, fmt.Errorf("dify error event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DifyStreamReader) GetConversationID() string { return r.conversationID }
|
||||
func (r *DifyStreamReader) Close() error { return r.reader.Close() }
|
||||
```
|
||||
|
||||
> ★ MVP 阶段**没有**滑动窗口审计、retry 循环、敏感词检测(现有 AuditService 已足够)
|
||||
|
||||
### 4.6 ChatService(核心业务逻辑)
|
||||
|
||||
```go
|
||||
package service
|
||||
|
||||
type ChatService struct {
|
||||
audit *AuditService
|
||||
convRepo *repository.ConversationRepository
|
||||
difyClient *DifyClient
|
||||
userIDSalt string
|
||||
}
|
||||
|
||||
func NewChatService(audit *AuditService, convRepo *repository.ConversationRepository, dify *DifyClient) *ChatService {
|
||||
return &ChatService{audit: audit, convRepo: convRepo, difyClient: dify}
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultStarID = int64(87) // 肖战
|
||||
DefaultUserSalt = "topfans-default-salt"
|
||||
)
|
||||
|
||||
func (s *ChatService) hashUserID(userID int64) string {
|
||||
h := sha256.Sum256([]byte(fmt.Sprintf("%d:%s", userID, s.userIDSalt)))
|
||||
return "aichat-" + hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
// SendMessage 核心流程(4 步)
|
||||
func (s *ChatService) SendMessage(ctx context.Context, userID int64, message string) (<-chan *StreamChunk, error) {
|
||||
out := make(chan *StreamChunk, 16)
|
||||
go func() {
|
||||
defer close(out)
|
||||
|
||||
// 1. 前置审核
|
||||
if !s.audit.AuditText(message) {
|
||||
out <- &StreamChunk{Type: "message", Content: s.audit.DefaultSafeResponse(), IsEnd: false}
|
||||
out <- &StreamChunk{Type: "message", IsEnd: true}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 获取/创建会话(★ V2 关键决策:PostgreSQL 持久化)
|
||||
conv, err := s.convRepo.GetOrCreate(ctx, userID, DefaultStarID)
|
||||
if err != nil {
|
||||
out <- &StreamChunk{Type: "error", Error: "会话创建失败"}
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 调 Dify(★ MVP 唯一 AI 源)
|
||||
streamReader, err := s.difyClient.StreamChat(ctx, message, s.hashUserID(userID), conv.ExternalConvID)
|
||||
if err != nil {
|
||||
logger.Logger.Error("Dify call failed", zap.Error(err))
|
||||
out <- &StreamChunk{Type: "error", Error: "服务暂不可用"}
|
||||
return
|
||||
}
|
||||
defer streamReader.Close()
|
||||
|
||||
// 4. 流式返回 + 后置审核 + 保存
|
||||
var fullResponse string
|
||||
for {
|
||||
content, done, err := streamReader.Next()
|
||||
if err != nil {
|
||||
out <- &StreamChunk{Type: "error", Error: "服务异常"}
|
||||
return
|
||||
}
|
||||
if content != "" && !s.audit.AuditResponse(content) {
|
||||
// 命中敏感词
|
||||
out <- &StreamChunk{Type: "message", Content: s.audit.DefaultSafeResponse(), IsEnd: false}
|
||||
out <- &StreamChunk{Type: "message", IsEnd: true}
|
||||
s.convRepo.AppendMessage(ctx, conv.ID, "assistant", s.audit.DefaultSafeResponse())
|
||||
return
|
||||
}
|
||||
fullResponse += content
|
||||
out <- &StreamChunk{Type: "message", Content: content, IsEnd: done}
|
||||
if done { break }
|
||||
}
|
||||
|
||||
// 5. 更新 Dify conv_id(首次)
|
||||
if newConvID := streamReader.GetConversationID(); newConvID != "" && newConvID != conv.ExternalConvID {
|
||||
s.convRepo.UpdateExternalConvID(ctx, conv.ID, newConvID)
|
||||
}
|
||||
|
||||
// 6. 保存消息
|
||||
s.convRepo.AppendMessage(ctx, conv.ID, "user", message)
|
||||
s.convRepo.AppendMessage(ctx, conv.ID, "assistant", fullResponse)
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetWelcomeMessage MVP 阶段固定返回"角角"欢迎语
|
||||
func (s *ChatService) GetWelcomeMessage() string {
|
||||
return "你好,我是角角,专注肖战的 AI 搭子。有什么想了解的?"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 Provider 大幅简化
|
||||
|
||||
```go
|
||||
package provider
|
||||
|
||||
type AIChatProvider struct {
|
||||
chatService *service.ChatService
|
||||
}
|
||||
|
||||
func (p *AIChatProvider) SendMessage(ctx context.Context, req *pb.ChatMessageRequest, stream pb.AIChatService_SendMessageServer) error {
|
||||
userID, _, err := extractUserInfoFromDubboAttachments(ctx)
|
||||
if err != nil { return err }
|
||||
|
||||
chunks, err := p.chatService.SendMessage(ctx, userID, req.Message)
|
||||
if err != nil { return err }
|
||||
|
||||
for chunk := range chunks {
|
||||
if chunk.Type == "error" {
|
||||
stream.Send(&pb.ChatMessageResponse{Content: chunk.Error, IsEnd: true})
|
||||
} else {
|
||||
stream.Send(&pb.ChatMessageResponse{Content: chunk.Content, IsEnd: chunk.IsEnd})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
> **极简**:~30 行。**完全没有 V2 里的 ChatEngine 编排、锁、Provider 抽象、Factory 等**
|
||||
|
||||
### 4.8 main.go 装配
|
||||
|
||||
```go
|
||||
// MVP 装配:极简
|
||||
convRepo := repository.NewConversationRepository(database.GetDB())
|
||||
difyClient := service.NewDifyClient(service.DifyConfig{
|
||||
APIBase: getEnv("DIFY_API_BASE", "https://api.dify.ai/v1"),
|
||||
WorkflowURL: "/workflows/run",
|
||||
APIKey: getEnv("DIFY_API_KEY", ""),
|
||||
TimeoutSec: 60,
|
||||
})
|
||||
chatService := service.NewChatService(auditService, convRepo, difyClient)
|
||||
aiChatProvider := provider.NewAIChatProvider(chatService)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、消息协议(无改动)
|
||||
|
||||
WebSocket 协议与现有实现一致:
|
||||
- Client → Server:`{action: "send_message", session_id, message}`
|
||||
- Server → Client:`{type: "message", content, is_end}` 或 `{type: "error", error}`
|
||||
|
||||
**前端零改动**。
|
||||
|
||||
---
|
||||
|
||||
## 六、关键设计决策
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| Provider 抽象 | **不做** | MVP 只有 1 个 AI 源,抽象无价值 |
|
||||
| MiniMax fallback | **不做** | Dify 失败就报错,避免增加复杂度 |
|
||||
| 长期记忆提取 | **不做** | 业务假设未验证前不做 |
|
||||
| Redis 缓存 | **保留** | 1h 缓存会话元数据,避免每次查 DB |
|
||||
| PostgreSQL 持久化 | **保留(★ 关键)** | 跨设备/跨天续接(追星场景长生命周期) |
|
||||
| 滑动窗口审计 | **不做** | 现有 AuditService 逐 token 检查已足够 |
|
||||
| 锁 | **不做** | 单实例部署,并发问题不严重 |
|
||||
| Dify 内容审核节点 | **不做** | 现有 AuditService 已拦截,MVP 够用 |
|
||||
| UserStyle/UserNickname 参数 | **不做** | 写死在 Dify Prompt 里 |
|
||||
| 星切换 | **不做** | 固定 star_id=87(肖战) |
|
||||
|
||||
---
|
||||
|
||||
## 七、配置清单
|
||||
|
||||
### 7.1 环境变量
|
||||
|
||||
| 变量 | 用途 | 必填 |
|
||||
|------|------|------|
|
||||
| `DIFY_API_KEY` | Dify Workflow API Key | 是 |
|
||||
| `DIFY_API_BASE` | Dify API 地址 | 否(默认 https://api.dify.ai/v1) |
|
||||
|
||||
### 7.2 ai_chat_configs(★ MVP 全部不要)
|
||||
|
||||
**MVP 阶段直接用环境变量,不写 ai_chat_configs 数据库**。
|
||||
|
||||
**V2 文档里 9 个 `dify.*` 配置项 MVP 全部不需要**(enabler、api_base、workflow_url、star_dataset_mapping、api_key、timeout_sec、user_id_salt、retry_count、fallback_to_minimax)——**全部 hardcode 或用环境变量**。
|
||||
|
||||
> Stage 2+ 才把这些移到数据库配置。
|
||||
|
||||
---
|
||||
|
||||
## 八、部署清单
|
||||
|
||||
> **不要分配实施时间**。按业务节奏推进。
|
||||
|
||||
### 8.1 数据库
|
||||
|
||||
- [ ] DBA 执行 `migrations/ai_conversations.sql`
|
||||
- [ ] 验证表结构和索引
|
||||
|
||||
### 8.2 Dify 端
|
||||
|
||||
- [ ] Dify 管理员创建 `star-xz-kb` 知识库
|
||||
- [ ] 导入肖战资料并等待向量化完成
|
||||
- [ ] 创建 `star-chat-workflow`(3 节点:开始/检索/LLM)
|
||||
- [ ] 配置 Prompt("你是角角,温柔回复,参考知识库...")
|
||||
- [ ] 调试并发布
|
||||
- [ ] 把 API Key 安全转给后端
|
||||
|
||||
### 8.3 后端代码
|
||||
|
||||
- [ ] 新建 `model/ai_chat_models.go` 的 AIConversation/AIMessage
|
||||
- [ ] 新建 `repository/conversation_repository.go`
|
||||
- [ ] 新建 `service/dify_client.go`
|
||||
- [ ] 修改 `service/chat_service.go`(**直接调 DifyClient,不引入 ChatEngine**)
|
||||
- [ ] 简化 `provider/ai_chat_provider.go`
|
||||
- [ ] 修改 `main.go` 装配
|
||||
- [ ] 单元测试:ConversationRepository CRUD
|
||||
- [ ] 集成测试:mock Dify server 跑完整 SendMessage
|
||||
|
||||
### 8.4 联调测试
|
||||
|
||||
- [ ] 内部账号测试:进 ai-dazi 页面发消息
|
||||
- [ ] 验证:流式返回正常
|
||||
- [ ] 验证:ai_messages 表有 user + assistant 两条记录
|
||||
- [ ] 验证:关掉重开会话能续接
|
||||
- [ ] 验证:敏感词("裸聊"等)被拦截
|
||||
- [ ] 验证:Dify 故障时返回明确错误
|
||||
|
||||
---
|
||||
|
||||
## 九、验证清单
|
||||
|
||||
### 9.1 功能验证
|
||||
|
||||
- [ ] 发送"肖战最近在干嘛?"能返回基于知识库的回答
|
||||
- [ ] 发送"你好"能返回通用问候
|
||||
- [ ] 同用户第二次发消息能续接上下文(Dify conversation_id)
|
||||
- [ ] ai_messages 表有 user + assistant 两条记录
|
||||
- [ ] ai_conversations 表的 message_count 正确递增
|
||||
|
||||
### 9.2 安全验证
|
||||
|
||||
- [ ] 前置审核:用户发"裸聊"等敏感词被拦截
|
||||
- [ ] 后置审核:Dify 回复中含敏感词被拦截
|
||||
- [ ] Dify API Key 不出现在日志
|
||||
|
||||
### 9.3 不验证(Stage 2+ 再做)
|
||||
|
||||
- ~~多星切换~~(MVP 不做)
|
||||
- ~~Fallback~~(MVP 不做)
|
||||
- ~~长期记忆提取~~(MVP 不做)
|
||||
|
||||
---
|
||||
|
||||
## 十、Stage 2+ 演进路径
|
||||
|
||||
MVP 跑通后,根据用户量和业务反馈,按以下顺序演进:
|
||||
|
||||
| Stage | 触发条件 | 关键改动 |
|
||||
|-------|---------|---------|
|
||||
| **Stage 2** | 用户量 > 1000 OR 加第 2 个星 | 1. 多星 Dataset 切换(star_dataset_mapping)<br>2. WebSocket 端 InitSession 欢迎语动态化<br>3. ai_conversations 加 UNIQUE(user_id, star_id) |
|
||||
| **Stage 3** | Dify 偶发故障 OR SLA 要求 | 1. MiniMax fallback(仅 message_count=0 时)<br>2. Dify retry 循环 |
|
||||
| **Stage 4** | 用户量 > 10万 OR 接 2+ AI 平台 | 1. AIProvider 抽象<br>2. ProviderFactory 策略模式<br>3. CozeProvider / OpenAIProvider 实现 |
|
||||
| **Stage 5** | 用户量 > 100万 OR 业务复杂 | 1. ChatEngine Pipeline 化<br>2. AIProfile 配置化(A/B 测试、灰度)<br>3. 长期记忆提取 |
|
||||
|
||||
**关键原则**:每个 Stage 都是**业务驱动**,不是架构驱动。
|
||||
|
||||
### ★ Stage 2+ 演进时必踩的 3 个坑(P0 修复笔记)
|
||||
|
||||
> ★ **这些是 V2 架构评审发现的真实 bug**,MVP 阶段不修(流量小、问题不暴露),但 **Stage 2+ 流量上来后必现**。
|
||||
> ★ **必读**:实施 Stage 2 之前,**必须**先修这 3 个 P0 问题。
|
||||
|
||||
#### 坑 1:并发请求分裂会话(★ P0-1)
|
||||
|
||||
**症状**:用户手机 + 平板同时发消息,Dify 端产生两个会话,AI 上下文错乱。
|
||||
|
||||
**根因**:两个并发请求都查到 `ExternalConvID=""`,都调 Dify,Dify 给两个不同的 `conversation_id`,后写入的覆盖先写入的。
|
||||
|
||||
**修复**:
|
||||
- 加 Redis 分布式锁 `conv_lock:{userId}:{starId}`,TTL 30s
|
||||
- 锁范围:`GetOrCreateConversation` → `UpdateExternalID`
|
||||
- 锁未获取时 sleep 200ms 重试一次
|
||||
|
||||
#### 坑 2:审计拦截后不保存对话(★ P0-2)
|
||||
|
||||
**症状**:用户每次触发敏感词拦截后,AI 都不记得之前说过什么,行为诡异。
|
||||
|
||||
**根因**:审计分支直接 return,没保存"user 原句 + 安全回复"。
|
||||
|
||||
**修复**:审计分支也调 `AppendConversationMessages` 保存对话。
|
||||
|
||||
#### 坑 3:组合敏感词漏检(★ P0-3)
|
||||
|
||||
**症状**:Dify 返回"色"+"情"分两个 token,单独都不违规,组合违规。
|
||||
|
||||
**根因**:V1 AuditService 逐 token 检查(`strings.Contains(token, word)`),单 token 视角。
|
||||
|
||||
**修复**:Dify 流式接收时维护 sliding window buffer(20 字符),每个 token 检查 buffer。
|
||||
|
||||
### ★ Stage 4 抽象时必踩的 3 个坑(架构评审笔记)
|
||||
|
||||
> ★ 这些是 V2 架构评审发现的 **设计层面**问题,Stage 4 做 AIProvider 抽象时必踩。
|
||||
|
||||
#### 坑 4:Provider God Class(评审 #1)
|
||||
|
||||
**症状**:DifyProvider 写了 2000+ 行,什么都管(HTTP / Stream / Cache / Conversation / Retry / Hash / Audit)。
|
||||
|
||||
**修复**:拆为 4 个组件:
|
||||
- `WorkflowClient`:HTTP + SSE 解析 + Retry
|
||||
- `HistoryClient`:拉历史消息
|
||||
- `DatasetResolver`:star_id → dataset_id 映射
|
||||
- `ConversationStore`:缓存 + 持久化
|
||||
|
||||
**原则**:Provider 只做协调,不做任何具体工作。
|
||||
|
||||
#### 坑 5:Provider 直接依赖 Redis/Repository(评审 #5)
|
||||
|
||||
**症状**:Provider 改存储(Redis → Memcached)时,所有 Provider 都要改。
|
||||
|
||||
**修复**:引入 `ConversationStore` 抽象,Provider 只依赖接口。底层是 `CachedConversationStore`(PostgreSQL + Redis 缓存)。
|
||||
|
||||
#### 坑 6:Workflow 与 Backend 重复维护 dataset 映射(评审 #3)
|
||||
|
||||
**症状**:Backend 改 `star_dataset_mapping` 忘改 Workflow → 用户问"肖战"答"王一博"的资料。
|
||||
|
||||
**修复**:
|
||||
- **单一数据源**:映射只在 Backend `ai_chat_configs.dify.star_dataset_mapping` 维护
|
||||
- Workflow 输入直接接 `dataset_id`(Backend 传过来的)
|
||||
- Workflow 内部**不**维护任何 `star_id → dataset_id` 映射(**没有 Code 节点**)
|
||||
|
||||
### ★ 演进时不要做
|
||||
|
||||
- ❌ **不要预先做 P0 修复**:MVP 阶段流量小,race / 审计保存 / 组合敏感词都暴露不出来
|
||||
- ❌ **不要预先做 Provider 抽象**:MVP 只有 1 个 AI 源(写死就行)
|
||||
- ❌ **不要预先做 Pipeline**:SendMessage 函数 < 200 行不需要 Pipeline
|
||||
- ❌ **不要预先做 AIProfile**:1 个星 1 种 AI 源根本不需要 A/B
|
||||
|
||||
---
|
||||
|
||||
## 十一、与 V2 文档的关系
|
||||
|
||||
**V2 文档已删除**(2026-06-29 决定)。
|
||||
|
||||
原因:V2 是"100 明星 + 多 AI 平台"的完整架构设计,**MVP 不需要 80% 的内容**。V2 关键内容已迁移到本 MVP 文档的 [§10 演进路径](#十stage-2-演进路径),包含 3 个 P0 修复笔记 + 3 个 Stage 4 抽象笔记。
|
||||
|
||||
实施时按 MVP 推进,跑通后再按 §10 Stage 2+ 演进。
|
||||
|
||||
---
|
||||
|
||||
## 十二、关键文件清单
|
||||
|
||||
### 12.1 新建文件
|
||||
|
||||
```
|
||||
backend/services/aiChatService/
|
||||
├── model/
|
||||
│ └── ai_chat_models.go (新增 AIConversation / AIMessage struct)
|
||||
├── repository/
|
||||
│ └── conversation_repository.go (新建,3 个方法)
|
||||
├── service/
|
||||
│ ├── dify_client.go (新建,唯一 AI 客户端)
|
||||
│ └── chat_service.go (修改,4 步流程)
|
||||
|
||||
migrations/
|
||||
└── ai_conversations.sql (新建,2 张表 DDL)
|
||||
```
|
||||
|
||||
### 12.2 修改文件
|
||||
|
||||
```
|
||||
backend/services/aiChatService/
|
||||
├── provider/
|
||||
│ └── ai_chat_provider.go (大幅简化)
|
||||
└── main.go (加 convRepo + difyClient 装配)
|
||||
```
|
||||
|
||||
### 12.3 不动文件
|
||||
|
||||
- 前端(所有 .vue / .js)
|
||||
- Gateway(WebSocket Hub)
|
||||
- AuditService(现有代码,MVP 沿用)
|
||||
- JWT 鉴权(现有代码)
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
**MVP 阶段的核心是验证业务假设,不是搭建完美架构**。
|
||||
|
||||
### MVP 范围
|
||||
|
||||
- ✅ 用户能跟"角角"聊天
|
||||
- ✅ AI 能基于肖战知识库回答
|
||||
- ✅ 对话跨天续接(PostgreSQL 持久化)
|
||||
- ✅ 敏感词拦截(AuditService 沿用)
|
||||
|
||||
### 不做(业务驱动)
|
||||
|
||||
- ❌ Provider 抽象(只有 1 个 AI 源)
|
||||
- ❌ Fallback(Dify 失败就报错)
|
||||
- ❌ 多星(只有 1 个星)
|
||||
- ❌ 长期记忆提取(每轮直接入 ai_messages)
|
||||
- ❌ Persona 自定义(写死在 Dify Prompt)
|
||||
- ❌ Dify 内容审核节点(AuditService 够用)
|
||||
- ❌ 9 个 dify.* 数据库配置(环境变量够用)
|
||||
|
||||
### 业务驱动原则
|
||||
|
||||
每个 Stage 都是**业务驱动**,不是架构驱动:
|
||||
|
||||
1. 业务没到的复杂度 → **不预先做**
|
||||
2. 架构是演进的,**不是一次性完美设计**
|
||||
3. V2 完整文档作为**长期演进路线图**,**不删除**
|
||||
4. 跑通 MVP 后,按 §十 Stage 2-5 渐进改进
|
||||
|
||||
**这些就够了**。其他都是"未来 100 明星 + 多 AI 平台"的事。
|
||||
678
docs/superpowers/plans/2026-06-26-lenticular-webgl-engine.md
Normal file
678
docs/superpowers/plans/2026-06-26-lenticular-webgl-engine.md
Normal file
@ -0,0 +1,678 @@
|
||||
# 光栅卡 WebGL 引擎实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 用 WebGL 实现光栅卡的条纹级像素交织,替代当前 CSS DOM opacity 叠化方案
|
||||
|
||||
**Architecture:** 新增 `LenticularWebGLEngine`(WebGL 引擎类)和片段着色器(条纹交织 + 圆角暗角),保留 `LenticularEngine` 的现有权重算法并在 `computeRenderState()` 末尾汇出 `engineUniforms`,`useLenticularPreview` 增加 `registerOnTick` 回调实现唯一 rAF 驱动,`LenticularCard.vue` 改为 WebGL / CSS DOM 双路径渲染
|
||||
|
||||
**Tech Stack:** WebGL1 (ES 100), uniapp APP-PLUS, Vue 3 composable
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 仅适配 APP-PLUS,无 H5/小程序降级
|
||||
- 2 层图(背景 + 主体),不支持 3+
|
||||
- 无全息效果、无噪声、无色散、无安全区域
|
||||
- `LenticularEngine` 原有输出 `renderState` + `layerTransforms` 不破坏
|
||||
- 文件组织与 `utils/laser-card/` 对仗,放 `utils/lenticular-card/`
|
||||
- WebGL 不可用时自动降级到现有 CSS DOM 路径
|
||||
- 陀螺仪代码 `useLenticularStudioTilt.js` 不修改
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
### 新增
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `frontend/utils/lenticular-card/lenticular-webgl-shaders.js` | VERT_SRC + FRAG_SRC(~80 行 GLSL) |
|
||||
| `frontend/utils/lenticular-card/lenticular-webgl-engine.js` | `LenticularWebGLEngine` 类(~200 行) |
|
||||
|
||||
### 修改
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `frontend/utils/lenticular-engine.js` → 移入 `utils/lenticular-card/` | 文件迁移 + `computeRenderState()` 末尾汇出 `engineUniforms` |
|
||||
| `frontend/composables/useLenticularPreview.js` | 追加 `engineUniforms` 返回值 + `registerOnTick` |
|
||||
| `frontend/components/lenticular/LenticularCard.vue` | 双路径渲染 + 引擎生命周期 |
|
||||
|
||||
### 不改
|
||||
|
||||
`useLenticularStudioTilt.js`、`HolographicCard.vue`、`HolographicEngine`、全部页面层(`lenticular-create/result/thinking.vue`)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 建立 `utils/lenticular-card/` 目录并迁移 `lenticular-engine.js`
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/utils/lenticular-card/`
|
||||
- Move: `frontend/utils/lenticular-engine.js` → `frontend/utils/lenticular-card/lenticular-engine.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 无(纯文件迁移)
|
||||
- Produces: `lenticular-engine.js` 在 `utils/lenticular-card/` 目录下可被 `import` 引用
|
||||
|
||||
- [ ] **Step 1: 创建目录**
|
||||
|
||||
```bash
|
||||
mkdir -p frontend/utils/lenticular-card
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 复制文件到新位置**
|
||||
|
||||
```bash
|
||||
cp frontend/utils/lenticular-engine.js frontend/utils/lenticular-card/lenticular-engine.js
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 确认所有引用旧路径的地方**
|
||||
|
||||
搜索 `@/utils/lenticular-engine.js` 找到所有引用:
|
||||
|
||||
```bash
|
||||
grep -rn "lenticular-engine" frontend/ --include="*.js" --include="*.vue"
|
||||
```
|
||||
|
||||
预期至少命中:
|
||||
- `frontend/composables/useLenticularPreview.js`
|
||||
- `frontend/components/lenticular/LenticularCard.vue`
|
||||
- 可能的测试文件
|
||||
|
||||
记录所有路径,后续 task 逐个更新。
|
||||
|
||||
- [ ] **Step 4: 删除旧文件**
|
||||
|
||||
```bash
|
||||
rm frontend/utils/lenticular-engine.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 编写 lenticular-webgl-shaders.js
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/utils/lenticular-card/lenticular-webgl-shaders.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 无
|
||||
- Produces: `export const VERT_SRC` 和 `export const FRAG_SRC`,供 `LenticularWebGLEngine` 使用
|
||||
|
||||
- [ ] **Step 1: 实现 VERT_SRC + FRAG_SRC**
|
||||
|
||||
```js
|
||||
/**
|
||||
* 光栅卡 WebGL 着色器
|
||||
* 管线:UV 偏移 → 条纹交织 → 输出修饰
|
||||
* 无全息效果
|
||||
*/
|
||||
|
||||
export const VERT_SRC = `
|
||||
attribute vec2 a_position;
|
||||
attribute vec2 a_texCoord;
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
void main() {
|
||||
v_texCoord = a_texCoord;
|
||||
gl_Position = vec4(a_position * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
export const FRAG_SRC = `
|
||||
precision highp float;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
uniform sampler2D u_textureA;
|
||||
uniform sampler2D u_textureB;
|
||||
uniform float u_parallax;
|
||||
uniform float u_phase;
|
||||
uniform float u_density;
|
||||
uniform float u_cornerRadius;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_dpr;
|
||||
|
||||
// ---- 圆角 SDF ----
|
||||
float roundedRectSDF(vec2 p, vec2 halfSize, float r) {
|
||||
vec2 q = abs(p) - halfSize + r;
|
||||
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
|
||||
}
|
||||
|
||||
void main() {
|
||||
// ① UV 偏移
|
||||
vec2 uv = v_texCoord;
|
||||
uv.x += u_parallax * 0.03;
|
||||
float scale = 1.0 + abs(u_parallax) * 0.015;
|
||||
uv = (uv - 0.5) * scale + 0.5;
|
||||
|
||||
// ② 条纹交织 —— 每个像素只来自一张图
|
||||
float stripe = fract(uv.x * u_density + u_phase);
|
||||
float mask = smoothstep(0.45, 0.55, stripe);
|
||||
vec4 baseColor = texture2D(u_textureA, uv);
|
||||
vec4 frontColor = texture2D(u_textureB, uv);
|
||||
vec4 finalColor = mix(baseColor, frontColor, mask);
|
||||
|
||||
// ③ 输出修饰
|
||||
vec2 halfRes = u_resolution * 0.5;
|
||||
vec2 pn = (uv - 0.5) * u_resolution;
|
||||
float cornerRadPx = u_cornerRadius * u_dpr;
|
||||
float sdf = roundedRectSDF(pn, halfRes - cornerRadPx, cornerRadPx);
|
||||
if (sdf > 1.5) discard;
|
||||
|
||||
// 暗角
|
||||
vec2 pnNorm = pn / max(halfRes.x, halfRes.y);
|
||||
float vignette = 1.0 - pow(clamp(length(pnNorm) * 1.1, 0.0, 1.0), 2.8) * 0.35;
|
||||
finalColor.rgb *= vignette;
|
||||
|
||||
// 边缘抗锯齿
|
||||
float cornerMask = 1.0 - smoothstep(-1.5, 1.5, sdf);
|
||||
float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf);
|
||||
float alpha = cornerMask * edgeAA;
|
||||
finalColor = clamp(finalColor, 0.0, 1.0);
|
||||
gl_FragColor = vec4(finalColor.rgb, alpha);
|
||||
}
|
||||
`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 编写 lenticular-webgl-engine.js
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/utils/lenticular-card/lenticular-webgl-engine.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `VERT_SRC`, `FRAG_SRC` from `./lenticular-webgl-shaders.js`; `loadTextureImage` from `@/utils/laser-card/laserPreviewWebgl.js`
|
||||
- Produces: `class LenticularWebGLEngine` 暴露 `init(textureA, textureB)`, `resize(w, h)`, `setUniforms({parallax, phase, density})`, `draw()`, `uploadTexture(index, image)`, `destroy()`
|
||||
|
||||
- [ ] **Step 1: 实现 LenticularWebGLEngine 类**
|
||||
|
||||
```js
|
||||
/**
|
||||
* 光栅卡 WebGL 渲染引擎
|
||||
* 单 DrawCall,8 uniforms,3 阶段管线
|
||||
*/
|
||||
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
|
||||
import { VERT_SRC, FRAG_SRC } from './lenticular-webgl-shaders.js'
|
||||
|
||||
export class LenticularWebGLEngine {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas
|
||||
this.gl = null
|
||||
this.program = null
|
||||
this.uniformLocs = {}
|
||||
this.textures = [null, null]
|
||||
this._dpr = 1
|
||||
this._uniforms = { parallax: 0, phase: 0, density: 0.16 }
|
||||
this._initialized = false
|
||||
this._destroyed = false
|
||||
}
|
||||
|
||||
init(textureSrcA, textureSrcB) {
|
||||
if (this._initialized || this._destroyed) return false
|
||||
const gl = this.canvas.getContext('webgl', {
|
||||
alpha: true, antialias: true, premultipliedAlpha: false,
|
||||
powerPreference: 'high-performance',
|
||||
})
|
||||
if (!gl) return false
|
||||
this.gl = gl
|
||||
this._dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
this._syncSize()
|
||||
|
||||
// 编译 shader
|
||||
const vs = this._compileShader(gl.VERTEX_SHADER, VERT_SRC)
|
||||
const fs = this._compileShader(gl.FRAGMENT_SHADER, FRAG_SRC)
|
||||
if (!vs || !fs) return false
|
||||
this.program = gl.createProgram()
|
||||
gl.attachShader(this.program, vs)
|
||||
gl.attachShader(this.program, fs)
|
||||
gl.linkProgram(this.program)
|
||||
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) return false
|
||||
|
||||
// 缓存 uniform 位置
|
||||
const names = ['u_textureA', 'u_textureB', 'u_parallax', 'u_phase', 'u_density',
|
||||
'u_cornerRadius', 'u_resolution', 'u_dpr']
|
||||
for (const name of names) {
|
||||
this.uniformLocs[name] = gl.getUniformLocation(this.program, name)
|
||||
}
|
||||
|
||||
// 全屏四边形
|
||||
const verts = new Float32Array([0,0, 1,0, 0,1, 1,0, 0,1, 1,1])
|
||||
const buf = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW)
|
||||
|
||||
const posLoc = gl.getAttribLocation(this.program, 'a_position')
|
||||
const tcLoc = gl.getAttribLocation(this.program, 'a_texCoord')
|
||||
gl.enableVertexAttribArray(posLoc)
|
||||
gl.enableVertexAttribArray(tcLoc)
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0)
|
||||
gl.vertexAttribPointer(tcLoc, 2, gl.FLOAT, false, 16, 8)
|
||||
|
||||
// 纹理
|
||||
this.textures[0] = gl.createTexture()
|
||||
this.textures[1] = gl.createTexture()
|
||||
gl.useProgram(this.program)
|
||||
gl.uniform1i(this.uniformLocs.u_textureA, 0)
|
||||
gl.uniform1i(this.uniformLocs.u_textureB, 1)
|
||||
|
||||
// 加载纹理
|
||||
if (textureSrcA) this._loadTexture(0, textureSrcA)
|
||||
if (textureSrcB) this._loadTexture(1, textureSrcB)
|
||||
|
||||
this._initialized = true
|
||||
return true
|
||||
}
|
||||
|
||||
resize(cssW, cssH) {
|
||||
const w = Math.round(cssW * this._dpr)
|
||||
const h = Math.round(cssH * this._dpr)
|
||||
if (this.canvas.width === w && this.canvas.height === h) return
|
||||
this.canvas.width = w
|
||||
this.canvas.height = h
|
||||
this.gl && this.gl.viewport(0, 0, w, h)
|
||||
}
|
||||
|
||||
_syncSize() {
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
if (rect.width && rect.height) this.resize(rect.width, rect.height)
|
||||
}
|
||||
|
||||
setUniforms(u) {
|
||||
Object.assign(this._uniforms, u)
|
||||
}
|
||||
|
||||
draw() {
|
||||
const gl = this.gl
|
||||
if (!gl || !this._initialized) return
|
||||
|
||||
gl.useProgram(this.program)
|
||||
gl.uniform1f(this.uniformLocs.u_parallax, this._uniforms.parallax)
|
||||
gl.uniform1f(this.uniformLocs.u_phase, this._uniforms.phase)
|
||||
gl.uniform1f(this.uniformLocs.u_density, this._uniforms.density)
|
||||
gl.uniform1f(this.uniformLocs.u_cornerRadius, this._uniforms.cornerRadius || 24)
|
||||
gl.uniform2f(this.uniformLocs.u_resolution, this.canvas.width, this.canvas.height)
|
||||
gl.uniform1f(this.uniformLocs.u_dpr, this._dpr)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6)
|
||||
}
|
||||
|
||||
uploadTexture(index, image) {
|
||||
if (index < 0 || index > 1 || !this.gl) return
|
||||
this._loadTexture(index, image)
|
||||
}
|
||||
|
||||
async _loadTexture(index, src) {
|
||||
const gl = this.gl
|
||||
const img = typeof src === 'string'
|
||||
? await loadTextureImage(src)
|
||||
: src
|
||||
if (!img) return
|
||||
gl.activeTexture(gl.TEXTURE0 + index)
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.textures[index])
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.generateMipmap(gl.TEXTURE_2D)
|
||||
}
|
||||
|
||||
_compileShader(type, src) {
|
||||
const gl = this.gl
|
||||
const s = gl.createShader(type)
|
||||
gl.shaderSource(s, src)
|
||||
gl.compileShader(s)
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
||||
console.error('[LenticularWebGLEngine] shader compile error:', gl.getShaderInfoLog(s))
|
||||
return null
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
destroy() {
|
||||
const gl = this.gl
|
||||
if (gl) {
|
||||
if (this.program) gl.deleteProgram(this.program)
|
||||
for (const t of this.textures) { if (t) gl.deleteTexture(t) }
|
||||
}
|
||||
this._destroyed = true
|
||||
this._initialized = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: lenticular-engine.js 追加 engineUniforms 汇出
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/utils/lenticular-card/lenticular-engine.js` — `computeRenderState()` 末尾
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 已有 `this.renderState`、`this.physics`、`this.displayGamma`
|
||||
- Produces: `computeRenderState()` 返回值新增字段 `engineUniforms: { parallax, phase, density }`
|
||||
|
||||
- [ ] **Step 1: computeRenderState() 末尾追加汇出代码**
|
||||
|
||||
找到 `lenticular-engine.js` 中 `computeRenderState()` 的 `return this.renderState` 行,在其之前插入 engineUniforms 计算。注意 `this.renderState.stripePhaseShift` 已经由原有逻辑写入。
|
||||
|
||||
```js
|
||||
// lenticular-engine.js — computeRenderState() 末尾,在 return 之前追加
|
||||
|
||||
// ===== WebGL engineUniforms 汇出(不破坏原有输出)=====
|
||||
const parallax = this.displayGamma * (this.physics.tiltSensitivity / 100) * (this.physics.parallaxDepth || 0) * 0.42
|
||||
const sensitivity = this.physics.tiltSensitivity / 100
|
||||
const stripePhaseShift = this.renderState.stripePhaseShift != null
|
||||
? this.renderState.stripePhaseShift
|
||||
: this.displayGamma * (0.38 + 0.52 * sensitivity)
|
||||
const phase = (stripePhaseShift + 1) / 2 // -1~1 → 0~1
|
||||
const pitchPx = this.renderState.lenticularPitchPx || 16
|
||||
const density = 50 / pitchPx
|
||||
|
||||
this.renderState.engineUniforms = {
|
||||
parallax: clamp(parallax, -1, 1),
|
||||
phase: clamp(phase, 0, 1),
|
||||
density: clamp(density, 0.08, 0.3),
|
||||
}
|
||||
|
||||
return this.renderState
|
||||
```
|
||||
|
||||
注意:函数顶部已有 `clamp`,无需重复定义。
|
||||
|
||||
---
|
||||
|
||||
## Task 5: useLenticularPreview.js 追加 engineUniforms + registerOnTick
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/composables/useLenticularPreview.js`
|
||||
- Modify: 更新 Task 1 中旧路径引用为 `@/utils/lenticular-card/lenticular-engine`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `LenticularEngine` 的 `computeRenderState()` 返回的 `engineUniforms`
|
||||
- Produces: 新增返回值 `engineUniforms`(ref)、`registerOnTick(fn)` 函数
|
||||
|
||||
- [ ] **Step 1: 更新 import 路径**
|
||||
|
||||
```js
|
||||
// 旧
|
||||
import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-engine.js'
|
||||
// 新
|
||||
import { LenticularEngine, DEFAULT_PHYSICS } from '@/utils/lenticular-card/lenticular-engine.js'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新增 registerOnTick 机制**
|
||||
|
||||
在 `useLenticularPreview` 函数体内,`let rafId = null` 之后新增:
|
||||
|
||||
```js
|
||||
let onTickCallback = null
|
||||
|
||||
export function registerOnTick(fn) {
|
||||
onTickCallback = typeof fn === 'function' ? fn : null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: tick() 中调用 onTickCallback**
|
||||
|
||||
将 `tick()` 函数从:
|
||||
|
||||
```js
|
||||
function tick() {
|
||||
try {
|
||||
const ls = getLayersArray()
|
||||
const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
|
||||
applyLayerTransformsFromRenderState(ls, renderState)
|
||||
} catch (e) {
|
||||
console.error('[useLenticularPreview] tick failed', e)
|
||||
}
|
||||
rafId = nextFrame(tick)
|
||||
}
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```js
|
||||
function tick() {
|
||||
try {
|
||||
const ls = getLayersArray()
|
||||
const renderState = engine.feedSimulatedTilt(sensorData.value.gamma, sensorData.value.beta)
|
||||
applyLayerTransformsFromRenderState(ls, renderState)
|
||||
// WebGL onTick
|
||||
if (onTickCallback && renderState.engineUniforms) {
|
||||
onTickCallback(renderState.engineUniforms)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[useLenticularPreview] tick failed', e)
|
||||
}
|
||||
rafId = nextFrame(tick)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 追加到返回值**
|
||||
|
||||
```js
|
||||
// 现有 return 末尾追加
|
||||
return {
|
||||
// ... 原有返回值
|
||||
registerOnTick,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 重构 LenticularCard.vue — 双路径渲染
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/lenticular/LenticularCard.vue`
|
||||
- Modify: 更新 import 路径为 `@/utils/lenticular-card/lenticular-engine`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `useLenticularPreview` 返回的 `layerTransforms` + `registerOnTick`
|
||||
- Consumes: `LenticularWebGLEngine` from `@/utils/lentricular-card/lenticular-webgl-engine`
|
||||
- Consumes: `loadTextureImage` from `@/utils/laser-card/laserPreviewWebgl.js`
|
||||
|
||||
- [ ] **Step 1: 更新 script setup 头部**
|
||||
|
||||
```js
|
||||
<script setup>
|
||||
import { computed, getCurrentInstance, onMounted, ref, watch, nextTick } from 'vue'
|
||||
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
|
||||
import { LenticularWebGLEngine } from '@/utils/lenticular-card/lenticular-webgl-engine.js'
|
||||
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
|
||||
|
||||
const props = defineProps({
|
||||
layers: { type: Array, required: true },
|
||||
transforms: { type: Object, default: () => ({}) },
|
||||
gyroSource: { type: String, default: 'simulation' },
|
||||
tiltHintText: { type: String, default: '倾斜手机预览' },
|
||||
approximatePreview: { type: Boolean, default: true },
|
||||
skipBuiltInTouch: { type: Boolean, default: false },
|
||||
// WebGL 专属
|
||||
cornerRadius: { type: Number, default: 24 },
|
||||
webglPreferred: { type: Boolean, default: true },
|
||||
shimmerMidOpacity: { type: Number, default: 0.1 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['simulate', 'ready', 'error'])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新增 WebGL 状态 + 引擎生命周期**
|
||||
|
||||
```js
|
||||
const pageProxy = getCurrentInstance()?.proxy
|
||||
const showHint = ref(true)
|
||||
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
|
||||
const cardRect = ref(null)
|
||||
|
||||
// ---- WebGL 状态 ----
|
||||
const webglCanvas = ref(null)
|
||||
const useWebgl = ref(false)
|
||||
let webglEngine = null
|
||||
let resizeObserver = null
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 接入 composable + 注册 onTick**
|
||||
|
||||
```js
|
||||
const layersRef = computed(() => props.layers)
|
||||
const { physics, layerTransforms, stripeRender, gyro, simulate, relax, snapSimulatedTilt,
|
||||
startRenderLoop, stopRenderLoop, registerOnTick } = useLenticularPreview(layersRef)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 初始化 WebGL(onMounted 中)**
|
||||
|
||||
```js
|
||||
async function initWebGL() {
|
||||
if (!props.webglPreferred) return false
|
||||
const canvas = webglCanvas.value
|
||||
if (!canvas) return false
|
||||
|
||||
const engine = new LenticularWebGLEngine(canvas)
|
||||
// 获取两张图的 src
|
||||
const srcA = props.layers[0]?.src || ''
|
||||
const srcB = props.layers[1]?.src || ''
|
||||
const ok = engine.init(srcA, srcB)
|
||||
if (!ok) {
|
||||
engine.destroy()
|
||||
return false
|
||||
}
|
||||
webglEngine = engine
|
||||
|
||||
// 监听尺寸变化
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const { width, height } = e.contentRect
|
||||
if (engine && width > 0 && height > 0) engine.resize(width, height)
|
||||
}
|
||||
})
|
||||
ro.observe(canvas.parentElement || canvas)
|
||||
}
|
||||
|
||||
// 注册 onTick
|
||||
registerOnTick((uniforms) => {
|
||||
if (webglEngine) {
|
||||
webglEngine.setUniforms({
|
||||
...uniforms,
|
||||
cornerRadius: props.cornerRadius,
|
||||
})
|
||||
webglEngine.draw()
|
||||
}
|
||||
})
|
||||
|
||||
useWebgl.value = true
|
||||
emit('ready')
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: onMounted 决策 WebGL / CSS**
|
||||
|
||||
```js
|
||||
onMounted(async () => {
|
||||
const webglOk = await initWebGL()
|
||||
if (!webglOk) {
|
||||
// 降级到 CSS DOM
|
||||
setTimeout(() => { void refreshRect() }, 0)
|
||||
emit('error', new Error('WebGL init failed, fallback to CSS'))
|
||||
}
|
||||
startRenderLoop()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRenderLoop()
|
||||
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null }
|
||||
if (webglEngine) {
|
||||
webglEngine.destroy()
|
||||
webglEngine = null
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 更新 template 为双路径**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view class="card-container">
|
||||
<!-- WebGL 路径 -->
|
||||
<canvas
|
||||
v-if="useWebgl"
|
||||
ref="webglCanvas"
|
||||
class="card-canvas"
|
||||
:style="{ width: '100%', height: '100%' }"
|
||||
@touchstart.stop="onFrameTouchStart"
|
||||
@touchmove.stop.prevent="onFrameTouchMove"
|
||||
@touchend.stop="onFrameTouchEnd"
|
||||
@touchcancel.stop="onFrameTouchEnd"
|
||||
/>
|
||||
<!-- CSS 降级路径 -->
|
||||
<template v-else>
|
||||
<view
|
||||
:id="cardId"
|
||||
class="card-frame"
|
||||
@touchstart.stop="onFrameTouchStart"
|
||||
@touchmove.stop.prevent="onFrameTouchMove"
|
||||
@touchend.stop="onFrameTouchEnd"
|
||||
@touchcancel.stop="onFrameTouchEnd"
|
||||
>
|
||||
<view class="card-body" :style="cardRotateStyle">
|
||||
<view
|
||||
v-for="layer in layers"
|
||||
:key="layer.id"
|
||||
class="card-layer"
|
||||
:style="getLayerStyle(layer)"
|
||||
>
|
||||
<image
|
||||
v-if="layer.src"
|
||||
class="card-layer-img"
|
||||
:src="layer.src"
|
||||
mode="aspectFill"
|
||||
@error="onImageError"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
</view>
|
||||
<view class="lenticular-shimmer" :style="shimmerStyle" />
|
||||
<view class="lenticular-tint" />
|
||||
<view class="glass-rim" />
|
||||
<view class="vignette" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 添加 CSS**
|
||||
|
||||
```css
|
||||
<style scoped>
|
||||
.card-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 保留原有 CSS DOM 路径的全部样式 */
|
||||
.card-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; perspective: 1000px; position: relative; }
|
||||
.card-frame { position: relative; height: 100%; width: 100%; overflow: visible; }
|
||||
.card-body { position: absolute; inset: 0; transform-style: preserve-3d; box-shadow: 0 20px 50px rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.18); background-color: #060e20; will-change: transform; }
|
||||
.card-layer { position: absolute; width: 100%; height: 100%; will-change: transform, opacity; background-size: cover; background-position: center; }
|
||||
.card-layer-img { width: 100%; height: 100%; }
|
||||
.lenticular-shimmer { position: absolute; inset: 0; pointer-events: none; opacity: 0.85; }
|
||||
.lenticular-tint { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(135deg, rgba(221,183,255,0.1) 0%, transparent 35%, transparent 65%, rgba(76,215,246,0.1) 100%); opacity: 0.6; }
|
||||
.glass-rim { position: absolute; inset: 0; border-radius: 24px; border-top: 1px solid rgba(221,183,255,0.42); box-shadow: inset 0 0 40px rgba(255,255,255,0.05); pointer-events: none; }
|
||||
.vignette { position: absolute; inset: 0; background: linear-gradient(to top, rgba(6,14,32,0.82), transparent 50%); pointer-events: none; }
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
| # | 验证项 | 方法 |
|
||||
|---|---|---|
|
||||
| 1 | WebGL 条纹交织 | 创建页打开,两张图倾斜时条纹交替,无叠化模糊 |
|
||||
| 2 | CSS DOM 降级 | 设置 `webglPreferred=false`,页面与改动前一致 |
|
||||
| 3 | 陀螺仪驱动 | 结果页用真机倾斜,条纹随角度平滑移动,无抖动 |
|
||||
| 4 | 生命周期 | 页面进出各 3 次,无 WebGL context lost 报错 |
|
||||
| 5 | Android 低端机 | 测试机输出 `fps > 50`,倾斜响应 < 50ms |
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,472 @@
|
||||
# 光栅卡 WebGL 引擎整合设计
|
||||
|
||||
> **创建日期:** 2026-06-26
|
||||
> **项目:** TopFans 星卡 · 光栅卡渲染升级
|
||||
> **状态:** 设计审核中
|
||||
> **版本:** v1.0
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与目标
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
光栅卡目前采用**CSS DOM 渲染路径**:`LenticularEngine`(纯 JS)计算每层权重/偏移,`LenticularCard.vue` 通过 `v-for` 渲染 N 个 `<view>` + `<image>` 标签,用 `translate3d` + `opacity` 模拟柱镜光栅的视角切换效果。
|
||||
|
||||
镭射卡已有独立的 `HolographicCard.vue` + `HolographicEngine`(WebGL),但**该组件是做单图全息光效的,不解决多图条纹交织问题**。
|
||||
|
||||
### 1.2 问题
|
||||
|
||||
| 问题 | 说明 |
|
||||
|---|---|
|
||||
| 无真正的条纹级像素交织 | 当前是叠化(opacity crossfade),不是物理柱镜的"逐像素切换" |
|
||||
| N>2 时性能线性衰减 | 每多一层就多一个 `<image>` 合成层 |
|
||||
| 两套引擎职责混淆 | HolographicEngine 被误认为"光栅的 WebGL 方案",实际它只处理单图全息 |
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
- 用 WebGL 实现真正的条纹级像素交织
|
||||
- 保留现有 `LenticularEngine` 的成熟权重算法和陀螺仪稳定逻辑
|
||||
- 不破坏现有 CSS DOM 降级路径
|
||||
- 代码组织与镭射卡 `utils/laser-card/` 对仗
|
||||
|
||||
---
|
||||
|
||||
## 二、视觉目标(Before / After)
|
||||
|
||||
### 现在:CSS opacity 叠化
|
||||
|
||||
倾斜手机时,用户看到的是两张图以不同透明度叠加:
|
||||
|
||||
```
|
||||
倾斜 0° 倾斜 -15° 倾斜 +15°
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ A 图 100% │ │ A 图 60% │ │ A 图 0% │
|
||||
│ B 图 0% │ → │ B 图 40% │ → │ B 图 100% │
|
||||
│ │ │ (半透叠化) │ │ │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
效果:A 渐隐、B 渐显,全程两张图同时存在,类似"交叉淡入淡出"
|
||||
问题:没有"一张图上、一张图下"的切换感
|
||||
```
|
||||
|
||||
### 改后:WebGL 条纹级像素交织
|
||||
|
||||
每个像素只来自一张图,相邻像素交替采样 A/B:
|
||||
|
||||
```
|
||||
倾斜 0° 倾斜 -15° 倾斜 +15°
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ A│B│A│B│A│B │ │ A│A│B│B│A│A │ │ B│A│B│A│B│A │
|
||||
│ A│B│A│B│A│B │ → │ A│A│B│B│A│A │ → │ B│A│B│A│B│A │
|
||||
│ A│B│A│B│A│B │ │ A│A│B│B│A│A │ │ B│A│B│A│B│A │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
每个格子 = 1 个像素
|
||||
效果:倾斜时 A 图区域缩小、B 图区域扩大,条纹边界非此即彼
|
||||
视觉:类似真实物理柱镜片的"啪一下亮出来"
|
||||
```
|
||||
|
||||
### 一句话总结
|
||||
|
||||
> **从"两张图互相半透叠化"变为"逐像素条纹交替",视觉上从"数字感"变为"物理柱镜卡片的质感"。**
|
||||
|
||||
---
|
||||
|
||||
## 三、关键决策
|
||||
|
||||
| 决策 | 选项 | 结论 |
|
||||
|---|---|---|
|
||||
| 图层数 | A) 2 层 / B) 3 层 / C) 动态 | **A) 2 层**(背景 + 主体) |
|
||||
| WebGL 降级 | A) CSS fallback / B) 静态图 / C) 不做 | **A) CSS DOM fallback** |
|
||||
| 技术路线 | A) 新引擎 / B) 扩展现有 / C) PixiJS | **A) 新 LenticularWebGLEngine** |
|
||||
| Composable 职责 | 仅数据逻辑,不碰 WebGL 生命周期 | 与组件现有模式一致 |
|
||||
| 目录层级 | 与镭射卡 `utils/laser-card/` 对仗 | `utils/lenticular-card/` |
|
||||
|
||||
---
|
||||
|
||||
## 四、整体架构
|
||||
|
||||
```
|
||||
┌───────────────────────┐
|
||||
│ useLenticularStudio- │ ← 不改
|
||||
│ Tilt.js (陀螺仪采集) │
|
||||
└──────────┬────────────┘
|
||||
│ gamma/beta
|
||||
┌──────────▼────────────┐
|
||||
│ LenticularEngine │ ← 追加 engineUniforms 汇出
|
||||
│ (视角→权重/偏移动画) │ 不破坏原输出
|
||||
└──────────┬────────────┘
|
||||
│ parallax, phase, density
|
||||
┌──────────▼────────────┐
|
||||
│ useLenticularPreview │ ← 追加 engineUniforms 返回值
|
||||
│ (composable) │
|
||||
└──────────┬────────────┘
|
||||
│
|
||||
┌────────────────────┴────────────────────┐
|
||||
│ WebGL 可用 │ CSS 不可用
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ LenticularWebGLEngine│ │ (现有 CSS DOM 路径) │
|
||||
│ (新增,组件内部管理) │ │ layerTransforms → │
|
||||
│ 8 个 uniforms → │ │ translate3d+opacity │
|
||||
│ 1 draw call │ │ │
|
||||
└──────────┬───────────┘ └──────────────────────┘
|
||||
│
|
||||
▼
|
||||
Fragment Shader 管线:
|
||||
① UV parallax 偏移
|
||||
② stripe 条纹交织 (mix textureA/B)
|
||||
③ 输出修饰 (圆角/暗角/edgeAA)
|
||||
```
|
||||
|
||||
### 4.1 模块职责
|
||||
|
||||
| 模块 | 职责 | 改否 |
|
||||
|---|---|---|
|
||||
| `useLenticularStudioTilt.js` | 陀螺仪采集 + 跳变拒绝 + EMA 平滑 | 不改 |
|
||||
| `LenticularEngine` | 视角→权重映射,stripShares,EMA 平滑 | 追加 `engineUniforms` 汇出,不破坏原输出 |
|
||||
|
||||
`engineUniforms` 格式:
|
||||
```js
|
||||
{
|
||||
parallax: float, // -1~1,视差偏移量
|
||||
phase: float, // 0~1,条纹相位(由 displayGamma 映射)
|
||||
density: float, // ≈0.08~0.3,条纹密度(由 lenticularPitchPx 算出)
|
||||
}
|
||||
```
|
||||
|
||||
| `useLenticularPreview` | 管理 rAF 循环,暴露 layerTransforms + engineUniforms | 追加 engineUniforms 返回值 |
|
||||
| `LenticularWebGLEngine` | **新增**。WebGL init/compile/resize/rAF/destroy | — |
|
||||
| `LenticularCard.vue` | 渲染入口,自选 WebGL/CSS 路径 | 重写 template,保留 fallback |
|
||||
|
||||
---
|
||||
|
||||
## 五、完整调用链路(从陀螺仪到屏幕像素)
|
||||
|
||||
以下是一个倾斜动作从上到下经过的全部环节:
|
||||
|
||||
```
|
||||
用户手指在卡面上拖动 / 手机倾斜
|
||||
│
|
||||
▼
|
||||
[硬件层]
|
||||
deviceorientation 事件 (陀螺仪) 或 touchstart/touchmove (拖动)
|
||||
│ 原始角度/坐标
|
||||
▼
|
||||
[useLenticularStudioTilt.js] ← 不改
|
||||
跳变拒绝:abs(raw - prev) > 15° → clamp(prev ± 15°)
|
||||
EMA 平滑 (快通道 α=0.25, 慢通道 α=0.08)
|
||||
delta 死区:abs < 0.5° → 0
|
||||
│ 输出: { gamma: -1~1, beta: 0 }
|
||||
▼
|
||||
[LenticularEngine.updateDisplayStable()]
|
||||
sensorDeadzoneStrength → softAttenuateNearZero
|
||||
│ 输出: displayGamma: -1~1
|
||||
▼
|
||||
[LenticularEngine.computeRenderState()]
|
||||
入参: displayGamma, physics (sensitivity, smoothness, depth, pitch 等)
|
||||
│
|
||||
├─ 计算 stripShares(N=2 时各 0.5;N=3 时分 background/foreground/mid)
|
||||
├─ 计算 rawWeights(视角位置 u 落在各层覆盖区的距离)
|
||||
├─ smoothstep 平滑 → smoothedW
|
||||
├─ 找到 dominant 层
|
||||
├─ prevLayerGhost + nonDominantResidual 保底
|
||||
├─ 按 parallaxFactor 计算各层 offsetX(CSS 降级用)
|
||||
│
|
||||
└─ 汇出 engineUniforms(新增部分,不破坏以上原有输出):
|
||||
{
|
||||
parallax: displayGamma × sensitivity × depth × 0.42, // -1~1
|
||||
phase: (displayGamma × 0.38 + 0.5) % 1, // 0~1
|
||||
density: 50 / lenticularPitchPx, // ≈3~6
|
||||
}
|
||||
▼
|
||||
[useLenticularPreview.tick()] ← rAF 循环(唯一驱动源)
|
||||
│ 每帧 16ms 执行一次
|
||||
│
|
||||
├─ [CSS 降级路径]: applyLayerTransformsFromRenderState()
|
||||
│ → 更新 layerTransforms.value → Vue 响应式 → DOM re-render
|
||||
│
|
||||
└─ [WebGL 路径]: 调用 registerOnTick 回调
|
||||
│
|
||||
▼
|
||||
[LenticularWebGLEngine.setUniforms(uniforms)]
|
||||
│ 设置 8 个 uniform:
|
||||
│ u_parallax, u_phase, u_density ← 来自 engineUniforms
|
||||
│ u_textureA, u_textureB ← 纹理绑定
|
||||
│ u_cornerRadius, u_resolution, u_dpr ← 来自组件 props + canvas
|
||||
▼
|
||||
[LenticularWebGLEngine.draw()]
|
||||
│
|
||||
├─ gl.useProgram(program) ← 编译好的 shader
|
||||
├─ gl.activeTexture(GL.TEXTURE0) ← 绑定纹理 A
|
||||
├─ gl.activeTexture(GL.TEXTURE1) ← 绑定纹理 B
|
||||
├─ gl.uniform*(loc, value) × 8 ← 写入 uniform(含 vec2)
|
||||
├─ gl.bindBuffer + gl.vertexAttribPointer
|
||||
└─ gl.drawArrays(GL_TRIANGLES, 0, 6) ← 1 个 draw call
|
||||
│
|
||||
▼
|
||||
[GPU Fragment Shader 并行执行]
|
||||
管线 3 阶段(见 §6.2):
|
||||
① UV parallax 偏移
|
||||
② stripe 条纹交织 → mix(textureA, textureB, mask)
|
||||
③ 输出修饰 (圆角/暗角/edgeAA)
|
||||
│
|
||||
▼
|
||||
[屏幕像素]
|
||||
Canvas 上的最终像素显示给用户
|
||||
每帧 ~200 万像素(以 375×500 CSS 尺寸 × DPR=2 计)
|
||||
```
|
||||
|
||||
### 关键时序
|
||||
|
||||
| 环节 | 耗时估计 | 说明 |
|
||||
|---|---|---|
|
||||
| 陀螺仪采集 + 平滑 | < 0.5ms | JS 纯算术 |
|
||||
| computeRenderState() + engineUniforms 汇出 | < 0.3ms | 10 层循环 + 浮点运算 |
|
||||
| setUniforms + draw | < 1ms | WebGL 调用开销 |
|
||||
| Fragment Shader 执行 | < 2ms | GPU 并行,200 万像素 |
|
||||
| 总计单帧 | < 4ms | 远低于 16ms 帧预算 |
|
||||
|
||||
---
|
||||
|
||||
## 六、Fragment Shader 管线
|
||||
|
||||
### 6.1 Uniform 清单(精简)
|
||||
|
||||
| uniform | 类型 | 来源 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `u_textureA` | sampler2D | layer[0] 底图 | 背景纹理 |
|
||||
| `u_textureB` | sampler2D | layer[1] 前景 | 人物纹理 |
|
||||
| `u_parallax` | float | LenticularEngine | 视差偏移 -1~1 |
|
||||
| `u_phase` | float | LenticularEngine | 条纹相位 0~1 |
|
||||
| `u_density` | float | LenticularEngine | 条纹密度 ≈0.08~0.3 |
|
||||
| `u_cornerRadius` | float | props | 圆角半径 |
|
||||
| `u_resolution` | vec2 | canvas size | 分辨率 |
|
||||
| `u_dpr` | float | devicePixelRatio | 像素比 |
|
||||
|
||||
共 **8 个 uniform**,无全息/时间相关参数。
|
||||
|
||||
### 6.2 管线(3 阶段)
|
||||
|
||||
#### ① UV 变换
|
||||
|
||||
```glsl
|
||||
vec2 uv = v_texCoord;
|
||||
uv.x += u_parallax * 0.03;
|
||||
float scale = 1.0 + abs(u_parallax) * 0.015;
|
||||
uv = (uv - 0.5) * scale + 0.5;
|
||||
```
|
||||
|
||||
#### ② 条纹交织(核心,仅此一个像素级效果)
|
||||
|
||||
```glsl
|
||||
float stripe = fract(uv.x * u_density + u_phase);
|
||||
float mask = smoothstep(0.45, 0.55, stripe);
|
||||
vec4 baseColor = mix(texture2D(u_textureA, uv),
|
||||
texture2D(u_textureB, uv),
|
||||
mask);
|
||||
```
|
||||
|
||||
**每个像素只来自一张图**,相邻像素交替采样 A/B。`u_density` 控制条纹密度(等效每毫米条纹数),`u_phase` 控制视角偏移时条纹组的左右滑动。这是整个引擎**唯一**的像素级视觉效果。
|
||||
|
||||
#### ③ 输出修饰
|
||||
|
||||
```glsl
|
||||
// 圆角裁剪
|
||||
float sdf = roundedRectSDF(pn, halfRes - cornerRadius, cornerRadius);
|
||||
if (sdf > 1.5) discard;
|
||||
|
||||
// 暗角
|
||||
baseColor *= 1.0 - pow(clamp(length(pnNorm) * 1.1, 0, 1), 2.8) * 0.35;
|
||||
|
||||
// 边缘抗锯齿
|
||||
float edgeAA = 1.0 - smoothstep(-1.5, 1.5, sdf);
|
||||
gl_FragColor = vec4(baseColor, cornerMask * edgeAA);
|
||||
```
|
||||
|
||||
无全息效果、无噪声、无色散、无高光、无划痕、无珠光。
|
||||
|
||||
---
|
||||
|
||||
## 七、组件 API
|
||||
|
||||
### 7.1 LenticularCard.vue
|
||||
|
||||
```vue
|
||||
<LenticularCard
|
||||
:layers="layers" // [{id, src, parallaxFactor, opacity}, ...]
|
||||
:transforms.sync="..." // 保留,CSS 降级时需要
|
||||
gyro-source="simulation" // "simulation" | "gyro"
|
||||
tilt-hint-text="倾斜手机预览"
|
||||
corner-radius="24" // 圆角
|
||||
webgl-preferred // Boolean,默认 true
|
||||
@simulate @ready @error
|
||||
/>
|
||||
```
|
||||
|
||||
使用方不感知渲染路径差异。
|
||||
|
||||
**WebGL 可用性判断**(在 `onMounted` 中按序执行):
|
||||
1. `webglPreferred` 为 `false` → 直接使用 CSS DOM 路径,不尝试 WebGL
|
||||
2. `webglPreferred` 为 `true` → 尝试创建 WebGL1 上下文
|
||||
3. 创建成功 → 使用 WebGL 路径,发射 `@ready` 事件
|
||||
4. 创建失败 → 自动降级到 CSS DOM 路径,发射 `@error` 事件(组件内不抛白屏)
|
||||
|
||||
**WebGL 初始化失败后重试**:不重试。一次失败代表该设备不支持 WebGL,重试无意义。
|
||||
|
||||
### 组件内部结构
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view class="lenticular-container">
|
||||
<canvas v-if="useWebgl" ref="webglCanvas" ... />
|
||||
<view v-else class="lenticular-fallback">
|
||||
<!-- 保留现有 CSS DOM 渲染代码 -->
|
||||
<view v-for="layer in layers" :key="layer.id" class="fallback-layer"
|
||||
:style="getLayerStyle(layer)">
|
||||
<image :src="layer.src" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="fallback-shimmer" />
|
||||
<view class="fallback-rim" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、文件结构
|
||||
|
||||
### 新增
|
||||
|
||||
| # | 文件 | 职责 |
|
||||
|---|---|---|
|
||||
| 1 | `frontend/utils/lenticular-card/lenticular-webgl-shaders.js` | VERT_SRC + FRAG_SRC(约 80 行,无全息效果) |
|
||||
| 2 | `frontend/utils/lenticular-card/lenticular-webgl-engine.js` | `LenticularWebGLEngine` 类(8 uniforms,3 阶段管线) |
|
||||
|
||||
### 修改
|
||||
|
||||
| # | 文件 | 改动 |
|
||||
|---|---|---|
|
||||
| 3 | `frontend/utils/lenticular-engine.js` → 移入 `utils/lenticular-card/` | 文件迁移 + `computeRenderState()` 末尾汇出 `engineUniforms` |
|
||||
| 4 | `frontend/composables/useLenticularPreview.js` | 追加 `engineUniforms` 到返回值 |
|
||||
| 5 | `frontend/components/lenticular/LenticularCard.vue` | 双路径渲染 + 引擎生命周期 |
|
||||
|
||||
### 不改
|
||||
|
||||
- `useLenticularStudioTilt.js`
|
||||
- `HolographicCard.vue` / `HolographicEngine` / `holographic-shaders.js`
|
||||
- 所有页面层(`lenticular-create.vue` / `lenticular-result.vue` / `lenticular-thinking.vue`)
|
||||
- 所有镭射卡文件
|
||||
|
||||
### 目录对仗
|
||||
|
||||
```
|
||||
utils/laser-card/ utils/lenticular-card/
|
||||
├── laserGrating.js ├── lenticular-engine.js ← 迁入
|
||||
├── laserPreviewWebgl.js ├── lenticular-webgl-engine.js ← 新增
|
||||
├── laserBatchExport.js ├── lenticular-webgl-shaders.js ← 新增
|
||||
├── laserPresets.js └── ...(后续光栅工具)
|
||||
├── gacha.js
|
||||
├── stylePool.js
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、实施步骤
|
||||
|
||||
### Phase 1 — 核心引擎
|
||||
|
||||
1. 建 `frontend/utils/lenticular-card/` 目录,将 `lenticular-engine.js` 迁入
|
||||
2. 编写 `lenticular-webgl-shaders.js`(VERT_SRC + FRAG_SRC,仅条纹交织 + 圆角暗角)
|
||||
3. 编写 `lenticular-webgl-engine.js`(`LenticularWebGLEngine` 类,8 uniform 管理)
|
||||
4. `lenticular-engine.js` 追加 `engineUniforms` 汇出
|
||||
5. `useLenticularPreview.js` 追加返回值
|
||||
|
||||
### Phase 2 — 组件集成
|
||||
|
||||
6. 重构 `LenticularCard.vue`:双路径 template + WebGL 引擎生命周期
|
||||
7. 联调:验证陀螺仪 → engineUniforms → uniform → stripe shader 响应
|
||||
|
||||
### Phase 3 — 回归
|
||||
|
||||
8. 验证 CSS DOM 降级(`webglPreferred=false`)
|
||||
9. 验证 2 图层各场景(创建页拖动、结果页陀螺仪)
|
||||
10. 低端安卓真机测试
|
||||
|
||||
---
|
||||
|
||||
## 十、DPR 与纹理管理策略
|
||||
|
||||
### 10.1 DPR 管理
|
||||
|
||||
- Canvas 物理分辨率 = CSS 尺寸 × `min(devicePixelRatio, 2)`
|
||||
- Fragment shader 中 `u_dpr` 用于圆角 SDF 计算
|
||||
- resize 时重建 viewport
|
||||
|
||||
### 10.2 纹理更新
|
||||
|
||||
当 `layers[].src` 变化时:
|
||||
1. 加载新图片 → `engine.uploadTexture(index, image)` 更新对应纹理单元
|
||||
2. 纹理使用 `gl.LINEAR_MIPMAP_LINEAR` 采样,上传后生成 mipmap
|
||||
3. 初始化或任一纹理未就绪时,已就绪的纹理正常显示,未就绪的通道采样纯黑色
|
||||
4. **shader 无需重新编译**——uniform 和纹理单元绑定不变,只换纹理数据
|
||||
|
||||
**纹理内存峰值**:2 张纹理 × `(CSS宽×DPR) × (CSS高×DPR) × 4 bytes`。若卡片 CSS 尺寸为 375×500、DPR=2,则单纹理约 1.5MB,合计约 3MB。在移动端可控。
|
||||
|
||||
### 10.3 生命周期
|
||||
|
||||
```
|
||||
组件 onMounted
|
||||
1. new LenticularWebGLEngine(canvas)
|
||||
2. engine.init(textureA, textureB) // layer[0] + layer[1]
|
||||
3. 注册 onTick 回调:engine.setUniforms() + engine.draw()
|
||||
4. useLenticularPreview.startRenderLoop() // 启动唯一 rAF
|
||||
|
||||
组件 watch layers[].src
|
||||
→ 加载新图片 → engine.uploadTexture(index, newImage)
|
||||
|
||||
组件 onUnmounted
|
||||
→ useLenticularPreview.stopRenderLoop()
|
||||
→ engine.destroy()
|
||||
```
|
||||
|
||||
### 10.4 rAF 循环 — 单一驱动
|
||||
|
||||
**不能有两套独立的 rAF**(一个来自 composable,一个来自 engine),否则帧同步紊乱。
|
||||
|
||||
改为 composable 的 rAF 作为唯一驱动源:
|
||||
|
||||
```
|
||||
composable.tick() → feedSimulatedTilt → computeRenderState()
|
||||
→ applyLayerTransformsFromRenderState() // CSS 降级用
|
||||
→ callback(engineUniforms) // WebGL 用
|
||||
└── component 注册的 onTick
|
||||
└── engine.setUniforms(uniforms)
|
||||
└── engine.draw()
|
||||
→ requestAnimationFrame(tick)
|
||||
```
|
||||
|
||||
实现方式:`useLenticularPreview` 新增一个 `onTick` 回调注册机制,组件在 `onMounted` 时传入:
|
||||
|
||||
```js
|
||||
// LenticularCard.vue
|
||||
const { ..., registerOnTick } = useLenticularPreview(layersRef)
|
||||
registerOnTick((uniforms) => {
|
||||
if (webglEngine) {
|
||||
webglEngine.setUniforms(uniforms)
|
||||
webglEngine.draw()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
这样整个渲染管线**单帧内同步**完成,没有多 rAF 竞态。
|
||||
|
||||
---
|
||||
|
||||
## 十一、不影响的范围
|
||||
|
||||
- HolographicCard / HolographicEngine 保持独立,不参与本设计
|
||||
- 页面层(lenticular-create/lenticular-result/lenticular-thinking)无需修改
|
||||
- 镭射卡所有文件不受影响
|
||||
- 后端无改动
|
||||
Loading…
Reference in New Issue
Block a user