You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

72 KiB

AI 对话 @ 提及与参考图系统

文档版本:v2.1
最后更新:2026-02-03
变更说明:修复技术栈合规性问题(UUID v7、TIMESTAMPTZ 规范说明)
符合规范:jointo-tech-stack v1.0


技术规范说明

本文档遵循 jointo-tech-stack 规范:

  • UUID 规范:所有 UUID 字段使用 UUID v7(应用层生成),符合 ADR 001 规范
  • 时间戳规范:所有时间字段使用 TIMESTAMPTZ 类型,符合 ADR 006 规范
  • 枚举类型:使用 SMALLINT 存储,Python 使用 IntEnum
  • 无物理外键:应用层保证引用完整性
  • 日志格式:使用 %-formatting,错误日志包含 exc_info=True

目录

  1. 功能概述
  2. 核心设计
  3. API 接口
  4. Service 实现
  5. 前端集成
  6. AI 提供商集成
  7. 使用场景示例

功能概述

什么是 @ 提及系统?

用户在 AI 对话输入框中输入 @ 符号,可以快速引用当前上下文中的角色、场景、道具、视频等资源作为参考图(Reference Image),提交给 AI 生成时使用。

典型使用场景

场景:用户在"分镜001"的对话窗口中

用户输入:
"生成一张 @张三-少年 在 @咖啡厅-白天 喝咖啡的图片"

系统处理:
1. 识别 @张三-少年 和 @咖啡厅-白天
2. 查找该分镜关联的"张三-少年"角色图片
3. 查找该分镜关联的"咖啡厅-白天"场景图片
4. 将这些图片作为参考图传递给 AI
5. AI 根据提示词 + 参考图生成新图片

核心价值

  • 快速引用:无需手动上传参考图,直接 @ 引用已有资源
  • 上下文感知:自动识别当前对话上下文,只显示相关资源
  • 标签支持:支持引用特定标签的资源(如角色的不同装扮、场景的不同时段)
  • 多资源组合:可以同时引用多个资源作为参考图

核心设计

设计原则

  1. 无需新表:使用现有的 meta_data 字段存储提及信息
  2. 上下文驱动:根据对话的 target_type + target_id 查询可提及的资源
  3. 实时查询:用户输入 @ 时实时查询,不预先存储
  4. 权限验证:确保用户只能引用有权限访问的资源
  5. 后端解析:前端只发送文本,后端解析提及标记并验证资源(保证数据一致性)
  6. 标准格式:使用 Markdown 风格的链接格式存储提及信息

数据流程

用户输入 "@"
    ↓
前端调用 GET /api/v1/ai/conversations/{id}/mentionable-resources
    ↓
后端根据对话上下文查询关联资源
    ↓
返回可提及的资源列表(角色、场景、道具、视频等)
    ↓
用户选择 "@张三-少年"
    ↓
前端插入标准格式的提及标记到输入框
    格式:@[张三-少年](character:元素ID:标签ID:资源ID)
    ↓
用户可以继续编辑、删除、修改提及(文本是唯一数据源)
    ↓
用户点击"发送"
    ↓
前端调用 POST /api/v1/ai/conversations/{id}/messages
    只发送 content 文本,不发送 mentions 数组
    ↓
后端解析文本中的提及标记
    ↓
后端验证每个提及的资源(权限、存在性、关联性)
    ↓
后端构建 meta_data(mentions + reference_images)
    ↓
后端存储消息到数据库
    ↓
用户点击"生成"
    ↓
后端从 meta_data 中提取参考图 URL
    ↓
调用 AI Service,传递参考图
    ↓
AI 根据提示词 + 参考图生成内容

提及标记格式

使用 Markdown 风格的链接格式,确保文本中包含所有必要信息:

@[显示名称](类型:元素ID:标签ID:资源ID)

示例

生成一张 @[张三-少年](character:019d1234-5678-7abc-def0-111111111111:019d1234-5678-7abc-def0-222222222222:019d1234-5678-7abc-def0-333333333333) 在 @[咖啡厅-白天](scene:019d1234-5678-7abc-def0-444444444444:019d1234-5678-7abc-def0-555555555555:019d1234-5678-7abc-def0-666666666666) 喝咖啡的图片

