From 8af37e5e8e3725640b0bd7c6b87e8be476b67266 Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 20 Mar 2026 16:36:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9ACI=20=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E7=8E=AF=E5=A2=83=E4=B8=8E=20ref=20=E9=94=99=E9=85=8D?= =?UTF-8?q?=E3=80=81=E8=BF=81=E7=A7=BB=E7=A2=8E=E7=89=87=E5=8C=96=E3=80=81?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=84=8F=E5=9B=BE=20source=5Fspan=E3=80=81?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E7=89=A9=E5=8C=96=E8=84=8F=E7=89=88=E5=BC=8F?= =?UTF-8?q?=E3=80=81=E4=BC=9A=E8=AF=9D=E5=8E=86=E5=8F=B2=E4=B8=8E=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E8=AF=AD=E9=9F=B3=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增:TTS 上传 COS 与分片、章节 reading_segments 物化与快照、markdown 清洗、会话消息 repository、语音 store 重构与相关测试 --- .github/workflows/android-release.yml | 2 +- .github/workflows/app-expo-deploy.yml | 17 +- .github/workflows/app-expo-quality.yml | 132 ------------ .github/workflows/docker-build-deploy.yml | 38 +++- api/.env.production | 4 +- api/alembic/versions/0001_initial_schema.py | 8 +- .../versions/0002_drop_chapter_sections.py | 29 --- api/app/agents/chat/conversation_agent.py | 4 + api/app/agents/chat/helpers.py | 2 + api/app/agents/chat/orchestrator.py | 16 ++ api/app/agents/memoir/prompts.py | 3 + api/app/core/cos_url_keys.py | 34 ++++ api/app/core/redis.py | 42 ++++ api/app/features/conversation/models.py | 13 +- api/app/features/conversation/service.py | 44 +++- .../features/conversation/session_history.py | 21 +- api/app/features/conversation/ws/pipeline.py | 75 ++++++- api/app/features/conversation/ws/router.py | 5 + api/app/features/memoir/asset_resolver.py | 5 + .../memoir/chapter_markdown_compose.py | 64 ++++-- api/app/features/memoir/cover_eligibility.py | 24 ++- api/app/features/memoir/helpers.py | 69 ++++++- api/app/features/memoir/markdown_sanitize.py | 63 ++++++ api/app/features/memoir/models.py | 2 + api/app/features/memoir/pdf_service.py | 17 +- .../memoir/reading_segment_materialize.py | 188 +++++++++++++++++ api/app/features/memoir/repo.py | 22 +- api/app/features/memoir/service.py | 4 +- api/app/features/story/backfill.py | 37 ++-- .../features/story/image_intent_extractor.py | 19 +- api/app/features/story/models.py | 1 - api/app/features/story/repo.py | 2 - api/app/features/story/service.py | 3 - api/app/features/story/sync_write.py | 3 - api/app/features/user/repo.py | 17 ++ api/app/tasks/chapter_cover_enqueue.py | 16 +- api/app/tasks/chapter_cover_tasks.py | 10 + api/app/tasks/story_image_tasks.py | 6 +- api/tests/test_chapter_cover_enqueue.py | 22 +- api/tests/test_chapter_markdown_compose.py | 26 ++- api/tests/test_chapters_router_images.py | 7 +- .../test_conversation_messages_history.py | 40 +++- api/tests/test_cover_eligibility.py | 49 +++++ api/tests/test_markdown_sanitize.py | 27 +++ api/tests/test_reading_segments_dedupe.py | 89 +++++++++ api/tests/test_session_history.py | 13 ++ api/tests/test_story_backfill.py | 20 ++ api/tests/test_story_image_tasks.py | 3 +- api/tests/test_tts_cos_keys.py | 39 ++++ app-expo/app.config.ts | 2 + app-expo/src/app/(main)/chapter/[id].tsx | 184 ++++++++++++++++- app-expo/src/app/(main)/conversation/[id].tsx | 189 +++++++++--------- app-expo/src/core/ws/client.ts | 3 + app-expo/src/core/ws/types.ts | 3 + .../conversation-messages-repository.ts | 44 ++++ .../features/conversation/event-handlers.ts | 33 ++- app-expo/src/features/conversation/hooks.ts | 31 ++- .../features/conversation/realtime-session.ts | 22 +- app-expo/src/features/conversation/types.ts | 13 +- .../src/features/memoir/markdown-renderer.tsx | 105 ++++++++-- app-expo/src/features/memoir/types.ts | 9 + .../src/features/voice/hooks/use-player.ts | 65 ++++-- app-expo/src/features/voice/types.ts | 2 +- ...gment-outbox.ts => voice-segment-store.ts} | 98 +++++++-- ...image-intent-placeholder-removal-design.md | 9 +- 65 files changed, 1704 insertions(+), 504 deletions(-) delete mode 100644 .github/workflows/app-expo-quality.yml delete mode 100644 api/alembic/versions/0002_drop_chapter_sections.py create mode 100644 api/app/features/memoir/markdown_sanitize.py create mode 100644 api/app/features/memoir/reading_segment_materialize.py create mode 100644 api/tests/test_cover_eligibility.py create mode 100644 api/tests/test_markdown_sanitize.py create mode 100644 api/tests/test_reading_segments_dedupe.py create mode 100644 api/tests/test_story_backfill.py create mode 100644 api/tests/test_tts_cos_keys.py create mode 100644 app-expo/src/features/conversation/conversation-messages-repository.ts rename app-expo/src/features/voice/{segment-outbox.ts => voice-segment-store.ts} (54%) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 84f5456..3e05eb6 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -3,7 +3,7 @@ name: Android Release Build on: push: tags: - - 'v*' # 推送 v1.0.0 等标签时自动触发 + - 'v*.*.*' # SemVer tag(如 v1.0.0)时自动触发 workflow_dispatch: # 支持手动触发 inputs: version_name: diff --git a/.github/workflows/app-expo-deploy.yml b/.github/workflows/app-expo-deploy.yml index 521a081..10cc867 100644 --- a/.github/workflows/app-expo-deploy.yml +++ b/.github/workflows/app-expo-deploy.yml @@ -2,16 +2,15 @@ # # 环境映射(按触发源自动推断): # main → dev (开发 + 内部测试) -# staging → stage (预发布) # v*.*.* → prod (正式发布) # -# 手动触发:workflow_dispatch 可选择环境 +# 手动触发:workflow_dispatch 可选择 dev / stage / prod name: App Expo Deploy on: push: - branches: [main, staging] + branches: [main] tags: ['v*.*.*'] paths: - "app-expo/**" @@ -41,18 +40,12 @@ env: jobs: deploy: - name: Build & Deploy + name: "Build & Deploy" runs-on: ubuntu-latest permissions: contents: read # GitHub Environments: 在 Repo Settings → Environments 中创建 dev/staging/production,可配置独立 secrets - environment: ${{ - (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'prod') && 'production' || - (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'stage') && 'staging' || - startsWith(github.ref, 'refs/tags/v') && 'production' || - github.ref == 'refs/heads/staging' && 'staging' || - 'dev' - }} + environment: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'prod') && 'production' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'stage') && 'staging' || startsWith(github.ref, 'refs/tags/v') && 'production' || 'dev' }} steps: - name: Checkout code @@ -65,8 +58,6 @@ jobs: run: | if [[ "${{ github.ref }}" == refs/tags/v* ]]; then echo "env=prod" - elif [[ "${{ github.ref }}" == refs/heads/staging ]]; then - echo "env=stage" elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then echo "env=${{ github.event.inputs.environment }}" else diff --git a/.github/workflows/app-expo-quality.yml b/.github/workflows/app-expo-quality.yml deleted file mode 100644 index 8381163..0000000 --- a/.github/workflows/app-expo-quality.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: App Expo Quality - -on: - pull_request: - paths: - - "app-expo/**" - - ".github/workflows/app-expo-quality.yml" - push: - branches: - - main - - master - - develop - paths: - - "app-expo/**" - - ".github/workflows/app-expo-quality.yml" - workflow_dispatch: - -concurrency: - group: app-expo-quality-${{ github.ref }} - cancel-in-progress: true - -jobs: - verify: - name: Verify app-expo - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - defaults: - run: - working-directory: app-expo - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: app-expo/package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Check formatting - run: npm run format:check - - - name: Run linter - run: npm run lint - - - name: Run Jest in CI mode - run: npm run test:ci - - - name: Build coverage summary - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - id: coverage_summary - run: | - node <<'EOF' - const fs = require('fs'); - const summaryPath = 'coverage/jest/coverage-summary.json'; - const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')).total; - const metrics = [ - ['Lines', summary.lines], - ['Statements', summary.statements], - ['Functions', summary.functions], - ['Branches', summary.branches], - ]; - - const body = [ - '## app-expo Jest Coverage', - '', - '| Metric | Percent | Covered / Total |', - '| --- | ---: | ---: |', - ...metrics.map( - ([name, metric]) => - `| ${name} | ${metric.pct}% | ${metric.covered}/${metric.total} |` - ), - '', - '_Generated by App Expo Quality._', - ].join('\n'); - - fs.appendFileSync(process.env.GITHUB_OUTPUT, `body< - comment.user?.login === 'github-actions[bot]' && - comment.body?.includes(marker) - ); - - if (existing) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body, - }); - } - - - name: Upload Jest coverage - if: always() - uses: actions/upload-artifact@v4 - with: - name: app-expo-jest-coverage - path: app-expo/coverage/jest - retention-days: 14 diff --git a/.github/workflows/docker-build-deploy.yml b/.github/workflows/docker-build-deploy.yml index 13b3659..17b296b 100644 --- a/.github/workflows/docker-build-deploy.yml +++ b/.github/workflows/docker-build-deploy.yml @@ -1,20 +1,36 @@ +# API Docker:main → Dev(GitHub Environment: dev),Tag v*.*.* → Production(environment: production) +# 在 Repo Settings → Environments 中为 dev / production 分别配置 SSH、DEPLOY_PATH、迁移 DB 等 Secrets。 +# +# 发布策略: +# - merge / push 到 main:构建并部署到 Dev / 内部测试 +# - 手动创建并推送 tag vMAJOR.MINOR.PATCH:构建并部署到 Production +# +# 注意:paths 过滤在 tag push 时按「被指向的 commit」判断;若该 commit 未改 api/ 与本 workflow,不会触发。 +# 此时可用 workflow_dispatch 选择对应 tag/ref 手动部署。 + name: Docker Build and Deploy on: push: branches: - - dev/add-agent + - main + tags: + - 'v*.*.*' paths: - 'api/**' - '.github/workflows/**' workflow_dispatch: inputs: branch: - description: '部署分支' + description: '部署 ref(分支名或 tag,如 main / v1.0.0);留空则使用当前运行所选 ref' required: false type: string default: '' +concurrency: + group: docker-api-${{ github.ref }} + cancel-in-progress: false + env: IMAGE_NAME: lifecho-api REGISTRY: crpi-u2903xccyzd6nqnc.cn-shanghai.personal.cr.aliyuncs.com @@ -81,6 +97,9 @@ jobs: runs-on: ubuntu-latest needs: build-and-push if: github.event_name != 'pull_request' + # workflow_dispatch 下若填写了 branch 输入,以输入为准选择 environment(避免仅 UI 选了 tag 但部署 main 时误用 production) + environment: + name: ${{ ((github.event_name == 'workflow_dispatch' && github.event.inputs.branch != '' && startsWith(github.event.inputs.branch, 'v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.branch == '' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))) && 'production' || 'dev' }} steps: - name: Checkout code @@ -101,12 +120,19 @@ jobs: - name: Determine image tag id: image_tag run: | - DEPLOY_BRANCH="${{ github.event.inputs.branch || github.ref_name }}" - echo "deploy_branch=$DEPLOY_BRANCH" >> "$GITHUB_OUTPUT" - if [ "$DEPLOY_BRANCH" == "main" ] || [ "$DEPLOY_BRANCH" == "master" ]; then + # 与 docker/metadata-action 的 semver 标签一致:v1.2.3 → 镜像 :1.2.3 + if [ -n "${{ github.event.inputs.branch }}" ]; then + REF_NAME="${{ github.event.inputs.branch }}" + else + REF_NAME="${{ github.ref_name }}" + fi + echo "deploy_ref=$REF_NAME" >> "$GITHUB_OUTPUT" + if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "tag=${REF_NAME#v}" >> "$GITHUB_OUTPUT" + elif [ "$REF_NAME" == "main" ] || [ "$REF_NAME" == "master" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT" else - BRANCH_TAG=$(echo "$DEPLOY_BRANCH" | sed 's/\//-/g') + BRANCH_TAG=$(echo "$REF_NAME" | sed 's/\//-/g') echo "tag=$BRANCH_TAG" >> "$GITHUB_OUTPUT" fi diff --git a/api/.env.production b/api/.env.production index d7da384..2ab878a 100644 --- a/api/.env.production +++ b/api/.env.production @@ -125,5 +125,5 @@ TENCENT_COS_SECRET_ID=AKIDa2ILCwUr56uVt31oU0JOHxPfGhvvkLiq TENCENT_COS_SECRET_KEY=xiFbjlZ9XheS2NWYLvHRPAh2A5nGYcR2 TENCENT_COS_REGION=ap-shanghai # 要把bucket改成生产环境的bucket -TENCENT_COS_BUCKET=life-echo-dev-1319381411 -TENCENT_COS_BASE_URL=https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com \ No newline at end of file +TENCENT_COS_BUCKET=life-echo-prod-1319381411 +TENCENT_COS_BASE_URL=https://life-echo-prod-1319381411.cos.ap-shanghai.myqcloud.com \ No newline at end of file diff --git a/api/alembic/versions/0001_initial_schema.py b/api/alembic/versions/0001_initial_schema.py index f416843..82fc6dd 100644 --- a/api/alembic/versions/0001_initial_schema.py +++ b/api/alembic/versions/0001_initial_schema.py @@ -2,7 +2,13 @@ 单一迁移:pgvector + 当前全部 ORM 表(含 conversations.deleted_at 软删除);并补充 models 未声明的 story_image_intents.asset_id → assets 外键,以及每个 story 仅一条 primary intent 的唯一索引。 -chapters 含 story 物化字段:markdown_compose_dirty、markdown_composed_at(随 ORM 一并 create_all)。 +chapters 含 story 物化字段:markdown_compose_dirty、markdown_composed_at、reading_segments_json +(阅读片段快照,随 ORM 一并 create_all)。 + +已并入原 0002(stories-first:无 chapter_sections / memoir_images.section_id)与原 0003(segments.tts_audio_urls) +的语义:新库仅由当前 ORM 建表即可,无需后续 ALTER。 +segments.audio_duration_seconds(语音条时长秒数,历史 API / Redis 回填)由 ORM 一并 create_all,无独立迁移。 +story_image_intents 无 source_span(主图回填在正文末尾,意图仅存 caption / prompt_brief 等)。 新库 / 删库重来:`alembic upgrade head`。 diff --git a/api/alembic/versions/0002_drop_chapter_sections.py b/api/alembic/versions/0002_drop_chapter_sections.py deleted file mode 100644 index 31bcb6f..0000000 --- a/api/alembic/versions/0002_drop_chapter_sections.py +++ /dev/null @@ -1,29 +0,0 @@ -"""drop chapter_sections + memoir_images.section_id (stories-first) - -Revision ID: 0002_drop_chapter_sections -Revises: 0001_initial -""" - -from typing import Sequence, Union - -from alembic import op - -revision: str = "0002_drop_chapter_sections" -down_revision: Union[str, None] = "0001_initial" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # memoir_images.section_id -> chapter_sections 必须先解除,再删表 - op.execute( - "ALTER TABLE memoir_images DROP CONSTRAINT IF EXISTS memoir_images_section_id_fkey" - ) - op.execute("ALTER TABLE memoir_images DROP COLUMN IF EXISTS section_id") - op.execute("DROP TABLE IF EXISTS chapter_sections CASCADE") - - -def downgrade() -> None: - raise NotImplementedError( - "0002 为破坏性迁移:不恢复 chapter_sections;请从备份还原数据库。" - ) diff --git a/api/app/agents/chat/conversation_agent.py b/api/app/agents/chat/conversation_agent.py index dbf3d67..6526e7d 100644 --- a/api/app/agents/chat/conversation_agent.py +++ b/api/app/agents/chat/conversation_agent.py @@ -39,6 +39,7 @@ class ConversationAgent: is_from_voice: bool = False, voice_session_id: str | None = None, user_message_timestamp: datetime | None = None, + audio_duration_seconds: int | None = None, ) -> List[str]: """委托 ChatOrchestrator/ProfileAgent 生成资料追问""" return await self._orchestrator.generate_profile_followup( @@ -50,6 +51,7 @@ class ConversationAgent: is_from_voice=is_from_voice, voice_session_id=voice_session_id, user_message_timestamp=user_message_timestamp, + audio_duration_seconds=audio_duration_seconds, ) async def generate_profile_greeting( @@ -74,6 +76,7 @@ class ConversationAgent: is_from_voice: bool = False, voice_session_id: str | None = None, user_message_timestamp: datetime | None = None, + audio_duration_seconds: int | None = None, ) -> List[str]: """委托 ChatOrchestrator/InterviewAgent 生成访谈回复""" return await self._orchestrator.generate_response_with_state( @@ -84,6 +87,7 @@ class ConversationAgent: is_from_voice=is_from_voice, voice_session_id=voice_session_id, user_message_timestamp=user_message_timestamp, + audio_duration_seconds=audio_duration_seconds, ) async def generate_opening_message( diff --git a/api/app/agents/chat/helpers.py b/api/app/agents/chat/helpers.py index ec6f6d9..a7d6fa5 100644 --- a/api/app/agents/chat/helpers.py +++ b/api/app/agents/chat/helpers.py @@ -38,6 +38,7 @@ async def save_message( message_type: str = "text", voice_session_id: str | None = None, timestamp: datetime | str | int | None = None, + audio_duration_seconds: int | None = None, ) -> None: """保存消息到 Redis""" await redis_service.add_message( @@ -49,4 +50,5 @@ async def save_message( timestamp=timestamp.isoformat() if isinstance(timestamp, datetime) else timestamp, + audio_duration_seconds=audio_duration_seconds, ) diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py index e0830ad..b57aa58 100644 --- a/api/app/agents/chat/orchestrator.py +++ b/api/app/agents/chat/orchestrator.py @@ -44,6 +44,7 @@ class ChatOrchestrator: get_missing_profile_fields_fn, get_filled_profile_fields_fn, user_message_timestamp: Optional[datetime] = None, + audio_duration_seconds: Optional[int] = None, ) -> List[str]: """ 处理用户消息,返回 AI 回复列表。 @@ -78,6 +79,7 @@ class ChatOrchestrator: is_from_voice=is_from_voice, voice_session_id=voice_session_id, user_message_timestamp=user_message_timestamp, + audio_duration_seconds=audio_duration_seconds, ) return responses except Exception as e: @@ -117,6 +119,7 @@ class ChatOrchestrator: is_from_voice=is_from_voice, voice_session_id=voice_session_id, user_message_timestamp=user_message_timestamp, + audio_duration_seconds=audio_duration_seconds, ) return responses @@ -128,9 +131,17 @@ class ChatOrchestrator: is_from_voice: bool = False, voice_session_id: Optional[str] = None, user_message_timestamp: Optional[datetime] = None, + audio_duration_seconds: Optional[int] = None, ) -> None: """统一写入 Human + AI 消息到 Redis""" human_msg_type = "audio" if is_from_voice else "text" + human_duration = ( + audio_duration_seconds + if is_from_voice + and audio_duration_seconds is not None + and audio_duration_seconds > 0 + else None + ) await save_message( conversation_id, "human", @@ -138,6 +149,7 @@ class ChatOrchestrator: message_type=human_msg_type, voice_session_id=voice_session_id, timestamp=user_message_timestamp, + audio_duration_seconds=human_duration, ) await save_message(conversation_id, "ai", response_text) @@ -162,6 +174,7 @@ class ChatOrchestrator: is_from_voice: bool = False, voice_session_id: str | None = None, user_message_timestamp: datetime | None = None, + audio_duration_seconds: int | None = None, ) -> List[str]: """委托 ProfileAgent 生成资料追问,并写入 Redis""" responses = await self.profile_agent.generate_profile_followup( @@ -179,6 +192,7 @@ class ChatOrchestrator: is_from_voice=is_from_voice, voice_session_id=voice_session_id, user_message_timestamp=user_message_timestamp, + audio_duration_seconds=audio_duration_seconds, ) return responses @@ -207,6 +221,7 @@ class ChatOrchestrator: is_from_voice: bool = False, voice_session_id: str | None = None, user_message_timestamp: datetime | None = None, + audio_duration_seconds: int | None = None, ) -> List[str]: """委托 InterviewAgent 生成访谈回复,并写入 Redis""" responses = await self.interview_agent.generate_response_with_state( @@ -223,6 +238,7 @@ class ChatOrchestrator: is_from_voice=is_from_voice, voice_session_id=voice_session_id, user_message_timestamp=user_message_timestamp, + audio_duration_seconds=audio_duration_seconds, ) return responses diff --git a/api/app/agents/memoir/prompts.py b/api/app/agents/memoir/prompts.py index 25fb6ea..ee05e92 100644 --- a/api/app/agents/memoir/prompts.py +++ b/api/app/agents/memoir/prompts.py @@ -342,6 +342,7 @@ def get_narrative_prompt( 6. 如果有用户的基本信息(出生地、成长地等),在叙述中自然融入地域文化和时代背景 8. **不要将对话中的交互性语言(如"我跟你说"、"你知道吗")写入叙述** 9. **不要在正文中插入章节标题或分类标签**(如"章节:信念与价值观"、"## 童年与成长背景"等),章节标题由系统单独管理 +10. **不要使用 Markdown 表格**(不要用 `|` 管道表格);故事标题由系统单独管理,**不要用 `#`、`##` 在正文里写故事标题** 只输出新对话内容的改写结果。如果对话中没有值得记录的人生经历内容,输出空字符串。 """ @@ -387,6 +388,8 @@ def get_narrative_json_prompt( 3. 只输出新内容的改写,不要重复已有内容 4. 每 200-300 字左右一个段落 5. 如有衔接上下文,确保新内容与之自然衔接 +6. **不要使用 Markdown 表格**(不要用 `|` 管道表格) +7. **不要用 `#`、`##` 写故事或章节标题**;标题由系统管理 ## 输出格式(严格 JSON) {{ diff --git a/api/app/core/cos_url_keys.py b/api/app/core/cos_url_keys.py index dc4e412..6cc33e7 100644 --- a/api/app/core/cos_url_keys.py +++ b/api/app/core/cos_url_keys.py @@ -1,5 +1,8 @@ """从 URL 解析当前环境腾讯云 COS object key(仅当 host 与配置一致时)。""" +from __future__ import annotations + +from typing import Any from urllib.parse import urlparse from app.core.config import settings @@ -41,3 +44,34 @@ def extract_cos_object_key_if_owned(url: str | None) -> str | None: key = (parsed.path or "").lstrip("/") return key or None + + +def collect_cos_keys_from_conversation_history( + history: list[dict[str, Any]], +) -> set[str]: + """从 Redis 会话历史中收集 AI 消息附带的 TTS 音频 COS object key。""" + keys: set[str] = set() + for msg in history: + if msg.get("role") != "ai": + continue + raw = msg.get("ttsAudioUrls") + if not isinstance(raw, list): + continue + for u in raw: + if isinstance(u, str): + k = extract_cos_object_key_if_owned(u) + if k: + keys.add(k) + return keys + + +def collect_cos_keys_from_tts_url_list(urls: list[str] | None) -> set[str]: + if not urls: + return set() + keys: set[str] = set() + for u in urls: + if isinstance(u, str): + k = extract_cos_object_key_if_owned(u) + if k: + keys.add(k) + return keys diff --git a/api/app/core/redis.py b/api/app/core/redis.py index 1aa9a77..6a653ca 100644 --- a/api/app/core/redis.py +++ b/api/app/core/redis.py @@ -85,6 +85,7 @@ class RedisService: message_type: str = "text", voice_session_id: str | None = None, timestamp: str | int | None = None, + audio_duration_seconds: int | None = None, ) -> bool: try: client = await self.get_client() @@ -98,6 +99,12 @@ class RedisService: } if voice_session_id: item["voiceSessionId"] = voice_session_id + if ( + audio_duration_seconds is not None + and audio_duration_seconds > 0 + and message_type == "audio" + ): + item["durationSeconds"] = int(audio_duration_seconds) history.append(item) await client.setex( key, self.session_ttl, json.dumps(history, ensure_ascii=False) @@ -107,6 +114,41 @@ class RedisService: logger.error("添加消息失败: %s", e) return False + async def append_tts_audio_url_to_last_ai_message( + self, conversation_id: str, url: str + ) -> bool: + """向最近一条 AI 消息的 ttsAudioUrls 追加 COS 公开 URL。""" + if not url: + return False + try: + client = await self.get_client() + key = self._conversation_key(conversation_id) + history = await self.get_conversation_history(conversation_id) + for i in range(len(history) - 1, -1, -1): + if history[i].get("role") == "ai": + existing = history[i].get("ttsAudioUrls") + urls: List[str] = ( + [x for x in existing if isinstance(x, str)] + if isinstance(existing, list) + else [] + ) + urls.append(url) + history[i]["ttsAudioUrls"] = urls + break + else: + logger.warning( + "append_tts_audio_url: no ai message in history conversation_id=%s", + conversation_id, + ) + return False + await client.setex( + key, self.session_ttl, json.dumps(history, ensure_ascii=False) + ) + return True + except Exception as e: + logger.error("append_tts_audio_url 失败: %s", e) + return False + async def clear_conversation_history(self, conversation_id: str) -> bool: try: client = await self.get_client() diff --git a/api/app/features/conversation/models.py b/api/app/features/conversation/models.py index 932072a..e961356 100644 --- a/api/app/features/conversation/models.py +++ b/api/app/features/conversation/models.py @@ -1,4 +1,13 @@ -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + Text, +) from sqlalchemy.orm import relationship from app.core.db import Base, utc_now @@ -32,9 +41,11 @@ class Segment(Base): conversation_id = Column(String, ForeignKey("conversations.id"), nullable=False) audio_url = Column(String, nullable=True) transcript_text = Column(Text, nullable=False) + audio_duration_seconds = Column(Integer, nullable=True) created_at = Column(DateTime(timezone=True), default=utc_now) processed = Column(Boolean, default=False) topic_category = Column(String, nullable=True) agent_response = Column(Text, nullable=True) + tts_audio_urls = Column(JSON, nullable=True) conversation = relationship("Conversation", back_populates="segments") diff --git a/api/app/features/conversation/service.py b/api/app/features/conversation/service.py index 6e6c9c2..c08af9f 100644 --- a/api/app/features/conversation/service.py +++ b/api/app/features/conversation/service.py @@ -6,7 +6,11 @@ from datetime import datetime, timezone from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from app.core.cos_url_keys import extract_cos_object_key_if_owned +from app.core.cos_url_keys import ( + collect_cos_keys_from_conversation_history, + collect_cos_keys_from_tts_url_list, + extract_cos_object_key_if_owned, +) from app.core.logging import get_logger from app.core.redis import redis_service from app.core.storage_purge import delete_object_storage_keys_best_effort @@ -67,16 +71,24 @@ def _build_messages_from_history( if voice_session_id in seen_audio_sessions: continue seen_audio_sessions.add(voice_session_id) - messages.append( - { - "id": f"{conversation_id}_msg_{idx}", - "conversationId": conversation_id, - "content": msg.get("content", ""), - "senderType": "user" if role == "human" else "assistant", - "timestamp": _message_timestamp_ms(msg, fallback_timestamp), - "messageType": message_type, - } - ) + item: dict = { + "id": f"{conversation_id}_msg_{idx}", + "conversationId": conversation_id, + "content": msg.get("content", ""), + "senderType": "user" if role == "human" else "assistant", + "timestamp": _message_timestamp_ms(msg, fallback_timestamp), + "messageType": message_type, + } + if voice_session_id and role == "human": + item["voiceSessionId"] = voice_session_id + ds = msg.get("durationSeconds") + if isinstance(ds, (int, float)) and ds > 0: + item["durationSeconds"] = int(ds) + if role == "ai": + tts = msg.get("ttsAudioUrls") + if isinstance(tts, list) and tts: + item["ttsAudioUrls"] = [x for x in tts if isinstance(x, str)] + messages.append(item) return messages @@ -202,11 +214,21 @@ class ConversationService: self._db, conversation_id ) ) + try: + hist = await redis_service.get_conversation_history(conversation_id) + cos_keys |= collect_cos_keys_from_conversation_history(hist) + except Exception: + pass segments = await repo.get_segments_for_conversation(conversation_id, self._db) for seg in segments: k = extract_cos_object_key_if_owned(seg.audio_url) if k: cos_keys.add(k) + raw_tts = getattr(seg, "tts_audio_urls", None) + if isinstance(raw_tts, list): + cos_keys |= collect_cos_keys_from_tts_url_list( + [str(x) for x in raw_tts if isinstance(x, str)] + ) await self._clear_history(conversation_id) conv.deleted_at = datetime.now(timezone.utc) diff --git a/api/app/features/conversation/session_history.py b/api/app/features/conversation/session_history.py index 7606b80..f0e533c 100644 --- a/api/app/features/conversation/session_history.py +++ b/api/app/features/conversation/session_history.py @@ -50,14 +50,19 @@ def segments_to_redis_history(segments: List[Segment]) -> List[Dict[str, Any]]: vsid = _voice_session_id_from_audio_url(seg.audio_url) if vsid: human["voiceSessionId"] = vsid + ads = getattr(seg, "audio_duration_seconds", None) + if ads is not None and ads > 0: + human["durationSeconds"] = int(ads) history.append(human) if seg.agent_response and seg.agent_response.strip(): - history.append( - { - "role": "ai", - "content": seg.agent_response.strip(), - "messageType": "text", - "timestamp": ts, - } - ) + ai_item: Dict[str, Any] = { + "role": "ai", + "content": seg.agent_response.strip(), + "messageType": "text", + "timestamp": ts, + } + tts = getattr(seg, "tts_audio_urls", None) + if isinstance(tts, list) and tts: + ai_item["ttsAudioUrls"] = [u for u in tts if isinstance(u, str)] + history.append(ai_item) return history diff --git a/api/app/features/conversation/ws/pipeline.py b/api/app/features/conversation/ws/pipeline.py index 62a98f1..96f3ba2 100644 --- a/api/app/features/conversation/ws/pipeline.py +++ b/api/app/features/conversation/ws/pipeline.py @@ -12,7 +12,7 @@ from app.core.logging import get_logger if TYPE_CHECKING: from app.features.quota.service import QuotaService -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.agents import ConversationAgent, MemoryAgent @@ -20,7 +20,8 @@ from app.agents.chat import ChatOrchestrator from app.agents.memoir import BackgroundTaskRunner from app.core.config import settings from app.core.db import AsyncSessionLocal -from app.core.dependencies import get_asr_provider, get_tts_provider +from app.core.dependencies import get_asr_provider, get_object_storage, get_tts_provider +from app.core.redis import redis_service from app.features.conversation.models import Conversation, Segment from app.features.conversation.ws.connection_manager import manager from app.features.conversation.ws.message_types import ( @@ -37,10 +38,32 @@ from app.features.user.models import User logger = get_logger(__name__) -async def _send_tts_audio(conversation_id: str, text: str) -> None: - """Synthesize text to speech and send TTS_AUDIO if successful.""" +def _tts_object_ext(codec: str) -> str: + c = (codec or "mp3").lower().lstrip(".") + if c in ("wave",): + return "wav" + return c if c else "mp3" + + +def _tts_codec_to_content_type(codec: str) -> str: + c = (codec or "mp3").lower().lstrip(".") + if c == "mp3": + return "audio/mpeg" + if c in ("wav", "wave"): + return "audio/wav" + return "application/octet-stream" + + +async def _send_tts_audio( + conversation_id: str, + text: str, + *, + chunk_index: int, + chunk_total: int, +) -> str | None: + """Synthesize TTS, upload to COS, append Redis, send TTS_AUDIO. Returns public URL or None.""" if not settings.enable_tts: - return + return None try: tts = get_tts_provider() audio_bytes = await tts.synthesize(text) @@ -48,7 +71,15 @@ async def _send_tts_audio(conversation_id: str, text: str) -> None: logger.warning( "TTS skipped: synthesize returned empty. Check TTS config in .env" ) - return + return None + ext = _tts_object_ext(settings.tts_codec) + content_type = _tts_codec_to_content_type(settings.tts_codec) + storage = get_object_storage() + key = f"conversations/{conversation_id}/tts/{uuid.uuid4().hex}.{ext}" + public_url = storage.upload(key, audio_bytes, content_type) + await redis_service.append_tts_audio_url_to_last_ai_message( + conversation_id, public_url + ) await manager.send_message( conversation_id, { @@ -57,10 +88,14 @@ async def _send_tts_audio(conversation_id: str, text: str) -> None: "data": { "audio_base64": base64.b64encode(audio_bytes).decode("utf-8"), "format": settings.tts_codec, + "audio_url": public_url, + "index": chunk_index, + "total": chunk_total, }, "timestamp": datetime.now(timezone.utc).isoformat(), }, ) + return public_url except Exception as e: err_str = str(e) if "PkgExhausted" in err_str: @@ -70,6 +105,7 @@ async def _send_tts_audio(conversation_id: str, text: str) -> None: ) else: logger.error("TTS synthesize failed: %s", e) + return None # ── Agent 实例(从 ConnectionManager 移出) ───────────────────── @@ -427,6 +463,9 @@ async def process_audio_segment( conversation_id=conversation_id, transcript_text=transcript_text or "", audio_url=_build_segment_audio_url(voice_session_id, segment_index), + audio_duration_seconds=audio_duration + if audio_duration > 0 + else None, processed=False, ) db.add(segment) @@ -499,6 +538,7 @@ async def process_user_message( try: is_from_voice = bool(segment.audio_url) voice_session_id = _voice_session_id_from_audio_url(segment.audio_url) + audio_dur = getattr(segment, "audio_duration_seconds", None) responses = await chat_orchestrator.process_user_message( conversation_id=conversation_id, user_message=user_message, @@ -511,12 +551,15 @@ async def process_user_message( get_missing_profile_fields_fn=get_missing_profile_fields, get_filled_profile_fields_fn=get_filled_profile_fields, user_message_timestamp=user_message_timestamp, + audio_duration_seconds=audio_dur, ) segment.agent_response = "\n\n".join(responses) _mark_conversation_active(conversation) await db.commit() + tts_urls: list[str] = [] + n = len(responses) for i, response_text in enumerate(responses): await manager.send_message( conversation_id, @@ -526,15 +569,29 @@ async def process_user_message( "data": { "text": response_text, "index": i, - "total": len(responses), + "total": n, }, "timestamp": datetime.now(timezone.utc).isoformat(), }, ) - await _send_tts_audio(conversation_id, response_text) - if i < len(responses) - 1: + url = await _send_tts_audio( + conversation_id, + response_text, + chunk_index=i, + chunk_total=n, + ) + if url: + tts_urls.append(url) + if i < n - 1: await asyncio.sleep(0.5) + await db.execute( + update(Segment) + .where(Segment.id == segment.id) + .values(tts_audio_urls=tts_urls if tts_urls else None) + ) + await db.commit() + except Exception as e: logger.error(f"处理用户消息失败: {e}", exc_info=True) if conversation_id in manager.active_connections: diff --git a/api/app/features/conversation/ws/router.py b/api/app/features/conversation/ws/router.py index 71c8ffd..75b04f5 100644 --- a/api/app/features/conversation/ws/router.py +++ b/api/app/features/conversation/ws/router.py @@ -462,11 +462,16 @@ async def websocket_endpoint( }, ) + try: + ads = int(audio_duration) + except (TypeError, ValueError): + ads = 0 segment = Segment( id=str(uuid.uuid4()), conversation_id=conversation_id, transcript_text=transcript_text, audio_url=f"audio:{audio_duration}s", + audio_duration_seconds=ads if ads > 0 else None, processed=False, ) db.add(segment) diff --git a/api/app/features/memoir/asset_resolver.py b/api/app/features/memoir/asset_resolver.py index 1bd520e..bdec4ea 100644 --- a/api/app/features/memoir/asset_resolver.py +++ b/api/app/features/memoir/asset_resolver.py @@ -47,6 +47,11 @@ def collect_asset_ids_for_chapter(chapter) -> set[str]: continue smd = getattr(st, "canonical_markdown", None) or "" ids.update(collect_asset_ids_from_markdown(smd)) + for intent in getattr(st, "image_intents", None) or []: + if getattr(intent, "intent_role", None) == "primary": + aid = getattr(intent, "asset_id", None) + if aid: + ids.add(str(aid)) return ids diff --git a/api/app/features/memoir/chapter_markdown_compose.py b/api/app/features/memoir/chapter_markdown_compose.py index cadac9f..d48036f 100644 --- a/api/app/features/memoir/chapter_markdown_compose.py +++ b/api/app/features/memoir/chapter_markdown_compose.py @@ -1,29 +1,16 @@ """ 按 chapter_story_links 顺序将各 story 正文物化为单一 markdown(无 LLM)。 保留 story 内 asset:// 引用不变。 +章节级 canonical:仅正文拼接,故事间用 ---;故事标题仅存 stories.title。 +PDF 导出可单独物化「## 标题 + 正文」版本。 """ from typing import Any - -def compose_ordered_stories_to_markdown( - ordered: list[tuple[str, str]], -) -> str: - """ - :param ordered: (story_title, canonical_markdown) 已按阅读顺序排好 - :return: 章节级 markdown;每个故事为 ## 标题 + 正文,故事之间用 markdown 水平线 --- 分隔 - (配图在 story 正文中,自然落在该故事块内、--- 之前) - """ - parts: list[str] = [] - for title, md in ordered: - title = (title or "").strip() or "故事" - body = (md or "").strip() - parts.append(f"## {title}\n\n{body}" if body else f"## {title}") - return "\n\n---\n\n".join(parts) +from app.features.memoir.markdown_sanitize import sanitize_story_for_chapter_compose -def materialize_chapter_markdown_from_loaded_chapter(chapter: Any) -> str: - """要求 chapter.story_links 已 eager-load,且各 link.story 可用。""" +def _gather_title_body_pairs(chapter: Any) -> list[tuple[str, str]]: links = sorted( list(getattr(chapter, "story_links", None) or []), key=lambda x: getattr(x, "order_index", 0), @@ -36,4 +23,45 @@ def materialize_chapter_markdown_from_loaded_chapter(chapter: Any) -> str: title = (getattr(st, "title", None) or "").strip() body = (getattr(st, "canonical_markdown", None) or "").strip() pairs.append((title, body)) - return compose_ordered_stories_to_markdown(pairs) + return pairs + + +def compose_ordered_stories_to_markdown(ordered: list[tuple[str, str]]) -> str: + """ + :param ordered: (story_title, canonical_markdown) 已按阅读顺序排好(title 仅用于清洗去重) + :return: 章节级 markdown;仅各故事正文,非空块之间用 \\n\\n---\\n\\n 分隔 + """ + bodies: list[str] = [] + for title, md in ordered: + raw = (md or "").strip() + if not raw: + continue + cleaned = sanitize_story_for_chapter_compose(raw, title) + if cleaned: + bodies.append(cleaned) + return "\n\n---\n\n".join(bodies) + + +def compose_ordered_stories_to_pdf_markdown(ordered: list[tuple[str, str]]) -> str: + """PDF:每故事 ## 标题 + 正文,块间 ---(标题来自元数据,不写回章节 canonical)。""" + parts: list[str] = [] + for title, md in ordered: + t = (title or "").strip() or "故事" + raw = (md or "").strip() + if not raw: + continue + body = sanitize_story_for_chapter_compose(raw, title) + if not body: + continue + parts.append(f"## {t}\n\n{body}") + return "\n\n---\n\n".join(parts) + + +def materialize_chapter_markdown_from_loaded_chapter(chapter: Any) -> str: + """要求 chapter.story_links 已 eager-load,且各 link.story 可用。""" + return compose_ordered_stories_to_markdown(_gather_title_body_pairs(chapter)) + + +def materialize_chapter_pdf_markdown_from_loaded_chapter(chapter: Any) -> str: + """PDF 专用:含每段 ## 故事名。""" + return compose_ordered_stories_to_pdf_markdown(_gather_title_body_pairs(chapter)) diff --git a/api/app/features/memoir/cover_eligibility.py b/api/app/features/memoir/cover_eligibility.py index fc3e6e8..7a08d71 100644 --- a/api/app/features/memoir/cover_eligibility.py +++ b/api/app/features/memoir/cover_eligibility.py @@ -4,11 +4,29 @@ from __future__ import annotations from typing import Any +from app.features.memoir.asset_resolver import parse_asset_refs from app.features.memoir.memoir_images.schema import ( IMAGE_STATUS_FAILED, IMAGE_STATUS_PENDING, ) +# 正文内 ![...](asset://...) 数量需 **大于** 此值才生成/展示章节封面(与故事头图、正文配图任务独立) +MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER = 3 + + +def count_chapter_inline_body_images(chapter: Any) -> int: + """统计章节 canonical_markdown 中正文插图(asset:// 图片引用)次数。""" + md = getattr(chapter, "canonical_markdown", None) or "" + return len(parse_asset_refs(md)) + + +def chapter_eligible_for_cover_by_inline_body_image_count(chapter: Any) -> bool: + """仅当正文内插图数量 > MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER 时才生成/展示章节封面。""" + return ( + count_chapter_inline_body_images(chapter) + > MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER + ) + def primary_chapter_memoir_image(chapter: Any) -> Any | None: """章节级 MemoirImage(封面槽位):按 order_index 最小取第一条。""" @@ -20,13 +38,15 @@ def primary_chapter_memoir_image(chapter: Any) -> Any | None: def chapter_needs_cover_enqueue(chapter) -> bool: - """尚无 cover_asset 且章节有正文时,可派发 generate_chapter_cover。""" + """尚无 cover_asset、有正文、且正文内 asset 插图多于阈值时,可派发 generate_chapter_cover。""" if not chapter: return False if getattr(chapter, "cover_asset_id", None): return False md = (getattr(chapter, "canonical_markdown", None) or "").strip() - return bool(md) + if not md: + return False + return chapter_eligible_for_cover_by_inline_body_image_count(chapter) def chapter_has_cover_to_generate(chapter) -> bool: diff --git a/api/app/features/memoir/helpers.py b/api/app/features/memoir/helpers.py index d3bef09..3b09483 100644 --- a/api/app/features/memoir/helpers.py +++ b/api/app/features/memoir/helpers.py @@ -2,9 +2,14 @@ 回忆录序列化与图片归一化辅助(供 MemoirService 使用)。 """ +from typing import Any + from app.core.config import settings from app.core.logging import get_logger from app.features.memoir.asset_resolver import resolve_asset_refs_in_markdown +from app.features.memoir.reading_segment_materialize import ( + resolve_reading_segments_for_chapter_detail, +) from app.features.memoir.memoir_images.schema import ( IMAGE_STATUS_COMPLETED, IMAGE_STATUS_FAILED, @@ -20,7 +25,10 @@ from app.features.memoir.memoir_images.storage import ( normalize_cos_url, resolve_image_storage_key, ) -from app.features.memoir.cover_eligibility import primary_chapter_memoir_image +from app.features.memoir.cover_eligibility import ( + chapter_eligible_for_cover_by_inline_body_image_count, + primary_chapter_memoir_image, +) from app.features.memoir.models import Chapter logger = get_logger(__name__) @@ -85,9 +93,64 @@ def is_image_permanently_unavailable(rec) -> bool: return False +def story_primary_cover_image_dict( + story: Any, asset_url_map: dict[str, str] | None = None +) -> dict | None: + """ + Story 主插图(StoryImageIntent intent_role=primary)。 + asset_url_map: asset_id -> 签名 URL;无 URL 时仍返回 pending/processing 等状态供客户端占位。 + """ + asset_url_map = asset_url_map or {} + intents = getattr(story, "image_intents", None) or [] + primary = None + for it in intents: + if getattr(it, "intent_role", None) == "primary": + primary = it + break + if not primary: + return None + aid = getattr(primary, "asset_id", None) + url = asset_url_map.get(str(aid)) if aid else None + status = getattr(primary, "status", None) or "pending" + return { + "placeholder": "", + "description": getattr(primary, "caption", None) or "故事配图", + "index": 0, + "status": status, + "prompt": getattr(primary, "prompt_brief", None), + "url": url, + "storage_key": None, + "provider": None, + "style": getattr(primary, "style_profile", None), + "size": None, + "error": getattr(primary, "error", None), + "retryable": None, + "created_at": primary.created_at.isoformat() if primary.created_at else None, + "updated_at": primary.updated_at.isoformat() if primary.updated_at else None, + } + + +def build_reading_segments( + ch: Chapter, + asset_url_map: dict[str, str] | None = None, +) -> list[dict]: + """与 chapter_story_links 顺序一致;每段 body 已清洗。 + + 配图策略:StoryImageIntent(intent_role=primary)的 asset_id → 签名 URL; + 无 intent 或未完成时 cover_image 为 null,客户端展示占位块;补图仍由 story_image_tasks 驱动。 + """ + from app.features.memoir.reading_segment_materialize import ( + materialize_chapter_reading_segments, + ) + + return materialize_chapter_reading_segments(ch, asset_url_map) + + def chapter_cover_to_dict( ch: Chapter, asset_url_map: dict[str, str] | None = None ) -> dict | None: + if not chapter_eligible_for_cover_by_inline_body_image_count(ch): + return None m = primary_chapter_memoir_image(ch) if m: return memoir_image_to_dict(m) @@ -177,6 +240,9 @@ def chapter_to_dict(ch: Chapter, asset_url_map: dict[str, str] | None = None) -> # 正文真源:优先 canonical_markdown canonical_md = _chapter_markdown(ch) canonical_md = resolve_asset_refs_in_markdown(canonical_md, resolve) + reading_segments = resolve_reading_segments_for_chapter_detail( + ch, asset_url_map=asset_url_map + ) return { "id": ch.id, "title": ch.title, @@ -189,6 +255,7 @@ def chapter_to_dict(ch: Chapter, asset_url_map: dict[str, str] | None = None) -> "cover_image": cover_normalized, "rendered_assets": normalized_images, "sections": sections_data, + "reading_segments": reading_segments, "updated_at": ch.updated_at.isoformat() if ch.updated_at else None, "is_new": ch.is_new, "source_segments": ch.source_segments or [], diff --git a/api/app/features/memoir/markdown_sanitize.py b/api/app/features/memoir/markdown_sanitize.py new file mode 100644 index 0000000..352193b --- /dev/null +++ b/api/app/features/memoir/markdown_sanitize.py @@ -0,0 +1,63 @@ +"""章节物化前对 story 正文的受限清洗:禁止表格、可选剥离与标题元数据重复的首行 heading。""" + +from __future__ import annotations + +import re + + +def _is_table_row(line: str) -> bool: + s = line.strip() + if not s.startswith("|"): + return False + return s.count("|") >= 2 + + +def strip_markdown_tables(text: str) -> str: + """移除 GFM 管道表格块(连续以 | 开头的行)。""" + if not text or not str(text).strip(): + return "" + lines = str(text).splitlines() + out: list[str] = [] + i = 0 + while i < len(lines): + if _is_table_row(lines[i]): + while i < len(lines) and _is_table_row(lines[i]): + i += 1 + continue + out.append(lines[i]) + i += 1 + return "\n".join(out).strip() + + +_HEADING_LINE_RE = re.compile(r"^#{1,6}\s+(.+?)\s*$") + + +def _normalize_title_key(s: str) -> str: + return "".join((s or "").split()).casefold() + + +def strip_leading_heading_if_matches_title(body: str, story_title: str) -> str: + """若首行为 markdown 标题且与 story 标题(规范化后)一致,则移除该行。""" + if not body or not str(body).strip(): + return body or "" + st_key = _normalize_title_key(story_title or "") + if not st_key: + return body + lines = str(body).splitlines() + if not lines: + return body + m = _HEADING_LINE_RE.match(lines[0].strip()) + if not m: + return body + heading_key = _normalize_title_key(m.group(1)) + if heading_key != st_key: + return body + rest = "\n".join(lines[1:]) + return rest.lstrip("\n") + + +def sanitize_story_for_chapter_compose(body: str, story_title: str) -> str: + """物化章节前:去表格、去与元数据重复的首行标题。""" + t = strip_markdown_tables(body or "") + t = strip_leading_heading_if_matches_title(t, story_title) + return (t or "").strip() diff --git a/api/app/features/memoir/models.py b/api/app/features/memoir/models.py index c9562e6..4c9f1fd 100644 --- a/api/app/features/memoir/models.py +++ b/api/app/features/memoir/models.py @@ -39,6 +39,8 @@ class Chapter(Base): # story-backed 章节:story 变更后标 True,由 Celery 重组 canonical_markdown markdown_compose_dirty = Column(Boolean, default=False, nullable=False) markdown_composed_at = Column(DateTime(timezone=True), nullable=True) + # 与 canonical 同一生成时机物化;无签名 URL,读时 hydrate + reading_segments_json = Column(JSON, nullable=True) user = relationship("User", back_populates="chapters") book = relationship("Book", back_populates="chapters") diff --git a/api/app/features/memoir/pdf_service.py b/api/app/features/memoir/pdf_service.py index 845b1c9..4ef7996 100644 --- a/api/app/features/memoir/pdf_service.py +++ b/api/app/features/memoir/pdf_service.py @@ -26,10 +26,23 @@ from app.features.memoir.asset_resolver import ( split_markdown_by_asset_refs, strip_legacy_image_placeholders, ) +from app.features.memoir.chapter_markdown_compose import ( + materialize_chapter_pdf_markdown_from_loaded_chapter, +) from app.features.memoir.helpers import ( _chapter_markdown, sections_to_content_and_images, ) + + +def _chapter_markdown_for_pdf(chapter) -> str: + """有 story 编排时 PDF 使用「## 故事名 + 正文」物化;否则沿用章节 canonical。""" + links = getattr(chapter, "story_links", None) or [] + if links and any(getattr(l, "story", None) for l in links): + return materialize_chapter_pdf_markdown_from_loaded_chapter(chapter) + return _chapter_markdown(chapter) + + from app.features.memoir.memoir_images.parser import PLACEHOLDER_RE from app.features.memoir.memoir_images.schema import ( IMAGE_STATUS_COMPLETED, @@ -165,8 +178,8 @@ class PDFService: for chapter in chapters: story.append(Paragraph(chapter.title, heading_style)) story.append(Spacer(1, 0.2 * inch)) - # 正文真源:canonical_markdown(与 API / 前端一致) - markdown = _chapter_markdown(chapter) + # 有 story_links 时按章节内故事注入 ## 标题(与物化章节正文不含故事标题区分) + markdown = _chapter_markdown_for_pdf(chapter) _, images_list = sections_to_content_and_images(chapter) if not markdown: markdown = getattr(chapter, "content", "") or "" diff --git a/api/app/features/memoir/reading_segment_materialize.py b/api/app/features/memoir/reading_segment_materialize.py new file mode 100644 index 0000000..1d65e11 --- /dev/null +++ b/api/app/features/memoir/reading_segment_materialize.py @@ -0,0 +1,188 @@ +""" +章节阅读片段物化:与 canonical 同一生成时机写入 reading_segments_json(无签名 URL); +API 读时 hydrate 或(dirty / 无快照)回退为运行时物化。 +""" + +from __future__ import annotations + +from typing import Any + +from app.features.memoir.asset_resolver import ( + collect_asset_ids_from_markdown, + resolve_asset_refs_in_markdown, +) +from app.features.memoir.markdown_sanitize import sanitize_story_for_chapter_compose +from app.features.memoir.models import Chapter + + +def _primary_story_intent_asset_id(story: Any) -> str | None: + for it in getattr(story, "image_intents", None) or []: + if getattr(it, "intent_role", None) == "primary": + aid = getattr(it, "asset_id", None) + return str(aid) if aid else None + return None + + +def _cover_intent_snapshot_from_story(story: Any) -> dict | None: + """primary intent 元数据(无 url),供 JSON 持久化。""" + intents = getattr(story, "image_intents", None) or [] + primary = None + for it in intents: + if getattr(it, "intent_role", None) == "primary": + primary = it + break + if not primary: + return None + aid = getattr(primary, "asset_id", None) + if not aid: + return None + status = getattr(primary, "status", None) or "pending" + return { + "asset_id": str(aid), + "status": status, + "description": getattr(primary, "caption", None) or "故事配图", + "prompt": getattr(primary, "prompt_brief", None), + "style": getattr(primary, "style_profile", None), + "error": getattr(primary, "error", None), + "created_at": primary.created_at.isoformat() if primary.created_at else None, + "updated_at": primary.updated_at.isoformat() if primary.updated_at else None, + } + + +def _cover_dict_from_snapshot_row( + snap: dict[str, Any], asset_url_map: dict[str, str] +) -> dict: + aid = snap.get("asset_id") + url = asset_url_map.get(str(aid)) if aid else None + return { + "placeholder": "", + "description": snap.get("description") or "故事配图", + "index": 0, + "status": snap.get("status") or "pending", + "prompt": snap.get("prompt"), + "url": url, + "storage_key": None, + "provider": None, + "style": snap.get("style"), + "size": None, + "error": snap.get("error"), + "retryable": None, + "created_at": snap.get("created_at"), + "updated_at": snap.get("updated_at"), + } + + +def build_reading_segments_snapshot(ch: Chapter) -> list[dict[str, Any]]: + """ + 物化阅读片段快照:body 保留 asset://;cover 仅存 intent 元数据(正文已含同 asset 则省略)。 + 与 append_chapter_compose_version 同路径写入。 + """ + links = sorted( + list(getattr(ch, "story_links", None) or []), + key=lambda x: getattr(x, "order_index", 0), + ) + out: list[dict[str, Any]] = [] + for link in links: + st = getattr(link, "story", None) + if st is None: + continue + title = (getattr(st, "title", None) or "").strip() + raw = (getattr(st, "canonical_markdown", None) or "").strip() + body = sanitize_story_for_chapter_compose(raw, title) + if not body: + continue + primary_aid = _primary_story_intent_asset_id(st) + inline_ids = set(collect_asset_ids_from_markdown(body)) + cover: dict | None = None + if primary_aid and primary_aid not in inline_ids: + cover = _cover_intent_snapshot_from_story(st) + out.append( + { + "story_id": st.id, + "body_markdown": body, + "cover_image": cover, + } + ) + return out + + +def materialize_chapter_reading_segments( + ch: Chapter, + asset_url_map: dict[str, str] | None = None, +) -> list[dict[str, Any]]: + """运行时物化(解析签名 URL),与旧 build_reading_segments 行为一致。""" + from app.features.memoir import helpers as h + + asset_url_map = asset_url_map or {} + resolve = lambda aid: asset_url_map.get(aid) # noqa: E731 + links = sorted( + list(getattr(ch, "story_links", None) or []), + key=lambda x: getattr(x, "order_index", 0), + ) + segments: list[dict[str, Any]] = [] + for link in links: + st = getattr(link, "story", None) + if st is None: + continue + title = (getattr(st, "title", None) or "").strip() + raw = (getattr(st, "canonical_markdown", None) or "").strip() + body = sanitize_story_for_chapter_compose(raw, title) + if not body: + continue + body_md = resolve_asset_refs_in_markdown(body, resolve) + img_raw = h.story_primary_cover_image_dict(st, asset_url_map=asset_url_map) + primary_aid = _primary_story_intent_asset_id(st) + inline_ids = set(collect_asset_ids_from_markdown(body)) + if img_raw and primary_aid and primary_aid in inline_ids: + img_raw = None + img_norm = h.first_normalized_image_for_api(img_raw) if img_raw else None + segments.append( + { + "story_id": st.id, + "body_markdown": body_md, + "cover_image": img_norm, + } + ) + return segments + + +def hydrate_reading_segments_from_snapshot( + ch: Chapter, + asset_url_map: dict[str, str] | None = None, +) -> list[dict[str, Any]]: + """将持久化快照解析为 API 形态(签名 URL)。""" + from app.features.memoir import helpers as h + + asset_url_map = asset_url_map or {} + resolve = lambda aid: asset_url_map.get(aid) # noqa: E731 + rows = getattr(ch, "reading_segments_json", None) or [] + out: list[dict[str, Any]] = [] + for row in rows: + body = resolve_asset_refs_in_markdown(row["body_markdown"], resolve) + ci = row.get("cover_image") + if ci: + img_raw = _cover_dict_from_snapshot_row(ci, asset_url_map) + img_norm = h.first_normalized_image_for_api(img_raw) + else: + img_norm = None + out.append( + { + "story_id": row["story_id"], + "body_markdown": body, + "cover_image": img_norm, + } + ) + return out + + +def resolve_reading_segments_for_chapter_detail( + ch: Chapter, + asset_url_map: dict[str, str] | None = None, +) -> list[dict[str, Any]]: + """章节详情:dirty 或无快照列时运行时物化;否则 hydrate。""" + asset_url_map = asset_url_map or {} + dirty = getattr(ch, "markdown_compose_dirty", True) + has_snapshot = getattr(ch, "reading_segments_json", None) is not None + if has_snapshot and not dirty: + return hydrate_reading_segments_from_snapshot(ch, asset_url_map=asset_url_map) + return materialize_chapter_reading_segments(ch, asset_url_map=asset_url_map) diff --git a/api/app/features/memoir/repo.py b/api/app/features/memoir/repo.py index 737b029..d9f53b2 100644 --- a/api/app/features/memoir/repo.py +++ b/api/app/features/memoir/repo.py @@ -12,6 +12,9 @@ from app.features.memoir.asset_resolver import collect_asset_ids_for_chapter from app.features.memoir.chapter_markdown_compose import ( materialize_chapter_markdown_from_loaded_chapter, ) +from app.features.memoir.reading_segment_materialize import ( + build_reading_segments_snapshot, +) from app.features.memoir.models import ( Book, Chapter, @@ -47,7 +50,9 @@ async def get_chapters_for_memoir_list( .where(Chapter.user_id == user_id) .options( joinedload(Chapter.images), - joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), + joinedload(Chapter.story_links) + .joinedload(ChapterStoryLink.story) + .joinedload(Story.image_intents), ) .order_by(Chapter.order_index) ) @@ -78,7 +83,9 @@ async def get_chapter_by_id(chapter_id: str, db: AsyncSession) -> Chapter | None .where(Chapter.id == chapter_id) .options( joinedload(Chapter.images), - joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), + joinedload(Chapter.story_links) + .joinedload(ChapterStoryLink.story) + .joinedload(Story.image_intents), ) ) result = await db.execute(stmt) @@ -259,7 +266,9 @@ async def get_chapter_with_story_links_for_compose( select(Chapter) .where(Chapter.id == chapter_id) .options( - joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), + joinedload(Chapter.story_links) + .joinedload(ChapterStoryLink.story) + .joinedload(Story.image_intents), ) ) result = await db.execute(stmt) @@ -291,6 +300,7 @@ async def append_chapter_compose_version_async( chapter.current_version_id = vid chapter.markdown_compose_dirty = False chapter.markdown_composed_at = utc_now() + chapter.reading_segments_json = build_reading_segments_snapshot(chapter) def append_chapter_compose_version_sync( @@ -317,6 +327,7 @@ def append_chapter_compose_version_sync( chapter.current_version_id = vid chapter.markdown_compose_dirty = False chapter.markdown_composed_at = utc_now() + chapter.reading_segments_json = build_reading_segments_snapshot(chapter) def compose_chapter_from_story_links_sync(session: Session, chapter_id: str) -> bool: @@ -328,7 +339,9 @@ def compose_chapter_from_story_links_sync(session: Session, chapter_id: str) -> select(Chapter) .where(Chapter.id == chapter_id) .options( - joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), + joinedload(Chapter.story_links) + .joinedload(ChapterStoryLink.story) + .joinedload(Story.image_intents), ) ) chapter = session.execute(stmt).unique().scalar_one_or_none() @@ -337,6 +350,7 @@ def compose_chapter_from_story_links_sync(session: Session, chapter_id: str) -> links = list(chapter.story_links or []) if not links: chapter.markdown_compose_dirty = False + chapter.reading_segments_json = [] session.flush() return False md = materialize_chapter_markdown_from_loaded_chapter(chapter) diff --git a/api/app/features/memoir/service.py b/api/app/features/memoir/service.py index e2e7c8a..67c9042 100644 --- a/api/app/features/memoir/service.py +++ b/api/app/features/memoir/service.py @@ -32,7 +32,7 @@ from app.features.memoir.helpers import ( is_image_permanently_unavailable, ) from app.features.memoir.memoir_images.settings import MemoirImageSettings -from app.features.memoir.models import Book, Chapter +from app.features.memoir.models import Book, Chapter, ChapterStoryLink from app.features.memory.service import MemoryService from app.ports.storage import ObjectStorage @@ -132,7 +132,7 @@ class MemoirService: .where(Chapter.user_id == user_id, Chapter.is_active == True) .options( joinedload(Chapter.images), - joinedload(Chapter.story_links), + joinedload(Chapter.story_links).joinedload(ChapterStoryLink.story), ) .order_by(Chapter.order_index) ) diff --git a/api/app/features/story/backfill.py b/api/app/features/story/backfill.py index 4306c8e..973c28a 100644 --- a/api/app/features/story/backfill.py +++ b/api/app/features/story/backfill.py @@ -1,34 +1,31 @@ """ -Story 图片回填 — 将 asset:// 引用插入 markdown。 +Story 图片回填 — 将 asset:// 引用追加到 markdown 末尾。 -图片生成成功后,基于 source_span 或 fallback 位置插入 ![caption](asset://asset_id)。 +图片生成成功后,在正文最后插入 ![alt](asset://asset_id)。 +alt 使用原始 prompt 短文(prompt_brief),而非模板拼接后的完整出图 prompt。 """ +def _escape_markdown_image_alt(text: str) -> str: + """在 ![alt](...) 的 alt 中安全转义 \\ 与 ]。""" + return text.replace("\\", "\\\\").replace("]", "\\]") + + def backfill_image_into_markdown( markdown: str, asset_id: str, - caption: str, - *, - source_span: dict | None = None, + image_alt: str, ) -> str: """ - 将图片引用回填到 markdown。 + 将图片引用追加到 markdown 末尾。 - 格式:![caption](asset://asset_id) - 位置:若 source_span 有效则在对应段落后插入;否则在开头插入。 + 格式:![image_alt](asset://asset_id) + image_alt 一般为 intent.prompt_brief(原始图片提示短文)。 """ - img_ref = f"![{caption}](asset://{asset_id})" + raw = (image_alt or "").strip() or "主插图" + alt = _escape_markdown_image_alt(raw) + img_ref = f"![{alt}](asset://{asset_id})" if not markdown or not markdown.strip(): return img_ref - - if source_span and isinstance(source_span, dict): - start = source_span.get("start") - end = source_span.get("end") - if start is not None and end is not None and 0 <= start <= end <= len(markdown): - return markdown[:end] + "\n\n" + img_ref + "\n\n" + markdown[end:] - - parts = markdown.strip().split("\n\n", 1) - if len(parts) == 1: - return img_ref + "\n\n" + markdown.strip() - return parts[0] + "\n\n" + img_ref + "\n\n" + parts[1] + body = markdown.rstrip() + return f"{body}\n\n{img_ref}\n" diff --git a/api/app/features/story/image_intent_extractor.py b/api/app/features/story/image_intent_extractor.py index 8c7461f..8cd6d34 100644 --- a/api/app/features/story/image_intent_extractor.py +++ b/api/app/features/story/image_intent_extractor.py @@ -12,7 +12,6 @@ from __future__ import annotations import re from dataclasses import dataclass -from typing import Any @dataclass @@ -21,7 +20,6 @@ class StoryImageIntentResult: caption: str prompt_brief: str - source_span: dict[str, Any] | None style_profile: str | None @@ -59,36 +57,28 @@ def extract_primary_image_intent( 优先从正文中选取最具画面感的段落;若正文过短或过于抽象,则使用 fallback。 """ - paragraphs: list[tuple[str, int, int]] = [] # (text, start, end) + paragraphs: list[str] = [] if markdown and markdown.strip(): - parts = re.split(r"\n\n+", markdown.strip()) - offset = 0 - for p in parts: + for p in re.split(r"\n\n+", markdown.strip()): t = p.strip() if t: - start = markdown.find(t, offset) - end = start + len(t) - paragraphs.append((t, start, end)) - offset = end + paragraphs.append(t) best_caption = "" best_prompt_brief = "" - best_source_span: dict[str, Any] | None = None best_score = 0.0 - for text, start, end in paragraphs: + for text in paragraphs: score = _score_paragraph(text) if score > best_score: best_score = score best_caption = (text[:80] + "…") if len(text) > 80 else text best_prompt_brief = text[:500].strip() - best_source_span = {"start": start, "end": end, "text_preview": text[:100]} if best_score >= 0.5: return StoryImageIntentResult( caption=best_caption, prompt_brief=best_prompt_brief, - source_span=best_source_span, style_profile=style_profile, ) @@ -110,6 +100,5 @@ def extract_primary_image_intent( return StoryImageIntentResult( caption=fallback_text[:80], prompt_brief=fallback_text, - source_span=None, style_profile=style_profile, ) diff --git a/api/app/features/story/models.py b/api/app/features/story/models.py index 4edb2b3..ab194da 100644 --- a/api/app/features/story/models.py +++ b/api/app/features/story/models.py @@ -142,7 +142,6 @@ class StoryImageIntent(Base): nullable=True, ) intent_role = Column(String, nullable=False) # primary - source_span = Column(JSON, nullable=True) caption = Column(String, nullable=True) prompt_brief = Column(Text, nullable=True) style_profile = Column(String, nullable=True) diff --git a/api/app/features/story/repo.py b/api/app/features/story/repo.py index 83b74e9..f47c4e5 100644 --- a/api/app/features/story/repo.py +++ b/api/app/features/story/repo.py @@ -123,7 +123,6 @@ async def create_story_image_intent( story_version_id: str | None, caption: str, prompt_brief: str, - source_span: dict | None = None, style_profile: str | None = None, ) -> StoryImageIntent: """Create primary image intent for a story. Caller must commit.""" @@ -132,7 +131,6 @@ async def create_story_image_intent( story_id=story_id, story_version_id=story_version_id, intent_role="primary", - source_span=source_span, caption=caption, prompt_brief=prompt_brief, style_profile=style_profile, diff --git a/api/app/features/story/service.py b/api/app/features/story/service.py index 8e21d68..10befc4 100644 --- a/api/app/features/story/service.py +++ b/api/app/features/story/service.py @@ -61,7 +61,6 @@ async def _extract_and_store_image_intent( return existing.caption = result.caption existing.prompt_brief = result.prompt_brief - existing.source_span = result.source_span existing.style_profile = result.style_profile existing.status = "pending" existing.error = None @@ -74,7 +73,6 @@ async def _extract_and_store_image_intent( existing.story_version_id = version.id existing.caption = result.caption existing.prompt_brief = result.prompt_brief - existing.source_span = result.source_span existing.style_profile = result.style_profile existing.status = "pending" existing.error = None @@ -88,7 +86,6 @@ async def _extract_and_store_image_intent( story_version_id=version.id, caption=result.caption, prompt_brief=result.prompt_brief, - source_span=result.source_span, style_profile=result.style_profile, ) diff --git a/api/app/features/story/sync_write.py b/api/app/features/story/sync_write.py index 718eca1..1cabc22 100644 --- a/api/app/features/story/sync_write.py +++ b/api/app/features/story/sync_write.py @@ -74,7 +74,6 @@ def _extract_and_store_image_intent_sync( return existing.caption = result.caption existing.prompt_brief = result.prompt_brief - existing.source_span = result.source_span existing.style_profile = result.style_profile existing.status = "pending" existing.error = None @@ -86,7 +85,6 @@ def _extract_and_store_image_intent_sync( existing.story_version_id = version.id existing.caption = result.caption existing.prompt_brief = result.prompt_brief - existing.source_span = result.source_span existing.style_profile = result.style_profile existing.status = "pending" existing.error = None @@ -100,7 +98,6 @@ def _extract_and_store_image_intent_sync( story_id=story.id, story_version_id=version.id, intent_role="primary", - source_span=result.source_span, caption=result.caption, prompt_brief=result.prompt_brief, style_profile=result.style_profile, diff --git a/api/app/features/user/repo.py b/api/app/features/user/repo.py index bf35fc4..67732a4 100644 --- a/api/app/features/user/repo.py +++ b/api/app/features/user/repo.py @@ -3,6 +3,10 @@ from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.cos_url_keys import ( + collect_cos_keys_from_tts_url_list, + extract_cos_object_key_if_owned, +) from app.features.asset.models import Asset from app.features.auth.models import RefreshToken from app.features.conversation.models import Conversation, Segment @@ -96,6 +100,19 @@ async def collect_object_storage_keys_before_purge( ) keys.update(x for x in r3.scalars().all() if x) + seg_rows = await db.execute( + select(Segment.audio_url, Segment.tts_audio_urls) + .join(Conversation, Segment.conversation_id == Conversation.id) + .where(Conversation.user_id == user_id) + ) + for audio_url, tts_urls in seg_rows.all(): + k = extract_cos_object_key_if_owned(audio_url) + if k: + keys.add(k) + keys |= collect_cos_keys_from_tts_url_list( + tts_urls if isinstance(tts_urls, list) else None + ) + return sorted(keys) diff --git a/api/app/tasks/chapter_cover_enqueue.py b/api/app/tasks/chapter_cover_enqueue.py index adcee7b..a0fe678 100644 --- a/api/app/tasks/chapter_cover_enqueue.py +++ b/api/app/tasks/chapter_cover_enqueue.py @@ -14,7 +14,11 @@ from app.core.config import settings from app.core.db import get_sync_db from app.core.logging import get_logger from app.features.memoir.asset_resolver import strip_legacy_image_placeholders -from app.features.memoir.cover_eligibility import primary_chapter_memoir_image +from app.features.memoir.cover_eligibility import ( + chapter_eligible_for_cover_by_inline_body_image_count, + chapter_needs_cover_enqueue, + primary_chapter_memoir_image, +) from app.features.memoir.models import Chapter logger = get_logger(__name__) @@ -42,6 +46,8 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool: body = strip_legacy_image_placeholders(body).strip() if not body: return False + if not chapter_eligible_for_cover_by_inline_body_image_count(chapter): + return False cover_rec = primary_chapter_memoir_image(chapter) if cover_rec and (cover_rec.status or "").strip() == "completed": return False @@ -49,12 +55,8 @@ def _chapter_eligible_for_http_enqueue(chapter: Chapter | None) -> bool: def _chapter_eligible_for_pipeline_enqueue(chapter: Chapter | None) -> bool: - """与 memoir.cover_eligibility.chapter_needs_cover_enqueue 一致。""" - if not chapter: - return False - if getattr(chapter, "cover_asset_id", None): - return False - return bool((getattr(chapter, "canonical_markdown", None) or "").strip()) + """尚无 cover_asset、正文插图数 > 3(与 HTTP 闸门共用 chapter_needs_cover_enqueue 核心)。""" + return bool(chapter_needs_cover_enqueue(chapter)) def _load_chapter_for_enqueue_sync(chapter_id: str) -> Chapter | None: diff --git a/api/app/tasks/chapter_cover_tasks.py b/api/app/tasks/chapter_cover_tasks.py index c2913a6..78efcbe 100644 --- a/api/app/tasks/chapter_cover_tasks.py +++ b/api/app/tasks/chapter_cover_tasks.py @@ -22,6 +22,9 @@ from app.features.memoir.chapter_cover import ( aggregate_cover_prompt_from_chapter, aggregate_cover_prompt_from_stories, ) +from app.features.memoir.cover_eligibility import ( + chapter_eligible_for_cover_by_inline_body_image_count, +) from app.features.memoir.memoir_images.storage import TencentCosStorageService from app.features.memoir.models import Chapter, ChapterCoverIntent, ChapterStoryLink from app.ports.image_gen import TaskStatus @@ -197,6 +200,13 @@ def generate_chapter_cover(self, chapter_id: str): ) return {"status": "no_chapter"} + if not chapter_eligible_for_cover_by_inline_body_image_count(chapter): + logger.info( + "generate_chapter_cover: chapter=%s, reason=insufficient_inline_body_images", + chapter_id, + ) + return {"status": "insufficient_inline_body_images"} + if getattr(chapter, "cover_asset_id", None): logger.info( "generate_chapter_cover: chapter=%s, reason=has_cover_asset", diff --git a/api/app/tasks/story_image_tasks.py b/api/app/tasks/story_image_tasks.py index e3a35d6..f73f9f9 100644 --- a/api/app/tasks/story_image_tasks.py +++ b/api/app/tasks/story_image_tasks.py @@ -263,11 +263,13 @@ def generate_story_image(self, story_id: str): return {"status": "success_no_snapshot", "asset_id": asset_id} base_md = ver.markdown_snapshot or "" + alt_text = (getattr(intent_db, "prompt_brief", None) or "").strip() + if not alt_text: + alt_text = (getattr(intent_db, "caption", None) or "").strip() backfilled_md = backfill_image_into_markdown( base_md, asset_id=asset_id, - caption=intent_db.caption or "主插图", - source_span=intent_db.source_span, + image_alt=alt_text or "主插图", ) max_stmt = select(func.max(StoryVersion.version_no)).where( StoryVersion.story_id == story_id diff --git a/api/tests/test_chapter_cover_enqueue.py b/api/tests/test_chapter_cover_enqueue.py index cb3b148..82a798f 100644 --- a/api/tests/test_chapter_cover_enqueue.py +++ b/api/tests/test_chapter_cover_enqueue.py @@ -8,10 +8,16 @@ from app.tasks.chapter_cover_enqueue import ( ) +def _md_with_n_inline_images(n: int) -> str: + lines = [f"![c](asset://a{i})" for i in range(n)] + return "\n\n".join(lines) + "\n\n正文" + + def _eligible_pipeline_chapter(): ch = MagicMock() ch.cover_asset_id = None - ch.canonical_markdown = "some body" + # 章节封面:正文内 asset 插图需 >3 才入队 + ch.canonical_markdown = _md_with_n_inline_images(4) ch.images = [] return ch @@ -69,6 +75,20 @@ def test_try_enqueue_true_when_eligible_and_nx_ok(mock_load, mock_redis, mock_ge r.set.assert_called_once() +@patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") +@patch("app.tasks.chapter_cover_enqueue.redis.from_url") +@patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") +def test_try_enqueue_false_when_inline_images_not_enough( + mock_load, mock_redis, mock_gen_task +): + ch = _eligible_pipeline_chapter() + ch.canonical_markdown = _md_with_n_inline_images(3) + mock_load.return_value = ch + assert try_enqueue_generate_chapter_cover("ch-1", "pipeline") is False + mock_gen_task.delay.assert_not_called() + mock_redis.assert_not_called() + + @patch("app.tasks.chapter_cover_tasks.generate_chapter_cover") @patch("app.tasks.chapter_cover_enqueue.redis.from_url") @patch("app.tasks.chapter_cover_enqueue._load_chapter_for_enqueue_sync") diff --git a/api/tests/test_chapter_markdown_compose.py b/api/tests/test_chapter_markdown_compose.py index 6f4bacb..7da34e0 100644 --- a/api/tests/test_chapter_markdown_compose.py +++ b/api/tests/test_chapter_markdown_compose.py @@ -2,36 +2,43 @@ import unittest from app.features.memoir.chapter_markdown_compose import ( compose_ordered_stories_to_markdown, + compose_ordered_stories_to_pdf_markdown, materialize_chapter_markdown_from_loaded_chapter, ) class ChapterMarkdownComposeTest(unittest.TestCase): - def test_orders_and_separates_with_headings(self): + def test_joins_bodies_with_hr_only_no_story_headings(self): md = compose_ordered_stories_to_markdown( [("第一章", "正文A"), ("第二章", "正文B")] ) - self.assertIn("## 第一章", md) + self.assertNotIn("##", md) self.assertIn("正文A", md) - self.assertIn("## 第二章", md) self.assertIn("正文B", md) self.assertIn("\n\n---\n\n", md) self.assertTrue(md.index("正文A") < md.index("---")) - self.assertTrue(md.index("---") < md.index("第二章")) - self.assertTrue(md.index("第一章") < md.index("第二章")) + self.assertTrue(md.index("---") < md.index("正文B")) def test_preserves_asset_refs(self): body = "![x](asset://abc-123)" md = compose_ordered_stories_to_markdown([("S", body)]) self.assertIn("asset://abc-123", md) - def test_empty_title_uses_fallback(self): + def test_empty_title_still_composes_body(self): md = compose_ordered_stories_to_markdown([("", "仅正文")]) - self.assertIn("## 故事", md) + self.assertEqual(md, "仅正文") + self.assertNotIn("##", md) - def test_empty_body_keeps_heading_only(self): + def test_empty_body_skipped(self): md = compose_ordered_stories_to_markdown([("仅标题", "")]) - self.assertEqual(md, "## 仅标题") + self.assertEqual(md, "") + + def test_pdf_markdown_includes_story_headings(self): + md = compose_ordered_stories_to_pdf_markdown( + [("第一章", "正文A"), ("第二章", "正文B")] + ) + self.assertIn("## 第一章", md) + self.assertIn("## 第二章", md) def test_materialize_respects_order_index(self): class _S: @@ -56,3 +63,4 @@ class ChapterMarkdownComposeTest(unittest.TestCase): )() md = materialize_chapter_markdown_from_loaded_chapter(ch) self.assertLess(md.index("先"), md.index("后")) + self.assertNotIn("##", md) diff --git a/api/tests/test_chapters_router_images.py b/api/tests/test_chapters_router_images.py index 0bb0c84..be6282d 100644 --- a/api/tests/test_chapters_router_images.py +++ b/api/tests/test_chapters_router_images.py @@ -28,9 +28,14 @@ def _image_stub(**kwargs): return type("ImageStub", (), defaults)() -def _chapter_stub(*, images=None, canonical_markdown="正文"): +def _chapter_stub(*, images=None, canonical_markdown=None): """stories-first:章节配图均为 chapter 级 MemoirImage(按 order_index 取封面)。""" images = images or [] + if canonical_markdown is None: + # 正文内 asset:// 插图数 >3 时章节封面才展示(cover_eligibility) + canonical_markdown = "正文\n" + "\n".join( + [f"![](asset://c{i})" for i in range(4)] + ) return type( "ChapterStub", (), diff --git a/api/tests/test_conversation_messages_history.py b/api/tests/test_conversation_messages_history.py index aa9142c..24d0739 100644 --- a/api/tests/test_conversation_messages_history.py +++ b/api/tests/test_conversation_messages_history.py @@ -3,7 +3,11 @@ from datetime import datetime, timezone from app.features.conversation.models import Conversation -from app.features.conversation import router as conversations_router +from app.features.conversation.service import ( + _build_messages_from_history, + _latest_message_time_ms, + _message_timestamp_ms, +) class ConversationMessagesHistoryTest(unittest.TestCase): @@ -37,7 +41,7 @@ class ConversationMessagesHistoryTest(unittest.TestCase): }, ] - messages = conversations_router._build_messages_from_history( + messages = _build_messages_from_history( conversation_id="conv-1", history=history, fallback_timestamp=datetime(2026, 3, 14, 12, 0, 0, tzinfo=timezone.utc), @@ -55,6 +59,7 @@ class ConversationMessagesHistoryTest(unittest.TestCase): ], ) self.assertEqual(messages[0]["timestamp"], 1773489601000) + self.assertEqual(messages[0]["voiceSessionId"], "voice-1") self.assertEqual(messages[1]["timestamp"], 1773489602000) self.assertEqual(messages[2]["timestamp"], 1773489604000) @@ -76,7 +81,7 @@ class ConversationMessagesHistoryTest(unittest.TestCase): }, ] - messages = conversations_router._build_messages_from_history( + messages = _build_messages_from_history( conversation_id="conv-1", history=history, fallback_timestamp=datetime(2026, 3, 14, 12, 0, 0, tzinfo=timezone.utc), @@ -85,8 +90,29 @@ class ConversationMessagesHistoryTest(unittest.TestCase): self.assertEqual(len(messages), 2) self.assertEqual(messages[0]["messageType"], "audio") self.assertEqual(messages[0]["content"], "第一次录音") + self.assertEqual(messages[0]["voiceSessionId"], "voice-1") self.assertEqual(messages[1]["messageType"], "audio") self.assertEqual(messages[1]["content"], "第二次录音") + self.assertEqual(messages[1]["voiceSessionId"], "voice-2") + + def test_build_messages_includes_duration_seconds_from_history(self): + history = [ + { + "role": "human", + "content": "你好", + "messageType": "audio", + "voiceSessionId": "vs-a", + "durationSeconds": 8, + "timestamp": "2026-03-14T12:00:01+00:00", + }, + ] + messages = _build_messages_from_history( + conversation_id="conv-1", + history=history, + fallback_timestamp=datetime(2026, 3, 14, 12, 0, 0, tzinfo=timezone.utc), + ) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0]["durationSeconds"], 8) def test_latest_message_time_prefers_conversation_last_message_at(self): conversation = Conversation( @@ -104,9 +130,7 @@ class ConversationMessagesHistoryTest(unittest.TestCase): } ] - latest_message_time = conversations_router._latest_message_time_ms( - conversation, history - ) + latest_message_time = _latest_message_time_ms(conversation, history) self.assertEqual(latest_message_time, 1773489605000) @@ -117,8 +141,6 @@ class ConversationMessagesHistoryTest(unittest.TestCase): started_at=datetime(2026, 3, 14, 12, 0, 0, tzinfo=timezone.utc), ) - timestamp = conversations_router._message_timestamp_ms( - {}, conversation.started_at - ) + timestamp = _message_timestamp_ms({}, conversation.started_at) self.assertEqual(timestamp, 1773489600000) diff --git a/api/tests/test_cover_eligibility.py b/api/tests/test_cover_eligibility.py new file mode 100644 index 0000000..f832032 --- /dev/null +++ b/api/tests/test_cover_eligibility.py @@ -0,0 +1,49 @@ +"""章节封面与正文插图数量闸门。""" + +import unittest + +from app.features.memoir.cover_eligibility import ( + MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER, + chapter_eligible_for_cover_by_inline_body_image_count, + chapter_needs_cover_enqueue, + count_chapter_inline_body_images, +) + + +def _md_with_n_inline_images(n: int) -> str: + lines = [f"![c](asset://a{i})" for i in range(n)] + return "\n\n".join(lines) + "\n\n正文" + + +class CoverEligibilityTest(unittest.TestCase): + def test_count_inline_images(self): + class Ch: + canonical_markdown = _md_with_n_inline_images(4) + + self.assertEqual(count_chapter_inline_body_images(Ch()), 4) + + def test_eligible_only_when_more_than_threshold(self): + class Ch: + pass + + ch = Ch() + ch.canonical_markdown = _md_with_n_inline_images( + MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER + ) + self.assertFalse(chapter_eligible_for_cover_by_inline_body_image_count(ch)) + + ch.canonical_markdown = _md_with_n_inline_images( + MIN_INLINE_BODY_IMAGES_FOR_CHAPTER_COVER + 1 + ) + self.assertTrue(chapter_eligible_for_cover_by_inline_body_image_count(ch)) + + def test_chapter_needs_cover_enqueue_requires_count(self): + class Ch: + cover_asset_id = None + + ch = Ch() + ch.canonical_markdown = _md_with_n_inline_images(2) + self.assertFalse(chapter_needs_cover_enqueue(ch)) + + ch.canonical_markdown = _md_with_n_inline_images(4) + self.assertTrue(chapter_needs_cover_enqueue(ch)) diff --git a/api/tests/test_markdown_sanitize.py b/api/tests/test_markdown_sanitize.py new file mode 100644 index 0000000..696bc7e --- /dev/null +++ b/api/tests/test_markdown_sanitize.py @@ -0,0 +1,27 @@ +import unittest + +from app.features.memoir.markdown_sanitize import ( + sanitize_story_for_chapter_compose, + strip_leading_heading_if_matches_title, + strip_markdown_tables, +) + + +class MarkdownSanitizeTest(unittest.TestCase): + def test_strip_table_block(self): + md = "第一段\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n第二段" + out = strip_markdown_tables(md) + self.assertNotIn("| a |", out) + self.assertIn("第一段", out) + self.assertIn("第二段", out) + + def test_strip_heading_when_matches_title(self): + body = "## 童年\n\n正文" + out = strip_leading_heading_if_matches_title(body, "童年") + self.assertEqual(out, "正文") + + def test_sanitize_compose(self): + raw = "| x | y |\n|---|---|\n|1|2|\n\n你好" + out = sanitize_story_for_chapter_compose(raw, "T") + self.assertIn("你好", out) + self.assertNotIn("| x |", out) diff --git a/api/tests/test_reading_segments_dedupe.py b/api/tests/test_reading_segments_dedupe.py new file mode 100644 index 0000000..9ec628e --- /dev/null +++ b/api/tests/test_reading_segments_dedupe.py @@ -0,0 +1,89 @@ +"""reading_segments:正文已含与主插图相同 asset 时不重复 cover_image。""" + +import unittest + +from app.features.memoir.helpers import build_reading_segments +from app.features.memoir.reading_segment_materialize import ( + build_reading_segments_snapshot, + resolve_reading_segments_for_chapter_detail, +) + + +class _Intent: + def __init__(self, asset_id: str): + self.intent_role = "primary" + self.asset_id = asset_id + self.caption = "cap" + self.prompt_brief = None + self.style_profile = None + self.error = None + self.status = "completed" + self.created_at = None + self.updated_at = None + + +class _Story: + def __init__(self, sid: str, body: str, asset_id: str): + self.id = sid + self.title = "T" + self.canonical_markdown = body + self.image_intents = [_Intent(asset_id)] + + +class _Link: + def __init__(self, order: int, story: _Story): + self.order_index = order + self.story = story + + +class _Ch: + def __init__(self, story: _Story): + self.story_links = [_Link(0, story)] + + +class ReadingSegmentsDedupeTest(unittest.TestCase): + def test_omits_cover_when_primary_asset_in_body(self): + aid = "c67d11c5-23b7-4ab9-91ed-c05b5a27a12b" + body = f"正文\n\n![cap](asset://{aid})\n\n更多" + st = _Story("s1", body, aid) + ch = _Ch(st) + asset_url_map = {aid: "https://cos.example/img.png"} + segs = build_reading_segments(ch, asset_url_map=asset_url_map) + self.assertEqual(len(segs), 1) + self.assertIn("https://cos.example", segs[0]["body_markdown"]) + self.assertIsNone(segs[0]["cover_image"]) + + def test_keeps_cover_when_primary_not_in_body(self): + aid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + body = "仅文字无图" + st = _Story("s1", body, aid) + ch = _Ch(st) + asset_url_map = {aid: "https://cos.example/cover.png"} + segs = build_reading_segments(ch, asset_url_map=asset_url_map) + self.assertEqual(len(segs), 1) + self.assertIsNotNone(segs[0]["cover_image"]) + self.assertTrue(segs[0]["cover_image"].get("url")) + + def test_persisted_snapshot_hydrate_matches_live_materialize(self): + aid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + body = "仅文字无图" + st = _Story("s1", body, aid) + ch = _Ch(st) + ch.reading_segments_json = build_reading_segments_snapshot(ch) + ch.markdown_compose_dirty = False + asset_url_map = {aid: "https://cos.example/cover.png"} + live = build_reading_segments(ch, asset_url_map=asset_url_map) + via_snapshot = resolve_reading_segments_for_chapter_detail( + ch, asset_url_map=asset_url_map + ) + self.assertEqual(len(live), len(via_snapshot)) + self.assertEqual(live[0]["story_id"], via_snapshot[0]["story_id"]) + self.assertEqual(live[0]["body_markdown"], via_snapshot[0]["body_markdown"]) + self.assertEqual( + (live[0]["cover_image"] or {}).get("url"), + (via_snapshot[0]["cover_image"] or {}).get("url"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/api/tests/test_session_history.py b/api/tests/test_session_history.py index 07b2bb1..3d5dc51 100644 --- a/api/tests/test_session_history.py +++ b/api/tests/test_session_history.py @@ -39,6 +39,19 @@ class SegmentsToRedisHistoryTest(unittest.TestCase): self.assertEqual(h[0]["messageType"], "audio") self.assertEqual(h[0]["voiceSessionId"], "vs-9") + def test_voice_segment_includes_duration_seconds_when_set(self): + seg = Segment( + id="s1", + conversation_id="c1", + transcript_text="嗯", + audio_url="audio-segment:vs-9:0", + audio_duration_seconds=12, + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + agent_response=None, + ) + h = segments_to_redis_history([seg]) + self.assertEqual(h[0]["durationSeconds"], 12) + if __name__ == "__main__": unittest.main() diff --git a/api/tests/test_story_backfill.py b/api/tests/test_story_backfill.py new file mode 100644 index 0000000..12242c5 --- /dev/null +++ b/api/tests/test_story_backfill.py @@ -0,0 +1,20 @@ +import unittest + +from app.features.story.backfill import backfill_image_into_markdown + + +class StoryBackfillTest(unittest.TestCase): + def test_appends_image_at_end_with_prompt_brief_alt(self): + md = "第一段\n\n第二段" + out = backfill_image_into_markdown(md, "aid-1", "院子里的藤椅") + self.assertTrue(out.endswith("](asset://aid-1)\n")) + self.assertIn("第二段", out) + self.assertLess(out.index("第二段"), out.index("![")) + + def test_empty_markdown_is_only_image_ref(self): + out = backfill_image_into_markdown("", "x", "提示") + self.assertEqual(out, "![提示](asset://x)") + + def test_escapes_bracket_in_alt(self): + out = backfill_image_into_markdown("正文", "a", "说明]尾") + self.assertIn(r"说明\]", out) diff --git a/api/tests/test_story_image_tasks.py b/api/tests/test_story_image_tasks.py index 681a0d9..a1505f5 100644 --- a/api/tests/test_story_image_tasks.py +++ b/api/tests/test_story_image_tasks.py @@ -73,7 +73,6 @@ class GenerateStoryImageTaskTest(unittest.TestCase): style_profile="watercolor", story_version_id="ver-1", caption="主插图", - source_span={"paragraph_index": 0}, status="processing", ) story = SimpleNamespace( @@ -89,7 +88,7 @@ class GenerateStoryImageTaskTest(unittest.TestCase): id="intent-1", story_version_id="ver-1", caption="主插图", - source_span={"paragraph_index": 0}, + prompt_brief="院子里的藤椅", status="processing", style_profile="watercolor", claim_token="claim-token", diff --git a/api/tests/test_tts_cos_keys.py b/api/tests/test_tts_cos_keys.py new file mode 100644 index 0000000..256d6b3 --- /dev/null +++ b/api/tests/test_tts_cos_keys.py @@ -0,0 +1,39 @@ +"""COS key collection for conversation TTS URLs.""" + +from app.core.cos_url_keys import ( + collect_cos_keys_from_conversation_history, + collect_cos_keys_from_tts_url_list, +) + + +def test_collect_from_history_empty(): + assert collect_cos_keys_from_conversation_history([]) == set() + + +def test_collect_from_history_ai_tts_urls(monkeypatch): + monkeypatch.setattr( + "app.core.cos_url_keys.settings.tencent_cos_bucket", + "bucket", + raising=False, + ) + monkeypatch.setattr( + "app.core.cos_url_keys.settings.tencent_cos_region", + "ap-guangzhou", + raising=False, + ) + url = "https://bucket.cos.ap-guangzhou.myqcloud.com/conversations/c1/tts/a.mp3" + hist = [ + {"role": "human", "content": "hi", "messageType": "text"}, + { + "role": "ai", + "content": "hello", + "messageType": "text", + "ttsAudioUrls": [url], + }, + ] + keys = collect_cos_keys_from_conversation_history(hist) + assert keys == {"conversations/c1/tts/a.mp3"} + + +def test_collect_from_tts_url_list_none(): + assert collect_cos_keys_from_tts_url_list(None) == set() diff --git a/app-expo/app.config.ts b/app-expo/app.config.ts index 2af63de..ba6107c 100644 --- a/app-expo/app.config.ts +++ b/app-expo/app.config.ts @@ -135,6 +135,8 @@ export default ({ config }: ConfigContext): ExpoConfig => { }, android: { ...config?.android, + // Reverse-DNS; no hyphens (Android package name rules). Matches iOS bundle id intent. + package: 'com.anonymous.appexpo', adaptiveIcon: { backgroundColor: '#E6F4FE', foregroundImage: './assets/images/android-icon-foreground.png', diff --git a/app-expo/src/app/(main)/chapter/[id].tsx b/app-expo/src/app/(main)/chapter/[id].tsx index af8b324..780352e 100644 --- a/app-expo/src/app/(main)/chapter/[id].tsx +++ b/app-expo/src/app/(main)/chapter/[id].tsx @@ -1,3 +1,5 @@ +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; import { router, useLocalSearchParams } from 'expo-router'; import { Settings, Trash2, X } from 'lucide-react-native'; import React, { useState } from 'react'; @@ -18,7 +20,11 @@ import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; import { ScreenGutter } from '@/constants/layout'; -import { MarkdownRenderer } from '@/features/memoir/markdown-renderer'; +import { + MarkdownRenderer, + ReadingMarkdownHorizontalRuleInColumn, +} from '@/features/memoir/markdown-renderer'; +import type { ChapterReadingSegment } from '@/features/memoir/types'; import { cn } from '@/lib/utils'; import { useChapterDetail, useDeleteChapter } from '@/features/memoir/hooks'; @@ -34,6 +40,98 @@ const READING_COLORS = { outlineVariant: 'rgba(121, 117, 127, 0.3)', }; +/** 章节封面 hero:全宽无水平留白,与 MarkdownRenderer 内 hero 同比例与渐变 */ +function ChapterCoverHero({ + coverImageUrl, + backgroundColor, +}: { + coverImageUrl: string; + backgroundColor: string; +}) { + const [loadFailed, setLoadFailed] = useState(false); + if (loadFailed) { + return null; + } + return ( + + setLoadFailed(true)} + style={{ + width: '100%', + height: '100%', + objectFit: 'cover', + }} + /> + + + ); +} + +/** 故事段末配图:与 Markdown 内嵌图同比例圆角;无独立主图时不渲染(避免与正文 asset 重复已由后端省略) */ +function StorySegmentCover({ + asset, + contentWidth, +}: { + asset: NonNullable; + contentWidth: number; +}) { + const url = asset?.url; + const wrap = { + maxWidth: contentWidth, + alignSelf: 'center' as const, + width: '100%' as const, + paddingHorizontal: 20, + marginTop: 20, + marginBottom: 0, + }; + if (url) { + return ( + + + + ); + } + return ( + + + + ); +} + type FontSize = 'small' | 'default' | 'large'; type FontFamily = 'serif' | 'sans'; type BackgroundTheme = 'white' | 'sepia'; @@ -306,6 +404,9 @@ export default function ChapterScreen() { const coverImageUrl = chapter.cover_image?.url ?? null; const canonicalMarkdown = (chapter.canonical_markdown ?? '').trim(); const renderedAssets = chapter.rendered_assets ?? chapter.images ?? []; + const readingSegments = chapter.reading_segments; + const useReadingSegments = + Array.isArray(readingSegments) && readingSegments.length > 0; /** 与 ScreenHeader(reading、useSafeArea)可视高度对齐,避免返回栏与首屏内容之间出现空隙 */ const headerOccupiedHeight = Math.max(insets.top, 12) + 56; @@ -335,7 +436,7 @@ export default function ChapterScreen() { variant="reading" absolute backgroundColor={bgColor} - title={chapter.title} + title={useReadingSegments ? '' : chapter.title} backAccessibilityLabel={t('chapterReading.back')} right={ @@ -382,15 +483,76 @@ export default function ChapterScreen() { showsVerticalScrollIndicator={false} style={{ backgroundColor: bgColor }} > - + {useReadingSegments ? ( + + {coverImageUrl ? ( + + ) : null} + + + {chapter.title} + + + {readingSegments!.map((seg, i) => ( + + + {seg.cover_image ? ( + + ) : null} + {i < readingSegments!.length - 1 ? ( + + ) : null} + + ))} + + ) : ( + + )} void; + onPausePlayback: () => void; }) { const isUser = item.senderType === 'user'; - const isVoice = item.messageType === 'voice'; + const isVoice = isVoiceMessage(item); return ( @@ -140,19 +139,39 @@ function MessageBubble({ > {isUser ? meLabel : agentName} - - {isVoice ? ( + {isVoice ? ( + { + if (!item.audioUri) return; + if (playbackIsPlaying && currentPlaybackUri === item.audioUri) { + onPausePlayback(); + } else { + onPlayVoiceExclusive(item.audioUri); + } + }} /> - ) : ( + + ) : ( + {item.content} - )} - + + )} ); @@ -250,33 +269,18 @@ function VoiceMessageBubble({ durationSeconds, audioUri, isUser, + isPlaying, + onPlayPress, }: { durationSeconds: number; audioUri?: string; isUser: boolean; + isPlaying: boolean; + onPlayPress: () => void; }) { - const player = useAudioPlayer(null); - const status = useAudioPlayerStatus(player); - - useEffect(() => { - const { playing, currentTime, duration } = status; - const finished = !playing && duration > 0 && currentTime >= duration - 0.05; - if (finished) { - void audioFocus.release(); - } - }, [status]); - - const handlePlayPause = useCallback(async () => { - if (!audioUri) return; - if (status.playing) { - player.pause(); - } else { - const acquired = await audioFocus.acquireForPlayback(); - if (!acquired) return; - player.replace(audioUri); - player.play(); - } - }, [audioUri, player, status.playing]); + const handlePlayPause = useCallback(() => { + onPlayPress(); + }, [onPlayPress]); return ( @@ -609,12 +613,44 @@ export default function ConversationScreen() { const { t } = useTranslation('conversation'); const { t: tApp } = useTranslation('app'); const { data: messages } = useMessages(id); - const { enqueueTtsAudio, status: playerStatus } = usePlayer(); + const { + enqueue, + enqueueExclusive, + stop, + status: playerStatus, + currentSource, + } = usePlayer(); + + const handleTtsSegment = useCallback( + (p: { audioBase64?: string; audioUrl?: string }) => { + if (p.audioBase64) { + void enqueue({ + uri: `data:audio/mp3;base64,${p.audioBase64}`, + label: 'TTS', + }); + } else if (p.audioUrl) { + void enqueue({ uri: p.audioUrl, label: 'TTS' }); + } + }, + [enqueue], + ); + + const handlePlayVoiceExclusive = useCallback( + (uri: string) => { + void enqueueExclusive({ uri, label: 'voice' }); + }, + [enqueueExclusive], + ); + + const handlePausePlayback = useCallback(() => { + void stop(); + }, [stop]); + const { connectionState, streamingMessage, sendText, sendVoiceMessage } = useRealtimeSession({ conversationId: id ?? '', enabled: !!id, - onTtsAudio: enqueueTtsAudio, + onTtsSegment: handleTtsSegment, }); const handleRecordingComplete = useCallback( @@ -643,30 +679,6 @@ export default function ConversationScreen() { const onShow = (e: { endCoordinates: { height: number } }) => { setIsKeyboardVisible(true); setKeyboardHeight(e.endCoordinates.height); - // #region agent log - void fetch( - 'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Debug-Session-Id': 'a82a4c', - }, - body: JSON.stringify({ - sessionId: 'a82a4c', - hypothesisId: 'H3', - location: 'ConversationScreen:keyboardShow', - message: 'keyboard open; composer uses insetBottom 0 when text+kb', - data: { - kbH: e.endCoordinates.height, - insetBottom: insets.bottom, - os: Platform.OS, - }, - timestamp: Date.now(), - }), - }, - ).catch(() => {}); - // #endregion InteractionManager.runAfterInteractions(() => { listRef.current?.scrollToEnd({ animated: true }); }); @@ -706,26 +718,6 @@ export default function ConversationScreen() { sendText(text); setInput(''); setInputResetKey((k) => k + 1); - // #region agent log - void fetch( - 'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Debug-Session-Id': 'a82a4c', - }, - body: JSON.stringify({ - sessionId: 'a82a4c', - hypothesisId: 'H4', - location: 'ConversationScreen:handleSend', - message: 'cleared input + bumped textInputKey', - data: { sentLen: text.length }, - timestamp: Date.now(), - }), - }, - ).catch(() => {}); - // #endregion }; const connectionLabel = @@ -752,14 +744,6 @@ export default function ConversationScreen() { title={ {tApp('name')} - {playerStatus === 'playing' && ( - - )} )} onContentSizeChange={() => @@ -930,6 +918,7 @@ const styles = StyleSheet.create({ alignItems: 'flex-start', gap: 10, marginBottom: 16, + overflow: 'visible', }, messageRowReverse: { flexDirection: 'row-reverse', diff --git a/app-expo/src/core/ws/client.ts b/app-expo/src/core/ws/client.ts index ca112dd..0ede1a5 100644 --- a/app-expo/src/core/ws/client.ts +++ b/app-expo/src/core/ws/client.ts @@ -46,6 +46,9 @@ function mapServerMessage(raw: RawServerMessage): WsEvent | null { kind: 'tts_audio_received', conversationId: cid, audioBase64: d.audio_base64 as string, + audioUrl: d.audio_url as string | undefined, + index: d.index as number | undefined, + total: d.total as number | undefined, }; case 'end_conversation': diff --git a/app-expo/src/core/ws/types.ts b/app-expo/src/core/ws/types.ts index ba84978..c08cdbc 100644 --- a/app-expo/src/core/ws/types.ts +++ b/app-expo/src/core/ws/types.ts @@ -60,6 +60,9 @@ export interface TtsAudioReceivedEvent { kind: 'tts_audio_received'; conversationId: string; audioBase64: string; + audioUrl?: string; + index?: number; + total?: number; } export interface ConversationEndedEvent { diff --git a/app-expo/src/features/conversation/conversation-messages-repository.ts b/app-expo/src/features/conversation/conversation-messages-repository.ts new file mode 100644 index 0000000..1b10e2e --- /dev/null +++ b/app-expo/src/features/conversation/conversation-messages-repository.ts @@ -0,0 +1,44 @@ +import { conversationApi } from './api'; +import { isVoiceMessage, type MessageItem } from './types'; +import { voiceSegmentStore } from '@/features/voice/voice-segment-store'; + +/** + * 会话消息读端口:REST 历史 + 本机语音回放路径装配。 + * 单一入口,供 React Query 与测试注入替代实现。 + */ +export interface ConversationMessagesPort { + loadMessages(conversationId: string): Promise; +} + +async function attachLocalVoicePlayback( + conversationId: string, + server: MessageItem[], +): Promise { + const playback = + await voiceSegmentStore.listPlaybackForConversation(conversationId); + if (playback.length === 0) return server; + const byVoice = new Map(playback.map((p) => [p.voiceSessionId, p])); + return server.map((m) => { + if (!isVoiceMessage(m)) return m; + const sid = m.voiceSessionId; + if (!sid) return m; + const row = byVoice.get(sid); + if (!row) return m; + const sec = Math.max(1, Math.round(row.durationMs / 1000)); + return { + ...m, + audioUri: row.fileUri, + durationSeconds: + m.durationSeconds != null && m.durationSeconds > 0 + ? m.durationSeconds + : sec, + }; + }); +} + +export const conversationMessagesRepository: ConversationMessagesPort = { + async loadMessages(conversationId: string): Promise { + const server = await conversationApi.messages(conversationId); + return attachLocalVoicePlayback(conversationId, server); + }, +}; diff --git a/app-expo/src/features/conversation/event-handlers.ts b/app-expo/src/features/conversation/event-handlers.ts index a6a6154..07b4d28 100644 --- a/app-expo/src/features/conversation/event-handlers.ts +++ b/app-expo/src/features/conversation/event-handlers.ts @@ -4,7 +4,11 @@ import i18n from '@/i18n'; import type { WsEvent } from '@/core/ws/types'; import { conversationKeys } from './query-keys'; -import type { ConversationListItem, MessageItem } from './types'; +import { + type ConversationListItem, + type MessageItem, + isVoiceMessage, +} from './types'; function nowMs(): number { return Date.now(); @@ -43,14 +47,27 @@ function handleTranscript( if (!old?.length) return old ?? []; const lastUser = [...old].reverse().find((m) => m.senderType === 'user'); const isPendingVoice = - lastUser?.id?.startsWith('pending_voice_') && - lastUser?.messageType === 'voice'; + lastUser?.id?.startsWith('pending_voice_') && isVoiceMessage(lastUser); if (isPendingVoice && lastUser) { - return old.map((m) => - m.id === lastUser.id && event.audioDuration != null - ? { ...m, durationSeconds: Math.round(event.audioDuration) } - : m, - ); + return old.map((m) => { + if (m.id !== lastUser.id) return m; + const serverSec = + event.audioDuration != null && event.audioDuration > 0 + ? Math.round(event.audioDuration) + : null; + const localSec = + m.durationSeconds != null && m.durationSeconds > 0 + ? m.durationSeconds + : undefined; + const nextDuration = serverSec ?? localSec; + return { + ...m, + ...(nextDuration != null && nextDuration > 0 + ? { durationSeconds: nextDuration } + : {}), + ...(event.text?.trim() ? { content: event.text.trim() } : {}), + }; + }); } return old; }); diff --git a/app-expo/src/features/conversation/hooks.ts b/app-expo/src/features/conversation/hooks.ts index 24ce8f9..64d79a5 100644 --- a/app-expo/src/features/conversation/hooks.ts +++ b/app-expo/src/features/conversation/hooks.ts @@ -5,17 +5,20 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { WsConnectionState } from '@/core/ws/types'; import { conversationApi } from './api'; +import { conversationMessagesRepository } from './conversation-messages-repository'; import { conversationKeys } from './query-keys'; import { RealtimeSession, type ErrorCallback, type StreamingTextCallback, + type TtsSegmentPayload, } from './realtime-session'; -import type { - ConversationListItem, - MessageItem, - StreamingAgentMessage, +import { + type ConversationListItem, + type MessageItem, + type StreamingAgentMessage, } from './types'; +import { voiceSegmentStore } from '@/features/voice/voice-segment-store'; // ─── Query hooks ─── @@ -38,7 +41,7 @@ export function useConversationDetail(conversationId: string) { export function useMessages(conversationId: string) { return useQuery({ queryKey: conversationKeys.messages(conversationId), - queryFn: () => conversationApi.messages(conversationId), + queryFn: () => conversationMessagesRepository.loadMessages(conversationId), enabled: !!conversationId, }); } @@ -76,7 +79,8 @@ export function useDeleteConversation() { return useMutation({ mutationFn: (conversationId: string) => conversationApi.delete(conversationId), - onSuccess: (_, conversationId) => { + onSuccess: async (_, conversationId) => { + await voiceSegmentStore.clearConversation(conversationId); queryClient.setQueryData( conversationKeys.lists(), (old) => old?.filter((item) => item.id !== conversationId), @@ -112,7 +116,7 @@ export function useEndConversation() { interface UseRealtimeSessionOptions { conversationId: string; enabled?: boolean; - onTtsAudio?: (audioBase64: string) => void; + onTtsSegment?: (payload: TtsSegmentPayload) => void; } const MIN_RECORDING_DURATION_SEC = 1; @@ -137,7 +141,7 @@ interface RealtimeSessionState { export function useRealtimeSession({ conversationId, enabled = true, - onTtsAudio, + onTtsSegment, }: UseRealtimeSessionOptions): RealtimeSessionState { const queryClient = useQueryClient(); const sessionRef = useRef(null); @@ -170,7 +174,7 @@ export function useRealtimeSession({ conversationId, queryClient, onStreamingText: handleStreamingText, - onTtsAudio, + onTtsSegment, onError: handleError, onStateChange: setConnectionState, }); @@ -190,7 +194,7 @@ export function useRealtimeSession({ queryClient, handleStreamingText, handleError, - onTtsAudio, + onTtsSegment, ]); const sendText = useCallback( @@ -249,6 +253,12 @@ export function useRealtimeSession({ } const localId = `pending_voice_${Date.now()}`; + await voiceSegmentStore.recordSentSegment({ + voiceSessionId, + conversationId, + fileUri: uri, + durationMs, + }); queryClient.setQueryData( conversationKeys.messages(conversationId), (old) => { @@ -259,6 +269,7 @@ export function useRealtimeSession({ senderType: 'user', timestamp: Date.now(), messageType: 'voice', + voiceSessionId, durationSeconds: durationSec, audioUri: uri, }; diff --git a/app-expo/src/features/conversation/realtime-session.ts b/app-expo/src/features/conversation/realtime-session.ts index 933db80..ecdafb1 100644 --- a/app-expo/src/features/conversation/realtime-session.ts +++ b/app-expo/src/features/conversation/realtime-session.ts @@ -14,11 +14,18 @@ import type { ConversationListItem, MessageItem } from './types'; export type StreamingTextCallback = (text: string, isComplete: boolean) => void; export type ErrorCallback = (message: string, code?: string) => void; +/** WebSocket `tts_audio`:服务端可能只带 base64、只带 COS URL,或两者都有 */ +export type TtsSegmentPayload = { + audioBase64?: string; + audioUrl?: string; +}; + interface RealtimeSessionOptions { conversationId: string; queryClient: QueryClient; onStreamingText?: StreamingTextCallback; - onTtsAudio?: (audioBase64: string) => void; + /** 收到 TTS 片段时入队播放(与「气泡上的手动朗读按钮」无关) */ + onTtsSegment?: (payload: TtsSegmentPayload) => void; onError?: ErrorCallback; onStateChange?: WsStateListener; } @@ -39,7 +46,7 @@ export class RealtimeSession { private conversationId: string; private queryClient: QueryClient; private onStreamingText?: StreamingTextCallback; - private onTtsAudio?: (audioBase64: string) => void; + private onTtsSegment?: (payload: TtsSegmentPayload) => void; private onError?: ErrorCallback; private unsubEvent: (() => void) | null = null; private unsubState: (() => void) | null = null; @@ -51,7 +58,7 @@ export class RealtimeSession { this.conversationId = options.conversationId; this.queryClient = options.queryClient; this.onStreamingText = options.onStreamingText; - this.onTtsAudio = options.onTtsAudio; + this.onTtsSegment = options.onTtsSegment; this.onError = options.onError; this.unsubEvent = this.client.onEvent(this.handleEvent); @@ -121,7 +128,14 @@ export class RealtimeSession { } if (event.kind === 'tts_audio_received') { - this.onTtsAudio?.(event.audioBase64); + const b64 = event.audioBase64?.trim(); + const url = event.audioUrl?.trim(); + if (b64 || url) { + this.onTtsSegment?.({ + audioBase64: b64 || undefined, + audioUrl: url || undefined, + }); + } return; } diff --git a/app-expo/src/features/conversation/types.ts b/app-expo/src/features/conversation/types.ts index 0ba3b83..1b6c3ec 100644 --- a/app-expo/src/features/conversation/types.ts +++ b/app-expo/src/features/conversation/types.ts @@ -48,16 +48,23 @@ export interface EndConversationResponse { duration_seconds: number; } +/** 后端历史里语音为 `audio`,与本地乐观更新的 `voice` 同义 */ +export function isVoiceMessage(m: Pick): boolean { + return m.messageType === 'voice' || m.messageType === 'audio'; +} + export interface MessageItem { id: string; conversationId: string; content: string; senderType: 'user' | 'assistant'; timestamp: number; - messageType: 'text' | 'voice'; - /** 语音消息时长(秒),仅 messageType='voice' 时有值 */ + messageType: 'text' | 'voice' | 'audio'; + /** 与 WS audio_segment 的 voice_session_id 一致,用于关联本地录音文件 */ + voiceSessionId?: string; + /** 语音消息时长(秒),语音消息(voice/audio)时有值 */ durationSeconds?: number; - /** 语音文件本地 URI,用于回放,仅 messageType='voice' 时有值 */ + /** 语音文件本地 URI,用于回放,仅本地乐观语音条有值 */ audioUri?: string; } diff --git a/app-expo/src/features/memoir/markdown-renderer.tsx b/app-expo/src/features/memoir/markdown-renderer.tsx index 087fe7b..c5cdee9 100644 --- a/app-expo/src/features/memoir/markdown-renderer.tsx +++ b/app-expo/src/features/memoir/markdown-renderer.tsx @@ -98,6 +98,85 @@ const READING_COLORS = { horizontalRule: 'rgba(121, 117, 127, 0.42)', }; +const HR_HEIGHT = Platform.OS === 'android' ? 2 : 1; + +/** 与 Markdown `---` / hr 规则完全一致,供分段阅读插在故事之间 */ +export function ReadingMarkdownHorizontalRule() { + return ( + + ); +} + +/** 章末细装饰线(与 MarkdownRenderer showBottomDivider 一致) */ +export function ReadingMarkdownEndDivider() { + return ( + + + + ); +} + +/** 与正文列宽对齐的 `---`(避免单独一行时宽度为 0) */ +export function ReadingMarkdownHorizontalRuleInColumn({ + contentWidth, +}: { + contentWidth: number; +}) { + return ( + + + + + + ); +} + +export function ReadingMarkdownEndDividerInColumn({ + contentWidth, +}: { + contentWidth: number; +}) { + return ( + + + + + + ); +} + const FONT_FAMILIES = { serif: Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ?? @@ -121,6 +200,10 @@ export interface MarkdownRendererProps { fontFamily: 'serif' | 'sans'; backgroundColor: string; contentWidth: number; + /** 多故事分段时仅首段下沉首字 */ + enableDropCap?: boolean; + /** 文末装饰分隔线(分段中间可关) */ + showBottomDivider?: boolean; } export function MarkdownRenderer({ @@ -131,6 +214,8 @@ export function MarkdownRenderer({ fontFamily, backgroundColor, contentWidth, + enableDropCap = true, + showBottomDivider = true, }: MarkdownRendererProps) { const processedMarkdown = React.useMemo( () => replaceImagePlaceholders(markdown, renderedAssets), @@ -213,7 +298,7 @@ export function MarkdownRenderer({ width: '100%', alignSelf: 'stretch', backgroundColor: READING_COLORS.horizontalRule, - height: Platform.OS === 'android' ? 2 : StyleSheet.hairlineWidth, + height: HR_HEIGHT, marginVertical: 28, }, image: { @@ -249,16 +334,9 @@ export function MarkdownRenderer({ return { hr: (node: { key: string }) => ( - + + + ), image: ( node: { key: string; attributes: Record }, @@ -330,6 +408,7 @@ export function MarkdownRenderer({ const afterWs = content.slice(leadingWs.length); const docFirstParagraph = isFirstParagraphUnderBody(paragraph, body); const wantDropCap = + enableDropCap && docFirstParagraph && !dropCapConsumedRef.current && afterWs.length > 0; @@ -377,7 +456,7 @@ export function MarkdownRenderer({ ); }, }; - }, [bodySize, fontFam, lineHeight, markdownStyles.image]); + }, [bodySize, enableDropCap, fontFam, lineHeight, markdownStyles.image]); return ( <> @@ -435,7 +514,7 @@ export function MarkdownRenderer({ ) : null} - {processedMarkdown.trim().length > 0 && ( + {showBottomDivider && processedMarkdown.trim().length > 0 && ( void; + /** Current playback source URI (file, https, or data URL). */ + currentSource: string | null; enqueue: (item: PlaybackItem) => void; + /** Replace queue and play this item (e.g. user voice bubble vs other sources). */ + enqueueExclusive: (item: PlaybackItem) => Promise; stop: () => void; } @@ -29,8 +32,12 @@ export function usePlayer(): UsePlayerResult { const isPlayingRef = useRef(false); const wasBlockedByRecorderRef = useRef(false); const isPlayNextInProgressRef = useRef(false); + /** 同步反映「当前是否正在播放某条 URI」;enqueue 不能依赖 state,否则 await stop() 后仍为陈旧闭包。 */ + const playbackActiveUriRef = useRef(null); + /** 当前 source 是否已进入过 playing=true,避免换源瞬间 playerStatus 仍带上一首的 duration 而误判「已播完」。 */ + const trackHasPlayedRef = useRef(false); - const player = useAudioPlayer(currentSource); + const player = useAudioPlayer(currentSource, { downloadFirst: false }); const playerStatus = useAudioPlayerStatus(player); // Start playback when a new source is set @@ -46,6 +53,7 @@ export function usePlayer(): UsePlayerResult { isPlayNextInProgressRef.current = true; try { if (queueRef.current.length === 0) { + playbackActiveUriRef.current = null; setCurrentSource(null); setStatus('idle'); setQueueLength(0); @@ -64,20 +72,33 @@ export function usePlayer(): UsePlayerResult { const next = queueRef.current.shift()!; setQueueLength(queueRef.current.length); setStatus('playing'); + trackHasPlayedRef.current = false; + playbackActiveUriRef.current = next.uri; setCurrentSource(next.uri); } finally { isPlayNextInProgressRef.current = false; } }, []); - // Detect playback completion → advance queue + useEffect(() => { + if (playerStatus.playing) { + trackHasPlayedRef.current = true; + } + }, [playerStatus.playing]); + + // Detect playback completion → advance queue(必须曾 playing,避免换源瞬间沿用上一条的 duration/currentTime) useEffect(() => { if (!currentSource || !isPlayingRef.current) return; const { playing, currentTime, duration } = playerStatus; - const finished = !playing && duration > 0 && currentTime >= duration - 0.05; + const finished = + trackHasPlayedRef.current && + !playing && + duration > 0 && + currentTime >= duration - 0.05; if (finished) { + trackHasPlayedRef.current = false; isPlayingRef.current = false; playNext(); } @@ -107,19 +128,31 @@ export function usePlayer(): UsePlayerResult { queueRef.current.push(item); setQueueLength(queueRef.current.length); - if (status === 'idle' && !currentSource) { + const shouldKick = + queueRef.current.length === 1 && playbackActiveUriRef.current === null; + + if (shouldKick) { await playNext(); } }, - [status, currentSource, playNext], + [playNext], ); - const enqueueTtsAudio = useCallback( - (audioBase64: string) => { - const uri = `data:audio/mp3;base64,${audioBase64}`; - enqueue({ uri, label: 'TTS' }); + const enqueueExclusive = useCallback( + async (item: PlaybackItem) => { + queueRef.current = [item]; + setQueueLength(1); + isPlayingRef.current = false; + if (player) { + player.pause(); + } + playbackActiveUriRef.current = null; + setCurrentSource(null); + setStatus('idle'); + await audioFocus.release(); + await playNext(); }, - [enqueue], + [player, playNext], ); const stop = useCallback(async () => { @@ -131,10 +164,18 @@ export function usePlayer(): UsePlayerResult { player.pause(); } + playbackActiveUriRef.current = null; setCurrentSource(null); setStatus('idle'); await audioFocus.release(); }, [player]); - return { status, queueLength, enqueueTtsAudio, enqueue, stop }; + return { + status, + queueLength, + currentSource, + enqueue, + enqueueExclusive, + stop, + }; } diff --git a/app-expo/src/features/voice/types.ts b/app-expo/src/features/voice/types.ts index 13e4fcb..31bdd7d 100644 --- a/app-expo/src/features/voice/types.ts +++ b/app-expo/src/features/voice/types.ts @@ -11,7 +11,7 @@ export interface SegmenterConfig { fixedDurationMs: number; } -// ─── Segment outbox ─── +// ─── 本地语音分段(outbox + 已发送可回放元数据,见 voice-segment-store)─── export type SegmentOutboxStatus = 'pending' | 'sending' | 'sent' | 'failed'; diff --git a/app-expo/src/features/voice/segment-outbox.ts b/app-expo/src/features/voice/voice-segment-store.ts similarity index 54% rename from app-expo/src/features/voice/segment-outbox.ts rename to app-expo/src/features/voice/voice-segment-store.ts index 88880f0..676d3f0 100644 --- a/app-expo/src/features/voice/segment-outbox.ts +++ b/app-expo/src/features/voice/voice-segment-store.ts @@ -18,9 +18,29 @@ const CREATE_TABLE_SQL = ` let initialized = false; +async function migrateLegacyVoiceMessageLocal(): Promise { + const rows = await querySql<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name='voice_message_local'`, + ); + if (rows.length === 0) return; + const now = Date.now(); + await executeSql( + `INSERT OR IGNORE INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at) + SELECT conversation_id, voice_session_id, 0, file_uri, duration_ms, 'sent', 0, ? + FROM voice_message_local`, + [now], + ); + await executeSql(`DROP TABLE IF EXISTS voice_message_local`); +} + async function ensureTable(): Promise { if (initialized) return; await executeSql(CREATE_TABLE_SQL); + await executeSql( + `CREATE UNIQUE INDEX IF NOT EXISTS uq_segment_outbox_voice_session_segment + ON segment_outbox(voice_session_id, segment_index)`, + ); + await migrateLegacyVoiceMessageLocal(); initialized = true; } @@ -38,14 +58,17 @@ function mapRow(row: Record): SegmentOutboxEntry { }; } +export interface VoicePlaybackRow { + voiceSessionId: string; + fileUri: string; + durationMs: number; +} + /** - * SQLite-backed outbox for voice segments. - * Stores metadata + queue state only — audio files live on the local filesystem. - * - * State machine: pending → sending → sent | failed - * Single writer pattern enforced by serializing all writes through this module. + * 本地语音分段:outbox(pending→sent)与可回放元数据共用同一张表。 + * 音频文件在文件系统;`status=sent` 行保留用于按 voice_session_id 关联 REST 历史。 */ -export const segmentOutbox = { +export const voiceSegmentStore = { async enqueue( entry: Omit< SegmentOutboxEntry, @@ -68,6 +91,53 @@ export const segmentOutbox = { return result.lastInsertRowId; }, + /** 发送成功后写入(或覆盖)同一条 voice+segment,用于回放与 outbox 终态统一 */ + async recordSentSegment(entry: { + conversationId: string; + voiceSessionId: string; + segmentIndex?: number; + fileUri: string; + durationMs: number; + }): Promise { + await ensureTable(); + const segmentIndex = entry.segmentIndex ?? 0; + const now = Date.now(); + await executeSql( + `INSERT INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at) + VALUES (?, ?, ?, ?, ?, 'sent', 0, ?) + ON CONFLICT(voice_session_id, segment_index) DO UPDATE SET + conversation_id = excluded.conversation_id, + file_uri = excluded.file_uri, + duration_ms = excluded.duration_ms, + status = 'sent', + retry_count = 0`, + [ + entry.conversationId, + entry.voiceSessionId, + segmentIndex, + entry.fileUri, + entry.durationMs, + now, + ], + ); + }, + + async listPlaybackForConversation( + conversationId: string, + ): Promise { + await ensureTable(); + const rows = await querySql>( + `SELECT voice_session_id AS voiceSessionId, file_uri AS fileUri, duration_ms AS durationMs + FROM segment_outbox WHERE conversation_id = ? AND status = 'sent'`, + [conversationId], + ); + return rows.map((r) => ({ + voiceSessionId: r.voiceSessionId as string, + fileUri: r.fileUri as string, + durationMs: r.durationMs as number, + })); + }, + async getPending(conversationId?: string): Promise { await ensureTable(); const sql = conversationId @@ -110,24 +180,18 @@ export const segmentOutbox = { await executeSql(sql, params); }, - async clearSent(conversationId?: string): Promise { - await ensureTable(); - const sql = conversationId - ? `DELETE FROM segment_outbox WHERE status = 'sent' AND conversation_id = ?` - : `DELETE FROM segment_outbox WHERE status = 'sent'`; - const params = conversationId ? [conversationId] : []; - await executeSql(sql, params); - }, - - async clearAll(conversationId: string): Promise { + async clearConversation(conversationId: string): Promise { await ensureTable(); await executeSql(`DELETE FROM segment_outbox WHERE conversation_id = ?`, [ conversationId, ]); }, - /** For testing — reset the initialization flag. */ + /** @internal 测试用 */ _resetForTest(): void { initialized = false; }, } as const; + +/** @deprecated 使用 voiceSegmentStore */ +export const segmentOutbox = voiceSegmentStore; diff --git a/docs/plans/2026-03-19-image-intent-placeholder-removal-design.md b/docs/plans/2026-03-19-image-intent-placeholder-removal-design.md index 2f2eba5..a09c323 100644 --- a/docs/plans/2026-03-19-image-intent-placeholder-removal-design.md +++ b/docs/plans/2026-03-19-image-intent-placeholder-removal-design.md @@ -61,7 +61,6 @@ | `story_id` | string | 所属 story | | `story_version_id` | string | 提取意图时对应的正文版本 | | `intent_role` | string | 固定为 `primary` | -| `source_span` | json/null | 对应正文中的段落或块位置信息 | | `caption` | string | 最终图注候选 | | `prompt_brief` | text | 供出图使用的结构化场景摘要 | | `style_profile` | string/null | 风格策略键 | @@ -203,7 +202,6 @@ flowchart LR - `caption` - `prompt_brief` -- `source_span` - `style_profile` ### 7.3 失败兜底 @@ -234,12 +232,7 @@ flowchart LR ### 8.2 回填位置 -建议由 `source_span` 或 block id 决定回填位置。 - -如果定位失败: - -- 退化为在 story 开头或相关段落后插入图片引用 -- 但仍需创建新版本,不可丢图 +主插图引用追加在**正文末尾**(`![alt](asset://…)` 的 `alt` 使用 `prompt_brief`)。仍需创建新版本,不可丢图。 ## 9. 状态机