diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..0459a2f --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,226 @@ +# XSH Assistant 编码指南 + +## 项目概览 + +**xsh-assistant** 是一个基于 Nuxt 3 的 AI 驱动内容生成平台,专注于数字内容创作(微课视频、虚拟讲师、绿幕合成等)。核心特性: + +- **前端框架**: Nuxt 3 + Vue 3 + TypeScript + Tailwind CSS + Radix Vue UI +- **状态管理**: Pinia(带持久化) +- **媒体处理**: FFmpeg WASM(客户端视频处理)、WebAV(视频剪辑) +- **API 集成**: 统一的 `useFetchWrapped` 包装器,所有请求都通过 `API_BASE` 代理(`https://service1.fenshenzhike.com/`) +- **部署模式**: SPA(SSR=false) + +## 核心架构模式 + +### 1. Composables 设计(Pinia + 自定义 Composables) + +**状态管理采用分层设计**: + +``` +Pinia Stores(持久化状态) +├── useLoginState: 用户认证、token、个人资料 +├── useHistory: AIGC 会话、聊天历史 +└── useTourState: 新手引导状态 + +业务 Composables(无状态或短生命周期) +├── useFetchWrapped: API 请求包装(自动添加 token、user_id) +├── useLLM: LLM API 调用(Spark 模型集成) +├── useFFmpeg: FFmpeg WASM 单例管理 +├── useVideoBackgroundCompositing: 视频合成(数字人+背景) +├── useVideoSubtitleEmbedding: 字幕嵌入 +└── useDownload: 文件下载管理 +``` + +**关键模式**: +- **Pinia stores 必须是单一实例**,通过 `storeToRefs()` 获取响应式引用 +- **API 请求必须通过 `useFetchWrapped`** 来自动处理认证头(token/user_id) +- **FFmpeg 采用单例模式**(`useFFmpeg()` 返回全局加载的实例),避免重复初始化 + +### 2. API 请求模式 + +所有 API 请求使用统一的 `useFetchWrapped` 包装器: + +```typescript +// 基础签名 +useFetchWrapped(action: string, payload?: RequestType, options?: FetchOptions) + +// 请求格式示例(来自 useHistory) +useFetchWrapped>( + 'App.User_User.CheckSession', // action 作为查询参数 ?s= + { token: loginState.token, user_id: loginState.user.id, ...payload }, + { method: 'POST' } // 默认 POST +) +``` + +**约定**: +- **每个请求必须包含** `token` 和 `user_id`(来自 `useLoginState`) +- **响应结构统一**: `BaseResponse` 包含 `ret: number` 状态码和 `data: T` 数据 +- **API_BASE 在 `nuxt.config.ts` 中定义**,所有请求都相对于此 URL + +### 3. 媒体处理架构 + +#### FFmpeg 初始化流程 +- **单例加载**: 首次调用 `useFFmpeg()` 时初始化,后续复用缓存的实例 +- **WASM 资源加载**: 从 CDN(`cdn.jsdelivr.net`)加载 FFmpeg core、wasm、worker +- **错误恢复**: 调用 `cleanupFFmpeg()` 清理资源并重置单例 + +#### 视频合成流程(核心用例) +``` +输入: 透明通道视频 (WebM) + 背景图 (PNG/File) + ↓ +1. 获取背景图尺寸 +2. 计算等比缩放到 720P +3. 加载文件到 FFmpeg vFS +4. 执行 FFmpeg 滤镜链: + - 背景: scale → ${outputWidth}x${outputHeight} + - 视频: scale → ${outputWidth}x${outputHeight} (保留 alpha) + - overlay: 视频叠加到背景(format=auto) +5. 使用 VP9 编码(支持 alpha)+ Opus 音频编码 +6. 返回 Blob → 可直接上传或本地预览 +``` + +### 4. UI 组件架构 + +#### 内置组件库(`components/uni/`) +自定义包装组件,提供统一 API: +- `UniButton`: 按钮 + loading 状态 +- `UniInput`/`UniTextArea`: 表单输入 +- `UniSelect`: 下拉选择 +- `UniMessage`: 全局消息通知(通过 provide/inject) +- `UniCopyable`: 可复制文本 + +**消息通知用法**: +```typescript +const toast = useToast() // Radix Vue 的 Toast(顶部通知) +// 或从 provide 注入 +const messageApi = inject('uni-message') +messageApi.success('操作成功') +messageApi.error('操作失败', 5000) +``` + +#### Radix Vue + Nuxt UI 集成 +- 使用 Radix Vue for 基础组件(button, dialog, select) +- Nuxt UI 用于高级组件 + 主题管理 +- **颜色方案**: primary='indigo', gray='neutral',详见 `app.config.ts` + +### 5. 路由与页面结构 + +**目录映射**: +``` +pages/ +├── generation.vue (导航枢纽) +└── aigc/ + ├── chat/index.vue (聊天页,支持多 LLM 模型) + ├── draw/index.vue (绘图生成) + └── generation/ + ├── course.vue (微课生成) + ├── green-screen.vue (绿幕视频) + ├── avatar-models.vue (数字讲师) + ├── materials.vue (片头片尾) + ├── ppt-templates.vue (PPT 库) + └── admin/ (管理功能) +``` + +**导航约定**: +- `/generation` → 功能导航页面 +- `/aigc/chat` → 聊天/文本生成 +- `/generation/course` → 视频生成工作流 +- 所有生成功能都需要登录(ModalAuthentication 处理) + +## 开发工作流 + +### 启动项目 +```bash +ni # 安装依赖 +nr dev # 启动 http://localhost:3000 +nr generate # 生产构建 (生成静态文件) +``` + +### 常见任务 + +**添加新的 API 端点**: +1. 定义 Request 和 Response 类型(参考 `typings/llm.ts`) +2. 在 composable 中使用 `useFetchWrapped` 调用 +3. 自动包含 token/user_id(来自 `useLoginState`) + +**添加新的视频处理功能**: +1. 使用 `useFFmpeg()` 获取实例(自动初始化) +2. 写入文件到 vFS: `ffmpeg.writeFile()` +3. 执行命令:`ffmpeg.exec([...filterArgs])` +4. 清理临时文件:`ffmpeg.deleteFile()` +5. 使用 progress callback 通报处理进度 + +**添加新的 UI 组件**: +1. 创建在 `components/` 下(自动注册) +2. 优先使用 Radix Vue + Nuxt UI(已集成) +3. 使用 Tailwind CSS utility classes + `@apply` 指令 +4. 通过 `app.config.ts` 自定义 UI 主题 + +## 项目特定的约定 + +### 类型定义位置 +- **LLM 相关**: `typings/llm.ts`(ChatMessage, ChatSession, ModelTag, LLMModal) +- **全局类型**: `typings/types.d.ts`(BaseResponse, AuthedRequest, UserSchema) +- **组件接口**: 组件目录下的 `index.d.ts`(例 `components/aigc/drawing/index.d.ts`) + +### 命名规范 +- **Composables**: `use` 前缀(`useLoginState`, `useLLM`) +- **Stores**: `use` + 功能名(`useHistory`, `useTourState`) +- **组件**: PascalCase(`ChatItem.vue`, `ModalAuthentication.vue`) +- **工具函数**: camelCase,放在 `composables/` 或各功能目录 + +### 响应式数据模式 +- **Pinia store 返回值**: 必须通过 `storeToRefs()` 才能保持响应式 +- **模板中的 ref**: 直接访问(Vue 自动展开) +- **跨组件数据**: 优先使用 Pinia store(带持久化) + +### 进度反馈与错误处理 +- **长时间操作** (视频处理): 通过 callback 函数报告 progress(0-100) +- **错误处理**: 返回 Promise reject,上层 catch 处理;可选通过 toast/message 提示 +- **FFmpeg 错误**: 捕获 exitCode 非零,记录详细的 FFmpeg 输出 + +## 依赖与性能优化 + +### 关键依赖 +- **@ffmpeg/ffmpeg@0.12.15**: WASM 视频处理(从 CDN 加载) +- **@webav/av-cliper**: 客户端视频剪辑库 +- **markdown-it + highlight.js**: 内容渲染(支持代码高亮) +- **date-fns/dayjs**: 时间处理(dayjs-nuxt 提供全局实例) +- **idb-keyval**: IndexedDB 简化操作(缓存大文件) + +### Vite 优化设置 +```typescript +// nuxt.config.ts 中排除以下包进行优化,避免 bundling WASM +optimizeDeps.exclude: ['@ffmpeg/ffmpeg', 'idb-keyval', '@webav/av-cliper', 'gsap', 'markdown-it'] +``` + +### 构建排除项 +Worker 格式设置为 ES Module,避免 Vite 默认处理: +```typescript +vite.worker.format = 'es' +``` + +## 测试与调试 + +- **开发服务器日志**: 浏览器控制台查看 FFmpeg、API、业务日志 +- **FFmpeg 调试**: `[FFmpeg]` 前缀的日志输出包含加载进度、命令执行信息 +- **状态调试**: Pinia DevTools(启用 `devtools: true`) +- **样式调试**: Tailwind 配置在 `tailwind.config.ts`,按需自定义 + +## 常见陷阱与解决方案 + +| 问题 | 原因 | 解决方案 | +|------|------|--------| +| API 请求 401 | 缺少 token 或已过期 | 检查 `useLoginState().token`,通过 ModalAuthentication 重新登录 | +| FFmpeg 加载超时 | CDN 资源加载慢 | 检查网络,可切换到本地 `/public/assets/ffmpeg` | +| 视频输出无声音 | 滤镜链未映射音频 | 确保 FFmpeg 命令包含 `-map '1:a?'` 映射音频轨道 | +| 组件未注册 | 文件位置错误 | 确保在 `components/` 目录下,子目录自动扁平化注册 | +| Pinia 状态未持久化 | 未配置 persist 选项 | 在 store 返回语句后添加 persist 配置(参考 `useLoginState`) | + +## 资源链接 + +- [Nuxt 3 文档](https://nuxt.com/docs) +- [Pinia 文档](https://pinia.vuejs.org) +- [FFmpeg.wasm 文档](https://ffmpegwasm.netlify.app/) +- [Radix Vue](https://www.radix-vue.com/) +- [Nuxt UI 组件库](https://ui.nuxt.com/):可使用 nuxt-ui MCP 工具 diff --git a/components/SlideCreateCourseGreen.vue b/components/SlideCreateCourseGreen.vue index 6394bf2..0d4633d 100644 --- a/components/SlideCreateCourseGreen.vue +++ b/components/SlideCreateCourseGreen.vue @@ -15,8 +15,16 @@ const creationPending = ref(false) const isDigitalSelectorOpen = ref(false) const createCourseSchema = object({ - title: string().trim().min(4, '标题必须大于4个字符').max(20, '标题不能超过20个字符').required('请输入视频标题'), - content: string().trim().min(4, '内容必须大于4个字符').max(1000, '内容不能超过1000个字符').required('请输入驱动文本内容'), + title: string() + .trim() + .min(4, '标题必须大于4个字符') + .max(20, '标题不能超过20个字符') + .required('请输入视频标题'), + content: string() + .trim() + .min(4, '内容必须大于4个字符') + .max(1000, '内容不能超过1000个字符') + .required('请输入驱动文本内容'), digital_human_id: number().not([0], '请选择数字人'), source_type: number().default(0).required(), speed: number().default(1.0).min(0.5).max(1.5).required(), @@ -31,40 +39,38 @@ const createCourseState = reactive({ digital_human_id: 0, source_type: 0, speed: 1.0, - bg_img: undefined, + bg_img: 'https://service1.fenshenzhike.com/default_background.png', }) const selected_digital_human = ref(null) -const selected_bg_img = ref(); +const selected_bg_img = ref() watchEffect(() => { if (selected_digital_human.value) { // 2025.02.26 使用内部数字人 ID createCourseState.digital_human_id = - selected_digital_human.value.digital_human_id ?? selected_digital_human.value.id ?? 0 + selected_digital_human.value.digital_human_id ?? + selected_digital_human.value.id ?? + 0 createCourseState.source_type = selected_digital_human.value.type! } }) -const onCreateCourseGreenSubmit = async (event: FormSubmitEvent) => { +const onCreateCourseGreenSubmit = async ( + event: FormSubmitEvent +) => { creationPending.value = true - let bgImgUrl = undefined - - if (selected_bg_img.value) { - bgImgUrl = await useFileGo(selected_bg_img.value, 'tmp') - } - let payload: { - token: string; - user_id: number; - title: string; - content: string; - digital_human_id: any; - speed: number; - device_id: string; - source_type: 1 | 2 | undefined; - bg_img?: string; + token: string + user_id: number + title: string + content: string + digital_human_id: any + speed: number + device_id: string + source_type: 1 | 2 | undefined + bg_img?: string } = { token: loginState.token!, user_id: loginState.user.id, @@ -74,66 +80,60 @@ const onCreateCourseGreenSubmit = async (event: FormSubmitEvent + >('App.Digital_VideoTask.Create', payload) + .then((res) => { + if (!!res.data.task_id) { + toast.add({ + title: '创建成功', + description: '视频已加入生成队列', + color: 'green', + icon: 'i-tabler-check', + }) + emit('success') + slide.close() + } else { + toast.add({ + title: '创建失败', + description: res.msg || '未知错误', + color: 'red', + icon: 'i-tabler-alert-triangle', + }) + } + creationPending.value = false + }) + .catch((e) => { creationPending.value = false - return - } - payload = { - ...payload, - bg_img: bgImgUrl, - } - } - - useFetchWrapped>('App.Digital_VideoTask.Create', payload).then(res => { - if (!!res.data.task_id) { - toast.add({ - title: '创建成功', - description: '视频已加入生成队列', - color: 'green', - icon: 'i-tabler-check', - }) - emit('success') - slide.close() - } else { toast.add({ title: '创建失败', - description: res.msg || '未知错误', + description: e.message || '未知错误', color: 'red', icon: 'i-tabler-alert-triangle', }) - } - creationPending.value = false - }).catch(e => { - creationPending.value = false - toast.add({ - title: '创建失败', - description: e.message || '未知错误', - color: 'red', - icon: 'i-tabler-alert-triangle', }) - }) }