格式说明

  • 显示名称:用户看到的友好名称(如 张三-少年咖啡厅-白天
  • 类型:资源类型(characterscenepropvideoaudio
  • 元素ID:元素的 UUID(如角色 ID、场景 ID)
  • 标签ID:标签的 UUID(如果没有标签则为空字符串)
  • 资源ID:具体资源的 UUID(图片、视频、音频文件的 ID)

优点

  1. 单一数据源:文本内容是唯一的真实来源,避免前端状态与文本不一致
  2. 自包含:文本中包含所有必要信息,后端可以直接解析
  3. 用户友好:用户看到的是友好的显示名称,不是 UUID
  4. 可复制粘贴:用户复制粘贴时不会丢失提及信息
  5. 容错性强:用户修改文本后,后端重新解析,始终保持一致

数据结构

消息 meta_data 结构

{
  "mentions": [
    {
      "type": "character",
      "elementId": "019d1234-5678-7abc-def0-222222222222",
      "elementName": "张三",
      "tagId": "019d1234-5678-7abc-def0-333333333333",
      "tagLabel": "少年",
      "resourceId": "019d1234-5678-7abc-def0-444444444444",
      "resourceUrl": "https://storage.example.com/char/zhangsan-youth.jpg",
      "thumbnailUrl": "https://storage.example.com/char/zhangsan-youth-thumb.jpg"
    },
    {
      "type": "scene",
      "elementId": "019d1234-5678-7abc-def0-555555555555",
      "elementName": "咖啡厅",
      "tagId": "019d1234-5678-7abc-def0-666666666666",
      "tagLabel": "白天",
      "resourceId": "019d1234-5678-7abc-def0-777777777777",
      "resourceUrl": "https://storage.example.com/scene/cafe-day.jpg",
      "thumbnailUrl": "https://storage.example.com/scene/cafe-day-thumb.jpg"
    }
  ],
  "referenceImages": [
    "https://storage.example.com/char/zhangsan-youth.jpg",
    "https://storage.example.com/scene/cafe-day.jpg"
  ]
}

可提及资源结构

{
  "type": "character",
  "elementId": "019d1234-5678-7abc-def0-222222222222",
  "elementName": "张三",
  "hasTags": true,
  "tags": [
    {
      "tagId": "019d1234-5678-7abc-def0-333333333333",
      "tagLabel": "少年",
      "resources": [
        {
          "resourceId": "019d1234-5678-7abc-def0-444444444444",
          "resourceUrl": "https://storage.example.com/char/zhangsan-youth.jpg",
          "thumbnailUrl": "https://storage.example.com/char/zhangsan-youth-thumb.jpg",
          "width": 1024,
          "height": 1024
        }
      ]
    },
    {
      "tagId": "019d1234-5678-7abc-def0-888888888888",
      "tagLabel": "成年",
      "resources": [
        {
          "resourceId": "019d1234-5678-7abc-def0-999999999999",
          "resourceUrl": "https://storage.example.com/char/zhangsan-adult.jpg",
          "thumbnailUrl": "https://storage.example.com/char/zhangsan-adult-thumb.jpg",
          "width": 1024,
          "height": 1024
        }
      ]
    }
  ],
  "defaultResource": {
    "resourceId": "019d1234-5678-7abc-def0-444444444444",
    "resourceUrl": "https://storage.example.com/char/zhangsan-youth.jpg",
    "thumbnailUrl": "https://storage.example.com/char/zhangsan-youth-thumb.jpg"
  }
}

资源查询逻辑

根据对话的 target_type 决定查询哪些资源:

target_type 查询逻辑 说明
STORYBOARD (1) 查询 storyboard_resources 返回该分镜关联的所有资源(角色、场景、道具、视频等)
CHARACTER (2) 查询 project_resources 返回该角色的所有形象图片(区分标签)
SCENE (3) 查询 project_resources 返回该场景的所有图片(区分标签)
PROP (4) 查询 project_resources 返回该道具的所有图片(区分标签)
RESOURCE (5) 查询 project_resources 返回该资源本身
SOUND_EFFECT (6) 查询 project_resources 返回该音效的音频文件
VOICEOVER (7) 查询 project_resources 返回该配音的音频文件

业务流程时序图

时序图 1:用户输入 @ 触发资源查询流程

sequenceDiagram
    actor User as 用户
    participant FE as 前端 MentionInput
    participant API as API Gateway
    participant Service as AIConversationService
    participant ConvRepo as ConversationRepository
    participant SBRepo as StoryboardResourceRepository
    participant PRRepo as ProjectResourceRepository
    participant TagRepo as ScreenplayTagRepository
    
    User->>FE: 输入 "@"
    FE->>FE: 检测到 @ 符号
    FE->>API: GET /api/v1/ai/conversations/{id}/mentionable-resources
    
    API->>Service: get_mentionable_resources(conversation_id, user_id)
    
    Service->>ConvRepo: get_by_id(conversation_id)
    ConvRepo-->>Service: conversation (target_type=STORYBOARD, target_id)
    
    Service->>Service: 验证用户权限
    
    alt target_type == STORYBOARD
        Service->>SBRepo: get_by_storyboard(storyboard_id)
        SBRepo-->>Service: storyboard_resources[]
        
        loop 遍历每个资源
            Service->>PRRepo: get_by_id(project_resource_id)
            PRRepo-->>Service: resource (file_url, thumbnail_url)
            
            opt 资源有标签
                Service->>TagRepo: get_by_id(element_tag_id)
                TagRepo-->>Service: tag (tag_label)
            end
            
            Service->>Service: 按元素分组,按标签分组
        end
    else target_type == CHARACTER
        Service->>PRRepo: get_by_element(element_type, element_id)
        PRRepo-->>Service: resources[]
        Service->>Service: 按标签分组
    else target_type == SCENE
        Service->>PRRepo: get_by_element(element_type, element_id)
        PRRepo-->>Service: resources[]
        Service->>Service: 按标签分组
    end
    
    Service-->>API: { conversation, resources[] }
    API-->>FE: 200 OK + 可提及资源列表
    
    FE->>FE: 渲染自动补全下拉菜单
    FE->>User: 显示可提及的资源列表
    
    User->>FE: 选择 "@张三-少年"
    FE->>FE: 插入提及标签到输入框
    FE->>FE: 存储 mention 对象到状态
    FE->>User: 显示提及标签(带缩略图)

流程说明

  1. 触发:用户在输入框中输入 @ 符号
  2. 查询:前端调用 API 获取可提及的资源列表
  3. 权限验证:后端验证用户是否有权限访问该对话
  4. 上下文识别:根据对话的 target_type 决定查询哪些资源
  5. 资源分组:按元素分组,按标签分组(如果有标签)
  6. 返回结果:返回结构化的资源列表
  7. 用户选择:用户从下拉菜单中选择资源
  8. 插入标签:前端将提及标签插入到输入框

时序图 2:发送带 @ 提及的消息并触发 AI 生成流程

sequenceDiagram
    actor User as 用户
    participant FE as 前端
    participant API as API Gateway
    participant ConvService as AIConversationService
    participant MsgRepo as MessageRepository
    participant AIService as AIService
    participant CreditService as CreditService
    participant JobRepo as AIJobRepository
    participant Celery as Celery Worker
    participant AIProvider as AI 提供商 (SD/MJ)
    participant Storage as 对象存储 (MinIO)
    
    User->>FE: 输入完整提示词 + 选择提及
    Note over FE: "生成一张 @张三-少年 在 @咖啡厅-白天 喝咖啡的图片"
    
    User->>FE: 点击"发送"按钮
    FE->>API: POST /api/v1/ai/conversations/{id}/messages
    Note over API: { content: "生成一张 @[张三-少年](...) 在 @[咖啡厅-白天](...) 喝咖啡的图片" }
    
    API->>ConvService: send_message(conversation_id, user_id, content)
    
    ConvService->>ConvService: 验证对话权限
    
    ConvService->>ConvService: _parse_mentions(content, user_id, conversation_id)
    Note over ConvService: 使用正则表达式解析提及标记<br/>pattern = @\[([^\]]+)\]\(([^:]+):([^:]+):([^:]*):([^)]+)\)
    
    loop 遍历每个提及标记
        ConvService->>ConvService: _validate_and_build_mention(...)
        ConvService->>ConvService: 验证资源是否存在、是否属于该项目、是否关联到分镜
    end
    
    ConvService->>ConvService: 构建 meta_data
    Note over ConvService: meta_data = {<br/>  mentions: [...],<br/>  reference_images: [url1, url2]<br/>}
    
    ConvService->>MsgRepo: create(message)
    MsgRepo-->>ConvService: message (message_id, meta_data)
    
    ConvService-->>API: { message_id, content, meta_data }
    API-->>FE: 200 OK + 消息详情
    FE->>User: 显示消息(带提及标签)
    
    User->>FE: 点击"生成"按钮
    FE->>API: POST /api/v1/ai/conversations/{id}/generate
    Note over API: { message_id, generation_type: "image", params }
    
    API->>ConvService: trigger_ai_generation(conversation_id, user_id, message_id, ...)
    
    ConvService->>MsgRepo: get_by_id(message_id)
    MsgRepo-->>ConvService: message (meta_data.reference_images)
    
    ConvService->>ConvService: 提取参考图 URL
    Note over ConvService: reference_images = [<br/>  "https://.../zhangsan-youth.jpg",<br/>  "https://.../cafe-day.jpg"<br/>]
    
    ConvService->>AIService: generate_image(user_id, prompt, reference_images, ...)
    
    AIService->>AIService: 计算积分
    Note over AIService: base_credits = 10<br/>reference_credits = 2 × 2 = 4<br/>total_credits = 14
    
    AIService->>CreditService: consume_credits(user_id, total_credits)
    CreditService-->>AIService: consumption_log
    
    AIService->>JobRepo: create(job)
    Note over JobRepo: input_data = {<br/>  prompt, width, height,<br/>  reference_images: [url1, url2]<br/>}
    JobRepo-->>AIService: job (job_id, status=PENDING)
    
    AIService->>Celery: generate_image_task.delay(job_id)
    Celery-->>AIService: task (task_id)
    
    AIService->>JobRepo: update(job_id, { task_id })
    
    AIService-->>ConvService: { job_id, task_id, status, reference_images_count }
    ConvService->>MsgRepo: update(message_id, { ai_job_id })
    
    ConvService->>MsgRepo: create(system_message)
    Note over MsgRepo: "AI image 生成任务已创建,<br/>使用了 2 张参考图"
    
    ConvService-->>API: { job_id, task_id, status, reference_images_count: 2 }
    API-->>FE: 200 OK + 任务详情
    FE->>User: 显示"生成中..."状态
    
    Note over Celery: === 异步任务开始 ===
    
    Celery->>JobRepo: get_by_id(job_id)
    JobRepo-->>Celery: job (input_data.reference_images)
    
    Celery->>JobRepo: update(job_id, { status: PROCESSING })
    
    alt model == stable_diffusion
        Celery->>AIProvider: generate_with_controlnet(prompt, control_images, ...)
        Note over AIProvider: 使用 ControlNet<br/>参考图权重 0.8
    else model == midjourney
        Celery->>AIProvider: generate(prompt + "--cref url1 --cw 80 --cref url2 --cw 80")
        Note over AIProvider: 使用 --cref 参数<br/>角色参考
    end
    
    AIProvider-->>Celery: { image_url, width, height }
    
    Celery->>Storage: upload_from_url(image_url, path)
    Storage-->>Celery: file_url
    
    Celery->>JobRepo: update(job_id, { status: COMPLETED, output_data })
    
    Celery->>CreditService: confirm_consumption(consumption_id, resource_id)
    
    Celery->>MsgRepo: create(assistant_message)
    Note over MsgRepo: role=ASSISTANT<br/>content="[生成的图片]"<br/>meta_data={ file_url, ... }
    
    Note over Celery: === 异步任务完成 ===
    
    Celery-->>FE: WebSocket 推送任务完成事件
    FE->>User: 显示生成的图片

流程说明

  1. 发送消息

    • 用户输入完整提示词并选择提及
    • 前端发送消息到后端
    • 后端验证所有提及的资源
    • 构建 meta_data,存储提及信息和参考图 URL
    • 创建用户消息
  2. 触发 AI 生成

    • 用户点击"生成"按钮
    • 后端从消息的 meta_data 中提取参考图 URL
    • 计算积分(基础积分 + 参考图积分)
    • 扣除积分
    • 创建 AI 任务,存储参考图 URL 到 input_data
    • 提交异步任务到 Celery
  3. 异步生成

    • Celery Worker 获取任务
    • 根据模型类型调用不同的 AI 提供商
    • Stable Diffusion 使用 ControlNet
    • Midjourney 使用 --cref 参数
    • AI 生成图片
    • 上传到对象存储
    • 更新任务状态
    • 确认积分消耗
    • 创建助手消息(包含生成的图片)
  4. 结果通知

    • WebSocket 推送任务完成事件
    • 前端显示生成的图片

API 接口

1. 获取可提及的资源列表

GET /api/v1/ai/conversations/{conversation_id}/mentionable-resources

功能:根据对话上下文,返回可以 @ 的资源列表。

查询参数

  • type(可选):资源类型过滤(character | scene | prop | video | audio
  • search(可选):搜索关键词

响应

{
  "code": 200,
  "message": "Success",
  "data": {
    "conversation": {
      "conversation_id": "019d1234-5678-7abc-def0-111111111111",
      "target_type": 1,
      "target_id": "019d1234-5678-7abc-def0-222222222222",
      "target_name": "分镜001"
    },
    "resources": [
      {
        "type": "character",
        "element_id": "019d1234-5678-7abc-def0-333333333333",
        "element_name": "张三",
        "has_tags": true,
        "tags": [
          {
            "tag_id": "019d1234-5678-7abc-def0-444444444444",
            "tag_label": "少年",
            "resources": [
              {
                "resource_id": "019d1234-5678-7abc-def0-555555555555",
                "resource_url": "https://storage.example.com/char/zhangsan-youth.jpg",
                "thumbnail_url": "https://storage.example.com/char/zhangsan-youth-thumb.jpg",
                "width": 1024,
                "height": 1024
              }
            ]
          },
          {
            "tag_id": "019d1234-5678-7abc-def0-666666666666",
            "tag_label": "成年",
            "resources": [
              {
                "resource_id": "019d1234-5678-7abc-def0-777777777777",
                "resource_url": "https://storage.example.com/char/zhangsan-adult.jpg",
                "thumbnail_url": "https://storage.example.com/char/zhangsan-adult-thumb.jpg",
                "width": 1024,
                "height": 1024
              }
            ]
          }
        ]
      },
      {
        "type": "scene",
        "element_id": "019d1234-5678-7abc-def0-888888888888",
        "element_name": "咖啡厅",
        "has_tags": true,
        "tags": [
          {
            "tag_id": "019d1234-5678-7abc-def0-999999999999",
            "tag_label": "白天",
            "resources": [
              {
                "resource_id": "019d1234-5678-7abc-def0-aaaaaaaaaaaa",
                "resource_url": "https://storage.example.com/scene/cafe-day.jpg",
                "thumbnail_url": "https://storage.example.com/scene/cafe-day-thumb.jpg",
                "width": 1920,
                "height": 1080
              }
            ]
          },
          {
            "tag_id": "019d1234-5678-7abc-def0-bbbbbbbbbbbb",
            "tag_label": "夜晚",
            "resources": [
              {
                "resource_id": "019d1234-5678-7abc-def0-cccccccccccc",
                "resource_url": "https://storage.example.com/scene/cafe-night.jpg",
                "thumbnail_url": "https://storage.example.com/scene/cafe-night-thumb.jpg",
                "width": 1920,
                "height": 1080
              }
            ]
          }
        ]
      },
      {
        "type": "prop",
        "element_id": "019d1234-5678-7abc-def0-dddddddddddd",
        "element_name": "咖啡杯",
        "has_tags": false,
        "default_resource": {
          "resource_id": "019d1234-5678-7abc-def0-eeeeeeeeeeee",
          "resource_url": "https://storage.example.com/prop/coffee-cup.jpg",
          "thumbnail_url": "https://storage.example.com/prop/coffee-cup-thumb.jpg",
          "width": 512,
          "height": 512
        }
      }
    ]
  }
}

使用示例

// 前端:用户输入 "@" 时,获取可提及的资源
const getMentionableResources = async (conversationId) => {
  const response = await fetch(
    `/api/v1/ai/conversations/${conversationId}/mentionable-resources`,
    { headers: { 'Authorization': `Bearer ${token}` } }
  );
  return response.json();
};

// 前端:搜索过滤
const searchMentionableResources = async (conversationId, keyword) => {
  const response = await fetch(
    `/api/v1/ai/conversations/${conversationId}/mentionable-resources?search=${keyword}`,
    { headers: { 'Authorization': `Bearer ${token}` } }
  );
  return response.json();
};

2. 发送带提及的消息(增强)

POST /api/v1/ai/conversations/{conversation_id}/messages

功能:发送用户消息,后端自动解析提及标记并验证资源。

请求体

{
  "content": "生成一张 @[张三-少年](character:019d1234-5678-7abc-def0-333333333333:019d1234-5678-7abc-def0-444444444444:019d1234-5678-7abc-def0-555555555555) 在 @[咖啡厅-白天](scene:019d1234-5678-7abc-def0-888888888888:019d1234-5678-7abc-def0-999999999999:019d1234-5678-7abc-def0-aaaaaaaaaaaa) 喝咖啡的图片"
}

注意

  • 前端只发送 content 文本,不发送 mentions 数组
  • 后端会自动解析文本中的提及标记
  • 后端会验证所有提及的资源是否有效

响应

{
  "code": 200,
  "message": "Success",
  "data": {
    "message_id": "019d1234-5678-7abc-def0-ffffffffffff",
    "role": "user",
    "content": "生成一张 @张三-少年 在 @咖啡厅-白天 喝咖啡的图片",
    "meta_data": {
      "mentions": [
        {
          "type": "character",
          "element_id": "019d1234-5678-7abc-def0-333333333333",
          "element_name": "张三",
          "tag_id": "019d1234-5678-7abc-def0-444444444444",
          "tag_label": "少年",
          "resource_id": "019d1234-5678-7abc-def0-555555555555",
          "resource_url": "https://storage.example.com/char/zhangsan-youth.jpg",
          "thumbnail_url": "https://storage.example.com/char/zhangsan-youth-thumb.jpg"
        },
        {
          "type": "scene",
          "element_id": "019d1234-5678-7abc-def0-888888888888",
          "element_name": "咖啡厅",
          "tag_id": "019d1234-5678-7abc-def0-999999999999",
          "tag_label": "白天",
          "resource_id": "019d1234-5678-7abc-def0-aaaaaaaaaaaa",
          "resource_url": "https://storage.example.com/scene/cafe-day.jpg",
          "thumbnail_url": "https://storage.example.com/scene/cafe-day-thumb.jpg"
        }
      ],
      "reference_images": [
        "https://storage.example.com/char/zhangsan-youth.jpg",
        "https://storage.example.com/scene/cafe-day.jpg"
      ]
    },
    "created_at": "2026-01-30T10:00:00Z"
  }
}

使用示例

// 前端:发送带提及的消息(只发送文本)
const sendMessageWithMentions = async (conversationId, content) => {
  const response = await fetch(
    `/api/v1/ai/conversations/${conversationId}/messages`,
    {
      method: 'POST',
      headers: { 
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
      },
      body: JSON.stringify({
        content  // 只发送文本,后端会自动解析提及
      })
    }
  );
  return response.json();
};

// 示例调用
const content = `生成一张 @[张三-少年](character:019d1234...:019d1234...:019d1234...) 在 @[咖啡厅-白天](scene:019d1234...:019d1234...:019d1234...) 喝咖啡的图片`;
const result = await sendMessageWithMentions(conversationId, content);

3. 触发 AI 生成(增强)

POST /api/v1/ai/conversations/{conversation_id}/generate

请求体

{
  "message_id": "019d1234-5678-7abc-def0-ffffffffffff",
  "generation_type": "image",
  "params": {
    "prompt": "生成一张张三在咖啡厅喝咖啡的图片",
    "model": "stable_diffusion",
    "width": 1024,
    "height": 1024,
    "style": "realistic"
  }
}

后端处理

  1. message.meta_data 中提取 reference_images
  2. 将参考图 URL 传递给 AI Service
  3. AI Service 调用 AI 提供商时,附带参考图

响应

{
  "code": 200,
  "message": "Success",
  "data": {
    "job_id": "019d1234-5678-7abc-def0-gggggggggggg",
    "task_id": "abc-123-def",
    "status": "pending",
    "estimated_credits": 15,
    "reference_images_count": 2
  }
}

Service 实现

AIConversationService 新增/增强方法

1. send_message(增强版)

# app/services/ai_conversation_service.py

import re
from typing import List, Dict, Any, Optional
from uuid import UUID

async def send_message(
    self,
    conversation_id: UUID,
    user_id: UUID,
    content: str,
    meta_data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
    """发送用户消息(自动解析提及)
    
    Args:
        conversation_id: 对话会话 ID
        user_id: 用户 ID
        content: 消息内容(包含提及标记)
        meta_data: 额外元数据(可选)
    """
    logger.info("发送消息: conversation_id=%s, user_id=%s", 
               conversation_id, user_id)
    
    # 验证对话会话
    conversation = await self.conversation_repo.get_by_id(conversation_id)
    if not conversation:
        raise NotFoundError("对话会话不存在")
    
    # 验证权限
    if conversation.user_id != user_id:
        raise PermissionDeniedError("没有权限在此对话中发送消息")
    
    # 解析提及标记
    mentions, reference_images = await self._parse_mentions(
        content, 
        user_id, 
        conversation_id
    )
    
    # 构建 meta_data
    message_meta_data = meta_data or {}
    if mentions:
        message_meta_data['mentions'] = mentions
        message_meta_data['reference_images'] = reference_images
    
    # 获取当前消息数量
    order_index = conversation.message_count
    
    # 创建用户消息
    from app.models.ai_conversation_message import MessageRole
    message = await self.message_repo.create({
        'conversation_id': conversation_id,
        'user_id': user_id,
        'role': MessageRole.USER,
        'content': content,
        'meta_data': message_meta_data,
        'order_index': order_index
    })
    
    logger.info(
        "用户消息已创建: message_id=%s, mentions=%d", 
        message.message_id, len(mentions)
    )
    
    return {
        'message_id': str(message.message_id),
        'role': 'user',
        'content': message.content,
        'meta_data': message.meta_data,
        'created_at': message.created_at.isoformat()
    }

2. _parse_mentions(新增)

async def _parse_mentions(
    self,
    content: str,
    user_id: UUID,
    conversation_id: UUID
) -> tuple[List[Dict[str, Any]], List[str]]:
    """解析消息中的提及标记
    
    Args:
        content: 消息内容
        user_id: 用户 ID
        conversation_id: 对话会话 ID
    
    Returns:
        (mentions, reference_images)
    """
    # 正则表达式匹配提及标记
    # 格式:@[显示名称](类型:元素ID:标签ID:资源ID)
    pattern = r'@\[([^\]]+)\]\(([^:]+):([^:]+):([^:]*):([^)]+)\)'
    matches = re.finditer(pattern, content)
    
    mentions = []
    reference_images = []
    
    for match in matches:
        display_name = match.group(1)  # 张三-少年
        resource_type = match.group(2)  # character
        element_id = match.group(3)     # 019d1234...
        tag_id = match.group(4) or None # 019d1234... 或空
        resource_id = match.group(5)    # 019d1234...
        
        try:
            # 验证资源
            mention = await self._validate_and_build_mention(
                user_id=user_id,
                conversation_id=conversation_id,
                resource_type=resource_type,
                element_id=UUID(element_id),
                tag_id=UUID(tag_id) if tag_id else None,
                resource_id=UUID(resource_id),
                display_name=display_name
            )
            
            if mention:
                mentions.append(mention)
                reference_images.append(mention['resource_url'])
                
        except Exception as e:
            logger.warning(
                "解析提及失败: display_name=%s, error=%s", 
                display_name, str(e)
            )
            # 继续处理其他提及,不中断整个流程
            continue
    
    logger.info("解析到 %d 个有效提及", len(mentions))
    return mentions, reference_images

3. _validate_and_build_mention(新增)

async def _validate_and_build_mention(
    self,
    user_id: UUID,
    conversation_id: UUID,
    resource_type: str,
    element_id: UUID,
    tag_id: Optional[UUID],
    resource_id: UUID,
    display_name: str
) -> Optional[Dict[str, Any]]:
    """验证并构建提及对象
    
    Args:
        user_id: 用户 ID
        conversation_id: 对话会话 ID
        resource_type: 资源类型
        element_id: 元素 ID
        tag_id: 标签 ID(可选)
        resource_id: 资源 ID
        display_name: 显示名称
    
    Returns:
        提及对象,如果验证失败则返回 None
    """
    from app.repositories.project_resource_repository import ProjectResourceRepository
    from app.repositories.screenplay_tag_repository import ScreenplayTagRepository
    
    resource_repo = ProjectResourceRepository(self.db)
    tag_repo = ScreenplayTagRepository(self.db)
    
    # 1. 获取对话上下文
    conversation = await self.conversation_repo.get_by_id(conversation_id)
    
    # 2. 验证资源是否存在
    resource = await resource_repo.get_by_id(resource_id)
    if not resource:
        logger.warning("资源不存在: resource_id=%s", resource_id)
        return None
    
    # 3. 验证资源是否属于该项目
    if resource.project_id != conversation.project_id:
        logger.warning(
            "资源不属于该项目: resource_id=%s, project_id=%s", 
            resource_id, conversation.project_id
        )
        return None
    
    # 4. 验证用户权限
    from app.repositories.project_repository import ProjectRepository
    project_repo = ProjectRepository(self.db)
    has_permission = await project_repo.check_user_permission(
        user_id, conversation.project_id, 'viewer'
    )
    if not has_permission:
        logger.warning("用户无权限访问该项目: user_id=%s", user_id)
        return None
    
    # 5. 如果是分镜对话,验证资源是否关联到该分镜
    from app.models.ai_conversation import TargetType
    if conversation.target_type == TargetType.STORYBOARD:
        from app.repositories.storyboard_resource_repository import StoryboardResourceRepository
        storyboard_resource_repo = StoryboardResourceRepository(self.db)
        is_linked = await storyboard_resource_repo.is_linked(
            conversation.target_id,
            resource_id
        )
        if not is_linked:
            logger.warning(
                "资源未关联到该分镜: storyboard_id=%s, resource_id=%s", 
                conversation.target_id, resource_id
            )
            return None
    
    # 6. 获取元素名称和标签信息
    element_name = display_name.split('-')[0] if '-' in display_name else display_name
    tag_label = display_name.split('-')[1] if '-' in display_name else None
    
    # 7. 构建提及对象
    mention = {
        'type': resource_type,
        'element_id': str(element_id),
        'element_name': element_name,
        'resource_id': str(resource_id),
        'resource_url': resource.file_url,
        'thumbnail_url': resource.thumbnail_url
    }
    
    if tag_id:
        mention['tag_id'] = str(tag_id)
        mention['tag_label'] = tag_label
    
    return mention

4. get_mentionable_resources(保持不变)

#### 4. get_mentionable_resources(保持不变)

```python
# app/services/ai_conversation_service.py

async def get_mentionable_resources(
    self,
    conversation_id: UUID,
    user_id: UUID,
    resource_type: Optional[str] = None,
    search: Optional[str] = None
) -> Dict[str, Any]:
    """获取可提及的资源列表
    
    Args:
        conversation_id: 对话会话 ID
        user_id: 用户 ID(用于权限验证)
        resource_type: 资源类型过滤(可选)
        search: 搜索关键词(可选)
    """
    logger.info("获取可提及资源: conversation_id=%s, type=%s, search=%s", 
               conversation_id, resource_type, search)
    
    # 1. 获取对话上下文
    conversation = await self.conversation_repo.get_by_id(conversation_id)
    if not conversation:
        raise NotFoundError("对话会话不存在")
    
    # 2. 验证权限
    if conversation.user_id != user_id:
        raise PermissionDeniedError("没有权限访问此对话")
    
    # 3. 根据 target_type 查询资源
    from app.models.ai_conversation import TargetType
    
    if conversation.target_type == TargetType.STORYBOARD:
        resources = await self._get_storyboard_mentionable_resources(
            conversation.target_id,
            resource_type,
            search
        )
    elif conversation.target_type == TargetType.CHARACTER:
        resources = await self._get_character_mentionable_resources(
            conversation.target_id,
            resource_type,
            search
        )
    elif conversation.target_type == TargetType.SCENE:
        resources = await self._get_scene_mentionable_resources(
            conversation.target_id,
            resource_type,
            search
        )
    elif conversation.target_type == TargetType.PROP:
        resources = await self._get_prop_mentionable_resources(
            conversation.target_id,
            resource_type,
            search
        )
    else:
        resources = []
    
    # 4. 获取目标对象名称
    target_name = await self._get_target_name(
        conversation.target_type,
        conversation.target_id
    )
    
    return {
        'conversation': {
            'conversation_id': str(conversation.conversation_id),
            'target_type': conversation.target_type,
            'target_id': str(conversation.target_id),
            'target_name': target_name
        },
        'resources': resources
    }

async def _get_storyboard_mentionable_resources(
    self,
    storyboard_id: UUID,
    resource_type: Optional[str] = None,
    search: Optional[str] = None
) -> List[Dict[str, Any]]:
    """获取分镜的可提及资源
    
    查询该分镜关联的所有资源(角色、场景、道具、视频等)
    """
    from app.repositories.storyboard_resource_repository import StoryboardResourceRepository
    from app.repositories.project_resource_repository import ProjectResourceRepository
    from app.repositories.screenplay_tag_repository import ScreenplayTagRepository
    
    storyboard_resource_repo = StoryboardResourceRepository(self.db)
    project_resource_repo = ProjectResourceRepository(self.db)
    tag_repo = ScreenplayTagRepository(self.db)
    
    # 1. 查询分镜关联的所有资源
    storyboard_resources = await storyboard_resource_repo.get_by_storyboard(storyboard_id)
    
    # 2. 按元素分组
    elements_map = {}
    for sr in storyboard_resources:
        # 获取资源详情
        resource = await project_resource_repo.get_by_id(sr.project_resource_id)
        if not resource:
            continue
        
        # 类型过滤
        if resource_type and resource.type != resource_type:
            continue
        
        # 搜索过滤
        if search and search.lower() not in sr.element_name.lower():
            continue
        
        # 按元素分组
        element_key = f"{sr.element_type}_{sr.element_id}"
        if element_key not in elements_map:
            elements_map[element_key] = {
                'type': self._get_element_type_name(sr.element_type),
                'element_id': str(sr.element_id),
                'element_name': sr.element_name,
                'has_tags': sr.element_tag_id is not None,
                'tags': {},
                'default_resource': None
            }
        
        # 构建资源信息
        resource_info = {
            'resource_id': str(resource.project_resource_id),
            'resource_url': resource.file_url,
            'thumbnail_url': resource.thumbnail_url,
            'width': resource.width,
            'height': resource.height
        }
        
        # 按标签分组
        if sr.element_tag_id:
            tag_key = str(sr.element_tag_id)
            if tag_key not in elements_map[element_key]['tags']:
                elements_map[element_key]['tags'][tag_key] = {
                    'tag_id': str(sr.element_tag_id),
                    'tag_label': sr.tag_label,
                    'resources': []
                }
            elements_map[element_key]['tags'][tag_key]['resources'].append(resource_info)
        else:
            # 无标签的资源作为默认资源
            if not elements_map[element_key]['default_resource']:
                elements_map[element_key]['default_resource'] = resource_info
    
    # 3. 转换为列表格式
    result = []
    for element in elements_map.values():
        if element['has_tags']:
            element['tags'] = list(element['tags'].values())
        else:
            del element['tags']
        result.append(element)
    
    return result

async def _get_character_mentionable_resources(
    self,
    character_id: UUID,
    resource_type: Optional[str] = None,
    search: Optional[str] = None
) -> List[Dict[str, Any]]:
    """获取角色的可提及资源
    
    查询该角色的所有形象图片(区分标签)
    """
    from app.repositories.project_resource_repository import ProjectResourceRepository
    from app.repositories.screenplay_character_repository import ScreenplayCharacterRepository
    from app.repositories.screenplay_tag_repository import ScreenplayTagRepository
    
    resource_repo = ProjectResourceRepository(self.db)
    character_repo = ScreenplayCharacterRepository(self.db)
    tag_repo = ScreenplayTagRepository(self.db)
    
    # 1. 获取角色信息
    character = await character_repo.get_by_id(character_id)
    if not character:
        return []
    
    # 2. 查询角色的所有资源
    resources = await resource_repo.get_by_element(
        element_type='character',
        element_id=character_id
    )
    
    # 3. 按标签分组
    tags_map = {}
    default_resource = None
    
    for resource in resources:
        # 类型过滤(只返回图片)
        if resource.type != 'image':
            continue
        
        resource_info = {
            'resource_id': str(resource.project_resource_id),
            'resource_url': resource.file_url,
            'thumbnail_url': resource.thumbnail_url,
            'width': resource.width,
            'height': resource.height
        }
        
        if resource.element_tag_id:
            tag_key = str(resource.element_tag_id)
            if tag_key not in tags_map:
                # 获取标签信息
                tag = await tag_repo.get_by_id(resource.element_tag_id)
                tags_map[tag_key] = {
                    'tag_id': str(resource.element_tag_id),
                    'tag_label': tag.tag_label if tag else '未知标签',
                    'resources': []
                }
            tags_map[tag_key]['resources'].append(resource_info)
        else:
            if not default_resource:
                default_resource = resource_info
    
    # 4. 构建返回结果
    result = {
        'type': 'character',
        'element_id': str(character_id),
        'element_name': character.name,
        'has_tags': len(tags_map) > 0
    }
    
    if result['has_tags']:
        result['tags'] = list(tags_map.values())
    
    if default_resource:
        result['default_resource'] = default_resource
    
    return [result]

async def _get_scene_mentionable_resources(
    self,
    scene_id: UUID,
    resource_type: Optional[str] = None,
    search: Optional[str] = None
) -> List[Dict[str, Any]]:
    """获取场景的可提及资源(逻辑类似 _get_character_mentionable_resources)"""
    # 实现逻辑与 _get_character_mentionable_resources 类似
    # 只是查询的是场景资源
    pass

async def _get_prop_mentionable_resources(
    self,
    prop_id: UUID,
    resource_type: Optional[str] = None,
    search: Optional[str] = None
) -> List[Dict[str, Any]]:
    """获取道具的可提及资源(逻辑类似 _get_character_mentionable_resources)"""
    # 实现逻辑与 _get_character_mentionable_resources 类似
    # 只是查询的是道具资源
    pass

def _get_element_type_name(self, element_type: int) -> str:
    """将元素类型枚举值转换为字符串"""
    type_map = {
        1: 'character',
        2: 'scene',
        3: 'prop',
        4: 'video',
        5: 'audio'
    }
    return type_map.get(element_type, 'unknown')

增强 send_message 方法

async def send_message(
    self,
    conversation_id: UUID,
    user_id: UUID,
    content: str,
    mentions: Optional[List[Dict[str, Any]]] = None,
    meta_data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
    """发送用户消息(增强版,支持提及)
    
    Args:
        conversation_id: 对话会话 ID
        user_id: 用户 ID
        content: 消息内容
        mentions: 提及列表(可选)
        meta_data: 额外元数据(可选)
    """
    logger.info("发送消息: conversation_id=%s, user_id=%s, mentions=%d", 
               conversation_id, user_id, len(mentions) if mentions else 0)
    
    # 验证对话会话是否存在
    conversation = await self.conversation_repo.get_by_id(conversation_id)
    if not conversation:
        raise NotFoundError("对话会话不存在")
    
    # 验证权限
    if conversation.user_id != user_id:
        raise PermissionDeniedError("没有权限在此对话中发送消息")
    
    # 构建 meta_data
    message_meta_data = meta_data or {}
    
    if mentions:
        # 验证所有提及的资源
        for mention in mentions:
            is_valid = await self._validate_mention(
                user_id,
                conversation_id,
                mention
            )
            if not is_valid:
                raise ValidationError(
                    f"无效的提及: {mention.get('element_name')}"
                )
        
        # 存储提及信息
        message_meta_data['mentions'] = mentions
        
        # 提取参考图 URL
        message_meta_data['reference_images'] = [
            m['resource_url'] 
            for m in mentions 
            if 'resource_url' in m
        ]
    
    # 获取当前消息数量(用于 order_index)
    order_index = conversation.message_count
    
    # 创建用户消息
    from app.models.ai_conversation_message import MessageRole
    message = await self.message_repo.create({
        'conversation_id': conversation_id,
        'user_id': user_id,
        'role': MessageRole.USER,
        'content': content,
        'meta_data': message_meta_data,
        'order_index': order_index
    })
    
    logger.info("用户消息已创建: message_id=%s", message.message_id)
    
    return {
        'message_id': str(message.message_id),
        'role': 'user',
        'content': message.content,
        'meta_data': message.meta_data,
        'created_at': message.created_at.isoformat()
    }

async def _validate_mention(
    self,
    user_id: UUID,
    conversation_id: UUID,
    mention: Dict[str, Any]
) -> bool:
    """验证提及的资源是否有效
    
    Args:
        user_id: 用户 ID
        conversation_id: 对话会话 ID
        mention: 提及信息
    """
    # 1. 获取对话上下文
    conversation = await self.conversation_repo.get_by_id(conversation_id)
    
    # 2. 验证项目权限
    if conversation.project_id:
        from app.repositories.project_repository import ProjectRepository
        project_repo = ProjectRepository(self.db)
        has_permission = await project_repo.check_user_permission(
            user_id, conversation.project_id, 'viewer'
        )
        if not has_permission:
            return False
    
    # 3. 验证资源是否存在
    from app.repositories.project_resource_repository import ProjectResourceRepository
    resource_repo = ProjectResourceRepository(self.db)
    resource = await resource_repo.get_by_id(mention['resource_id'])
    if not resource:
        return False
    
    # 4. 验证资源是否属于该项目
    if resource.project_id != conversation.project_id:
        return False
    
    # 5. 如果是分镜对话,验证资源是否关联到该分镜
    from app.models.ai_conversation import TargetType
    if conversation.target_type == TargetType.STORYBOARD:
        from app.repositories.storyboard_resource_repository import StoryboardResourceRepository
        storyboard_resource_repo = StoryboardResourceRepository(self.db)
        is_linked = await storyboard_resource_repo.is_linked(
            conversation.target_id,
            mention['resource_id']
        )
        if not is_linked:
            return False
    
    return True

增强 trigger_ai_generation 方法

async def trigger_ai_generation(
    self,
    conversation_id: UUID,
    user_id: UUID,
    message_id: UUID,
    generation_type: str,
    params: Dict[str, Any]
) -> Dict[str, Any]:
    """从对话触发 AI 生成任务(增强版,支持参考图)
    
    Args:
        conversation_id: 对话会话 ID
        user_id: 用户 ID
        message_id: 消息 ID(用于关联)
        generation_type: 生成类型(image/video)
        params: 生成参数
    """
    logger.info("触发 AI 生成: conversation_id=%s, type=%s", 
               conversation_id, generation_type)
    
    # 验证对话会话
    conversation = await self.conversation_repo.get_by_id(conversation_id)
    if not conversation:
        raise NotFoundError("对话会话不存在")
    
    # 验证权限
    if conversation.user_id != user_id:
        raise PermissionDeniedError("没有权限在此对话中触发 AI 生成")
    
    # 获取消息
    message = await self.message_repo.get_by_id(message_id)
    if not message:
        raise NotFoundError("消息不存在")
    
    # 从 meta_data 中提取参考图
    reference_images = []
    if 'reference_images' in message.meta_data:
        reference_images = message.meta_data['reference_images']
        logger.info("提取到 %d 张参考图", len(reference_images))
    
    # 调用 AI Service 创建任务
    if generation_type == 'image':
        result = await self.ai_service.generate_image(
            user_id=user_id,
            prompt=params.get('prompt'),
            reference_images=reference_images,  # 传递参考图
            model=params.get('model'),
            width=params.get('width', 1024),
            height=params.get('height', 1024),
            style=params.get('style')
        )
    elif generation_type == 'video':
        result = await self.ai_service.generate_video(
            user_id=user_id,
            video_type=params.get('video_type'),
            prompt=params.get('prompt'),
            reference_images=reference_images,  # 传递参考图
            image_url=params.get('image_url'),
            duration=params.get('duration', 5),
            fps=params.get('fps', 30),
            model=params.get('model')
        )
    else:
        raise ValidationError(f"不支持的生成类型: {generation_type}")
    
    # 更新消息,关联 AI 任务
    await self.message_repo.update(message_id, {
        'ai_job_id': result['job_id']
    })
    
    # 创建系统消息通知任务已创建
    from app.models.ai_conversation_message import MessageRole
    order_index = conversation.message_count
    await self.message_repo.create({
        'conversation_id': conversation_id,
        'user_id': user_id,
        'role': MessageRole.SYSTEM,
        'content': f'AI {generation_type} 生成任务已创建,使用了 {len(reference_images)} 张参考图',
        'meta_data': {
            'job_id': result['job_id'],
            'generation_type': generation_type,
            'reference_images_count': len(reference_images)
        },
        'order_index': order_index,
        'ai_job_id': result['job_id']
    })
    
    logger.info("AI 生成任务已创建: job_id=%s, reference_images=%d", 
               result['job_id'], len(reference_images))
    
    result['reference_images_count'] = len(reference_images)
    return result

前端集成

核心原则

  1. 前端职责

    • 提供 @ 自动补全,帮助用户快速输入
    • 插入标准格式的提及标记到文本
    • 高亮显示提及标记,提供视觉反馈
    • 只发送文本,不发送 mentions 数组
  2. 后端职责

    • 解析文本中的提及标记
    • 验证所有提及的资源
    • 构建 meta_data(mentions + reference_images)
    • 存储消息到数据库
  3. 数据一致性

    • 文本内容是唯一的数据源
    • 用户修改文本后,后端自动重新解析
    • 避免前端状态与文本不一致

提及标记格式

前端需要插入标准格式的提及标记:

@[显示名称](类型:元素ID:标签ID:资源ID)

示例

生成一张 @[张三-少年](character:019d1234...:019d1234...:019d1234...) 在 @[咖啡厅-白天](scene:019d1234...:019d1234...:019d1234...) 喝咖啡的图片

使用示例

// 1. 用户输入 "@" 时,获取可提及的资源
const resources = await fetch(
  `/api/v1/ai/conversations/${conversationId}/mentionable-resources`
).then(r => r.json());

// 2. 用户选择提及后,前端插入标准格式的提及标记
const content = `生成一张 @[张三-少年](character:019d1234...:019d1234...:019d1234...) 在 @[咖啡厅-白天](scene:019d1234...:019d1234...:019d1234...) 喝咖啡的图片`;

// 3. 发送消息(只发送文本)
const message = await fetch(
  `/api/v1/ai/conversations/${conversationId}/messages`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      content  // 只发送文本,不发送 mentions 数组
    })
  }
).then(r => r.json());

// 后端会自动解析提及标记,并返回 meta_data
console.log(message.data.meta_data.mentions);
console.log(message.data.meta_data.reference_images);

完整的前端实现方案

详细的前端实现方案(包含组件设计、状态管理、API 集成、键盘交互、性能优化等)请参考:

RFC 138: AI 对话 @ 提及功能前端实现方案

该文档包含:

  • 提及识别方案(如何识别 @张三在花果山 中的 张三
  • MentionInput 组件完整实现
  • MentionAutocomplete 组件完整实现
  • 状态管理(TanStack Query)
  • 键盘交互(↑↓ 选择、Enter 确认、ESC 取消)
  • 性能优化(防抖、缓存、虚拟滚动)
  • 测试策略

AI 提供商集成

参考图支持

不同的 AI 提供商对参考图的支持方式不同:

AI 提供商 参考图支持 参数名称 说明
Stable Diffusion ControlNet control_image, control_weight 支持多张参考图,可控制权重
Stable Diffusion IP-Adapter ip_adapter_image 风格参考
Midjourney --cref --cref <url> --cw <weight> 角色参考,权重 0-100
Midjourney --sref --sref <url> --sw <weight> 风格参考,权重 0-1000
DALL-E 3 不支持 - 需要在提示词中详细描述
Runway Gen-2 init_image init_image 视频首帧参考
Pika Labs image image 视频参考图

AIService 增强

# app/services/ai_service.py

async def generate_image(
    self,
    user_id: UUID,
    prompt: str,
    reference_images: Optional[List[str]] = None,  # 新增参数
    model: Optional[str] = None,
    width: int = 1024,
    height: int = 1024,
    style: Optional[str] = None
) -> Dict[str, Any]:
    """生成图片(增强版,支持参考图)
    
    Args:
        user_id: 用户 ID
        prompt: 提示词
        reference_images: 参考图 URL 列表(可选)
        model: 模型名称
        width: 宽度
        height: 高度
        style: 风格
    """
    logger.info(
        "生成图片: user_id=%s, model=%s, reference_images=%d",
        user_id, model, len(reference_images) if reference_images else 0
    )
    
    # 1. 获取模型配置
    model_config = await self._get_model(model or 'stable_diffusion', 'image')
    
    # 2. 计算积分(参考图会增加积分消耗)
    base_credits = model_config.credits_per_unit
    reference_credits = len(reference_images) * 2 if reference_images else 0
    total_credits = base_credits + reference_credits
    
    # 3. 扣除积分
    try:
        consumption_log = await self.credit_service.consume_credits(
            user_id=user_id,
            amount=total_credits,
            feature_type=1,  # FeatureType.IMAGE_GENERATION
            ai_job_id=None,
            task_params={
                'prompt': prompt,
                'model': model_config.model_name,
                'width': width,
                'height': height,
                'reference_images_count': len(reference_images) if reference_images else 0
            }
        )
    except InsufficientCreditsError:
        raise ValidationError("积分不足")
    
    # 4. 创建 AI 任务
    job = await self.job_repository.create({
        'user_id': user_id,
        'job_type': AIJobType.IMAGE,
        'status': AIJobStatus.PENDING,
        'model_id': model_config.model_id,
        'model_name': model_config.model_name,
        'consumption_log_id': consumption_log.consumption_id,
        'input_data': {
            'prompt': prompt,
            'width': width,
            'height': height,
            'style': style,
            'reference_images': reference_images  # 存储参考图 URL
        },
        'credits_used': total_credits
    })
    
    # 5. 更新 consumption_log 的 ai_job_id
    consumption_log.ai_job_id = job.ai_job_id
    await self.db.commit()
    
    # 6. 提交异步任务
    from app.tasks.ai_tasks import generate_image_task
    task = generate_image_task.delay(str(job.ai_job_id))
    
    # 7. 更新任务 ID
    await self.job_repository.update(job.ai_job_id, {
        'task_id': task.id
    })
    
    logger.info(
        "图片生成任务已创建: job_id=%s, task_id=%s, reference_images=%d",
        job.ai_job_id, task.id, len(reference_images) if reference_images else 0
    )
    
    return {
        'job_id': str(job.ai_job_id),
        'task_id': task.id,
        'status': 'pending',
        'estimated_credits': total_credits,
        'reference_images_count': len(reference_images) if reference_images else 0
    }

Celery Worker 任务增强

# app/tasks/ai_tasks.py

@celery_app.task(bind=True)
def generate_image_task(self, job_id: str):
    """图片生成任务(增强版,支持参考图)"""
    from app.services.ai_service import AIService
    from app.clients.stable_diffusion_client import StableDiffusionClient
    from app.clients.midjourney_client import MidjourneyClient
    
    # 获取任务信息
    job = ai_service.get_job(UUID(job_id))
    
    # 提取参数
    prompt = job.input_data['prompt']
    width = job.input_data.get('width', 1024)
    height = job.input_data.get('height', 1024)
    style = job.input_data.get('style')
    reference_images = job.input_data.get('reference_images', [])
    
    try:
        # 更新任务状态
        ai_service.update_job(job_id, {'status': AIJobStatus.PROCESSING})
        
        # 根据模型调用不同的 AI 提供商
        if job.model_name == 'stable_diffusion':
            # Stable Diffusion 使用 ControlNet
            sd_client = StableDiffusionClient()
            result = sd_client.generate_with_controlnet(
                prompt=prompt,
                control_images=reference_images,
                control_weight=0.8,  # 参考图权重
                width=width,
                height=height
            )
        elif job.model_name == 'midjourney':
            # Midjourney 使用 --cref 参数
            mj_client = MidjourneyClient()
            
            # 构建 --cref 参数
            cref_params = ' '.join([
                f'--cref {url} --cw 80' 
                for url in reference_images
            ])
            
            full_prompt = f'{prompt} {cref_params}'
            result = mj_client.generate(full_prompt)
        else:
            raise ValueError(f"不支持的模型: {job.model_name}")
        
        # 上传图片到对象存储
        file_url = await file_storage_service.upload_from_url(
            result['image_url'],
            f'ai-generated/images/{job_id}.png'
        )
        
        # 更新任务状态
        ai_service.update_job(job_id, {
            'status': AIJobStatus.COMPLETED,
            'output_data': {
                'file_url': file_url,
                'original_url': result['image_url'],
                'width': result['width'],
                'height': result['height']
            },
            'completed_at': datetime.now(timezone.utc)
        })
        
        # 确认积分消耗
        credit_service.confirm_consumption(
            consumption_id=job.consumption_log_id,
            resource_id=file_url
        )
        
        logger.info("图片生成成功: job_id=%s, file_url=%s", job_id, file_url)
        
    except Exception as e:
        logger.error("图片生成失败: job_id=%s, error=%s", job_id, str(e))
        
        # 更新任务状态
        ai_service.update_job(job_id, {
            'status': AIJobStatus.FAILED,
            'error_message': str(e)
        })
        
        # 退还积分
        credit_service.refund_credits(
            consumption_id=job.consumption_log_id,
            reason=str(e)
        )
        
        raise

Stable Diffusion ControlNet 集成

# app/clients/stable_diffusion_client.py

class StableDiffusionClient:
    """Stable Diffusion 客户端(支持 ControlNet)"""
    
    def __init__(self):
        self.api_url = settings.STABLE_DIFFUSION_API_URL
        self.api_key = settings.STABLE_DIFFUSION_API_KEY
    
    async def generate_with_controlnet(
        self,
        prompt: str,
        control_images: List[str],
        control_weight: float = 0.8,
        width: int = 1024,
        height: int = 1024
    ) -> Dict[str, Any]:
        """使用 ControlNet 生成图片
        
        Args:
            prompt: 提示词
            control_images: 参考图 URL 列表
            control_weight: 参考图权重(0.0-1.0)
            width: 宽度
            height: 高度
        """
        # 下载参考图到本地
        control_image_paths = []
        for url in control_images:
            response = requests.get(url)
            image = Image.open(BytesIO(response.content))
            
            # 保存到临时文件
            temp_path = f'/tmp/control_{uuid.uuid4()}.png'
            image.save(temp_path)
            control_image_paths.append(temp_path)
        
        # 调用 Stable Diffusion API
        payload = {
            'prompt': prompt,
            'width': width,
            'height': height,
            'controlnet_units': [
                {
                    'input_image': self._encode_image(path),
                    'module': 'canny',  # 使用 Canny 边缘检测
                    'model': 'control_v11p_sd15_canny',
                    'weight': control_weight
                }
                for path in control_image_paths
            ]
        }
        
        response = requests.post(
            f'{self.api_url}/sdapi/v1/txt2img',
            json=payload,
            headers={'Authorization': f'Bearer {self.api_key}'}
        )
        
        result = response.json()
        
        # 清理临时文件
        for path in control_image_paths:
            os.remove(path)
        
        return {
            'image_url': result['images'][0],
            'width': width,
            'height': height
        }
    
    def _encode_image(self, image_path: str) -> str:
        """将图片编码为 base64"""
        with open(image_path, 'rb') as f:
            return base64.b64encode(f.read()).decode('utf-8')

使用场景示例

场景1:分镜对话 - 引用角色和场景

用户操作流程

  1. 用户在"分镜001"的对话窗口中
  2. 点击输入框,输入 @
  3. 系统显示该分镜关联的所有资源:
    • 角色:张三(少年、成年)、李四
    • 场景:咖啡厅(白天、夜晚)
    • 道具:咖啡杯
  4. 用户选择 @张三,系统显示标签选择器
  5. 用户选择"少年"标签
  6. 用户继续输入 在 @
  7. 用户选择 @咖啡厅,选择"白天"标签
  8. 用户输入完整提示词:生成一张 @张三-少年 在 @咖啡厅-白天 喝咖啡的图片
  9. 点击"发送"
  10. 点击"生成"按钮
  11. 系统将"张三-少年"和"咖啡厅-白天"的图片作为参考图传递给 AI
  12. AI 生成新图片

前端代码示例

// 1. 用户输入 "@" 时,获取可提及的资源
const resources = await fetch(
  `/api/v1/ai/conversations/${conversationId}/mentionable-resources`
).then(r => r.json());

console.log(resources.data);
// {
//   conversation: { ... },
//   resources: [ ... ]
// }

// 2. 用户选择提及后,前端插入标准格式的提及标记
const content = `生成一张 @[张三-少年](character:019d1234...:019d1234...:019d1234...) 在 @[咖啡厅-白天](scene:019d1234...:019d1234...:019d1234...) 喝咖啡的图片`;

// 3. 发送消息(只发送文本)
const message = await fetch(
  `/api/v1/ai/conversations/${conversationId}/messages`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      content  // 只发送文本,不发送 mentions 数组
    })
  }
).then(r => r.json());

console.log(message.data);
// {
//   message_id: "...",
//   role: "user",
//   content: "生成一张 @[张三-少年](...) 在 @[咖啡厅-白天](...) 喝咖啡的图片",
//   meta_data: {
//     mentions: [
//       {
//         type: "character",
//         element_id: "...",
//         element_name: "张三",
//         tag_id: "...",
//         tag_label: "少年",
//         resource_id: "...",
//         resource_url: "https://storage.example.com/char/zhangsan-youth.jpg",
//         thumbnail_url: "https://storage.example.com/char/zhangsan-youth-thumb.jpg"
//       },
//       {
//         type: "scene",
//         element_id: "...",
//         element_name: "咖啡厅",
//         tag_id: "...",
//         tag_label: "白天",
//         resource_id: "...",
//         resource_url: "https://storage.example.com/scene/cafe-day.jpg",
//         thumbnail_url: "https://storage.example.com/scene/cafe-day-thumb.jpg"
//       }
//     ],
//     reference_images: [
//       "https://storage.example.com/char/zhangsan-youth.jpg",
//       "https://storage.example.com/scene/cafe-day.jpg"
//     ]
//   },
//   created_at: "2026-01-30T10:00:00Z"
// }

// 注意:后端自动解析了提及标记,并构建了 meta_data

// 3. 触发 AI 生成
const job = await fetch(
  `/api/v1/ai/conversations/${conversationId}/generate`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message_id: message.data.message_id,
      generation_type: "image",
      params: {
        prompt: "生成一张张三在咖啡厅喝咖啡的图片",
        model: "stable_diffusion",
        width: 1024,
        height: 1024
      }
    })
  }
).then(r => r.json());

console.log(job.data);
// {
//   job_id: "...",
//   task_id: "...",
//   status: "pending",
//   estimated_credits: 15,
//   reference_images_count: 2  // 使用了 2 张参考图
// }

场景2:角色对话 - 引用不同装扮

用户操作流程

  1. 用户在"角色-张三"的对话窗口中
  2. 输入 @
  3. 系统只显示"张三"的不同装扮:
    • 少年
    • 成年
    • 老年
  4. 用户选择"成年"装扮
  5. 输入:生成一张 @张三-成年 穿西装的图片
  6. 系统将"张三-成年"的图片作为参考图传递给 AI

数据查询

-- 后端查询"角色-张三"的可提及资源
SELECT 
    pr.project_resource_id,
    pr.file_url,
    pr.thumbnail_url,
    pr.element_tag_id,
    t.tag_label
FROM project_resources pr
LEFT JOIN screenplay_element_tags t ON pr.element_tag_id = t.tag_id
WHERE pr.type = 2  -- character
  AND pr.element_id = '019d1234-5678-7abc-def0-333333333333'  -- 张三的ID
  AND pr.project_id = '019d1234-5678-7abc-def0-999999999999'
ORDER BY t.display_order;

-- 结果:
-- resource_id | file_url | thumbnail_url | tag_id | tag_label
-- ------------|----------|---------------|--------|----------
-- res-001     | .../youth.jpg | .../youth-thumb.jpg | tag-001 | 少年
-- res-002     | .../adult.jpg | .../adult-thumb.jpg | tag-002 | 成年
-- res-003     | .../elder.jpg | .../elder-thumb.jpg | tag-003 | 老年

场景3:分镜对话 - 引用视频作为参考

用户操作流程

  1. 用户在"分镜001"的对话窗口中
  2. 该分镜已经生成了一个视频
  3. 用户想基于这个视频生成新的变体
  4. 输入 @,选择该视频
  5. 输入:基于 @分镜001-视频 生成一个慢动作版本
  6. 系统将视频的首帧作为参考图传递给 AI

特殊处理

# 后端处理视频提及
async def _extract_reference_from_video(self, video_url: str) -> str:
    """从视频中提取首帧作为参考图
    
    Args:
        video_url: 视频 URL
    
    Returns:
        首帧图片 URL
    """
    import cv2
    import tempfile
    
    # 1. 下载视频
    response = requests.get(video_url)
    with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as f:
        f.write(response.content)
        video_path = f.name
    
    # 2. 提取首帧
    cap = cv2.VideoCapture(video_path)
    ret, frame = cap.read()
    cap.release()
    
    if not ret:
        raise ValueError("无法提取视频首帧")
    
    # 3. 保存首帧
    frame_path = tempfile.mktemp(suffix='.jpg')
    cv2.imwrite(frame_path, frame)
    
    # 4. 上传到对象存储
    from app.services.file_storage_service import FileStorageService
    file_storage = FileStorageService()
    frame_url = await file_storage.upload_file(
        frame_path,
        f'ai-generated/video-frames/{uuid.uuid4()}.jpg'
    )
    
    # 5. 清理临时文件
    os.remove(video_path)
    os.remove(frame_path)
    
    return frame_url

场景4:搜索过滤

用户操作流程

  1. 用户在分镜对话中输入 @张
  2. 系统自动过滤,只显示名称包含"张"的资源:
    • 张三(少年、成年)
    • 张飞
  3. 用户选择"张三-少年"

API 调用

// 前端:实时搜索
const searchResources = async (conversationId, keyword) => {
  const response = await fetch(
    `/api/v1/ai/conversations/${conversationId}/mentionable-resources?search=${keyword}`
  );
  return response.json();
};

// 用户输入 "@张" 时
const results = await searchResources(conversationId, "张");
// 只返回名称包含"张"的资源

场景5:无标签资源

用户操作流程

  1. 用户在分镜对话中输入 @
  2. 选择"咖啡杯"(无标签的道具)
  3. 系统直接使用默认资源,无需选择标签

数据结构

{
  "type": "prop",
  "element_id": "019d1234-5678-7abc-def0-dddddddddddd",
  "element_name": "咖啡杯",
  "has_tags": false,
  "default_resource": {
    "resource_id": "019d1234-5678-7abc-def0-eeeeeeeeeeee",
    "resource_url": "https://storage.example.com/prop/coffee-cup.jpg",
    "thumbnail_url": "https://storage.example.com/prop/coffee-cup-thumb.jpg",
    "width": 512,
    "height": 512
  }
}

场景6:多个参考图组合

用户操作流程

  1. 用户想生成一个复杂场景
  2. 输入:生成一张 @张三-少年 和 @李四-成年 在 @咖啡厅-白天 喝 @咖啡 的图片
  3. 系统使用 4 张参考图:
    • 张三-少年的形象
    • 李四-成年的形象
    • 咖啡厅-白天的场景
    • 咖啡杯的道具
  4. AI 根据 4 张参考图生成新图片

积分计算

# 基础积分:10
# 参考图积分:4 × 2 = 8
# 总积分:18

base_credits = 10
reference_credits = len(reference_images) * 2  # 每张参考图 2 积分
total_credits = base_credits + reference_credits  # 18

设计决策说明

为什么采用后端解析方案?

问题场景

用户在输入框中输入 @张三-少年 在咖啡厅,然后修改为 @李四-成年 在咖啡厅。如果前端维护 mentions[] 数组,容易出现以下问题:

  1. 状态不一致:用户手动删除文本中的 @张三,但前端忘记更新 mentions[]
  2. 复杂的同步逻辑:前端需要监听文本变化,实时更新 mentions[]
  3. 容错性差:前端状态错误时,后端收到错误的数据

解决方案对比

方案 前端维护 mentions 后端解析 mentions(采用)
数据一致性 容易不一致 始终一致(文本是唯一数据源)
前端复杂度 高(需要复杂的状态同步) 低(只需插入标准格式)
后端复杂度 ⚠️ 中等(需要解析和验证)
用户体验 实时反馈 实时反馈(前端本地解析显示)
容错性 强(后端重新解析)
可维护性

最终方案:混合方案

前端职责

  • 提供 @ 自动补全
  • 插入标准格式的提及标记:@[显示名称](类型:元素ID:标签ID:资源ID)
  • 本地解析文本,高亮显示提及标记(仅用于 UI)
  • 只发送文本,不发送 mentions[]

后端职责

  • 解析文本中的提及标记
  • 验证所有提及的资源(权限、存在性、关联性)
  • 构建 meta_datamentions + reference_images
  • 存储消息到数据库

核心优势

  1. 单一数据源:文本内容是唯一的真实来源
  2. 自动同步:用户修改文本,后端自动解析最新的提及
  3. 容错性强:即使前端状态错误,后端也能正确解析
  4. 简化前端:前端只需要处理文本输入和显示

相关文档


附录

前端库推荐

  • @tiptap/react:富文本编辑器,支持 @ 提及
  • react-mentions:轻量级提及组件
  • draft-js:Facebook 的富文本编辑器框架
  • slate:可定制的富文本编辑器框架

性能优化建议

  1. 资源列表缓存

    • 首次加载时缓存可提及的资源列表
    • 使用 React Query 的 staleTime 配置
  2. 图片懒加载

    • 自动补全下拉菜单中的缩略图使用懒加载
    • 使用 loading="lazy" 属性
  3. 搜索防抖

    • 用户输入搜索关键词时,使用 300ms 防抖
    • 避免频繁请求后端
  4. 虚拟滚动

    • 如果资源列表很长,使用虚拟滚动
    • 推荐使用 react-windowreact-virtualized

安全注意事项

  1. 权限验证

    • 后端必须验证用户是否有权限访问提及的资源
    • 防止用户引用其他项目的资源
  2. XSS 防护

    • 前端显示提及标签时,使用 dangerouslySetInnerHTML 需谨慎
    • 推荐使用 React 的默认转义
  3. 参考图验证

    • 后端必须验证参考图 URL 是否属于当前项目
    • 防止用户提交恶意 URL

文档版本:v2.0
最后更新:2026-01-30
变更说明:采用后端解析提及标记方案,确保数据一致性
作者:Jointo AI Team