DialogueSessionSplitPlan.md 46 KB

对话 Session 创建拆分 - 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal:POST /session 里的"建会话 + 生成开场白"拆成两个独立请求,前端每步独立 loading / 重试;全部重试 user-triggered,无自动重试。

Architecture: 后端新增 POST /session/{id}/greeting,幂等实现 + SELECT ... FOR UPDATE 行锁并发保护。前端 useDialogueEngineinitSessionattachSession + generateGreeting + retryGreeting;TopicDiscussionPreview 的"开始对话"按钮 async 化,进入聊天页后自动触发 greeting;error-card 按 HTTP status 区分可恢复 / 不可恢复(404/409 换"返回重开")。

Tech Stack:

  • 后端:FastAPI + SQLAlchemy async + MySQL (asyncmy) + pytest/pytest-asyncio
  • 前端:Vue 3 + TypeScript + composables (无测试框架,验证靠 pnpm type-check + 手动联调)

Spec: /Users/buoy/Development/gitrepo/PPT/doc/DialogueSessionSplitDesign.md


File Structure

后端 (/Users/buoy/Development/gitrepo/cococlass-english-speaking-api)

路径 改动
app/service/speaking/dialogue_service.py create_session() 拆为 create_session_only() + generate_greeting()
app/api/dialogue.py /session 响应去掉 aiMessage;新增 POST /session/{session_id}/greeting 路由
tests/service/speaking/test_dialogue_service_greeting.py 新建:service 层幂等、行锁、lookup 逻辑单测
tests/api/__init__.py 新建(空文件,包标记)
tests/api/test_dialogue_greeting.py 新建:端点层 200/404/409 集成测试

前端 (/Users/buoy/Development/gitrepo/PPT)

路径 改动
src/types/englishSpeaking.ts SessionInfo 去掉 aiMessage;PreviewChatMessageunrecoverable?: boolean
src/views/Editor/EnglishSpeaking/services/llmService.ts 新增 DialogueApiError + createDialogueApi() 工厂;DialogueAPI 接口扩 generateGreeting;Real/Mock 都实现
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts initSessionattachSession + generateGreeting(含 inflight 锁 + AbortController) + retryGreeting;onUnmounted 追加 abort
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue sessionInfo prop + restart emit;onMounted 改为 attachSession + generateGreeting;error-card 按 unrecoverable 三路分支;handleRestart 改为 emit
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue startDialogue async 化,持有 sessionCreating/sessionError/preparedSession;按钮 loading/error UI;监听 @restart

Task 1: 后端 - DialogueService 拆分与幂等实现

Files:

  • Modify: /Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py (拆 create_session 方法)
  • Test: /Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_greeting.py (新建)

  • [ ] Step 1.1: 写 service 层测试

新建 tests/service/speaking/test_dialogue_service_greeting.py:

"""Tests for split session creation + greeting generation."""

from unittest.mock import AsyncMock, MagicMock

import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool

from app.models.dialogue import Base, DialogueMessage, DialogueSession
from app.service.speaking.dialogue_service import DialogueService


@pytest_asyncio.fixture
async def db_session():
    """In-memory SQLite for fast tests. Uses SQLAlchemy core types that work on both MySQL and SQLite."""
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        poolclass=StaticPool,
        connect_args={"check_same_thread": False},
    )
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
    async with SessionLocal() as session:
        yield session
    await engine.dispose()


def _build_service(llm_response: str = "Hi! What's your favorite animal?") -> DialogueService:
    llm = MagicMock()
    llm.chat = AsyncMock(return_value=llm_response)
    return DialogueService(
        asr=MagicMock(),
        llm=llm,
        assessor=MagicMock(),
        storage=MagicMock(),
    )


@pytest.mark.asyncio
async def test_create_session_only_returns_session_without_llm_call(db_session: AsyncSession):
    """create_session_only 只建会话记录,不调 LLM,响应不含 aiMessage。"""
    service = _build_service()
    result = await service.create_session_only(
        db=db_session,
        topic="My favorite animal",
        total_rounds=3,
    )

    assert "sessionId" in result
    assert "aiMessage" not in result
    assert result["totalRounds"] == 3
    assert result["currentRound"] == 1

    # DB 只有 session 无 message
    sessions = (await db_session.execute(select(DialogueSession))).scalars().all()
    assert len(sessions) == 1
    messages = (await db_session.execute(select(DialogueMessage))).scalars().all()
    assert len(messages) == 0

    # LLM 未被调用
    service.llm.chat.assert_not_called()


@pytest.mark.asyncio
async def test_generate_greeting_happy_path(db_session: AsyncSession):
    """对新建 session 首次生成开场白:调 LLM,落 round=1 AI 消息,返回内容。"""
    service = _build_service(llm_response="Hello! Ready to chat?")
    created = await service.create_session_only(
        db=db_session, topic="Hobbies", total_rounds=3,
    )
    session_uuid = created["sessionId"]

    result = await service.generate_greeting(db=db_session, session_uuid=session_uuid)

    assert result["aiMessage"] == "Hello! Ready to chat?"
    msgs = (await db_session.execute(
        select(DialogueMessage).where(DialogueMessage.role == "ai")
    )).scalars().all()
    assert len(msgs) == 1
    assert msgs[0].round == 1
    assert msgs[0].content == "Hello! Ready to chat?"
    service.llm.chat.assert_called_once()


@pytest.mark.asyncio
async def test_generate_greeting_idempotent_returns_existing(db_session: AsyncSession):
    """重复调 generate_greeting 不重复调 LLM,不重复落消息,返回相同内容。"""
    service = _build_service(llm_response="First greeting")
    created = await service.create_session_only(
        db=db_session, topic="Sports", total_rounds=3,
    )
    session_uuid = created["sessionId"]

    r1 = await service.generate_greeting(db=db_session, session_uuid=session_uuid)
    # 把 mock 改成会抛异常的,证明二次调用不会走到 LLM
    service.llm.chat = AsyncMock(side_effect=AssertionError("LLM should NOT be called on idempotent hit"))
    r2 = await service.generate_greeting(db=db_session, session_uuid=session_uuid)

    assert r1["aiMessage"] == "First greeting"
    assert r2["aiMessage"] == "First greeting"
    msgs = (await db_session.execute(
        select(DialogueMessage).where(DialogueMessage.role == "ai")
    )).scalars().all()
    assert len(msgs) == 1


@pytest.mark.asyncio
async def test_generate_greeting_session_not_found(db_session: AsyncSession):
    """不存在的 session → raise LookupError。"""
    service = _build_service()
    with pytest.raises(LookupError):
        await service.generate_greeting(
            db=db_session, session_uuid="00000000-0000-0000-0000-000000000000",
        )


@pytest.mark.asyncio
async def test_generate_greeting_session_not_active(db_session: AsyncSession):
    """completed session → raise ValueError('not active')。"""
    service = _build_service()
    created = await service.create_session_only(
        db=db_session, topic="X", total_rounds=3,
    )
    session_uuid = created["sessionId"]

    # 手动把 session 置为 completed
    session = (await db_session.execute(
        select(DialogueSession).where(DialogueSession.uuid == session_uuid)
    )).scalar_one()
    session.status = "completed"
    await db_session.commit()

    with pytest.raises(ValueError, match="not active"):
        await service.generate_greeting(db=db_session, session_uuid=session_uuid)
  • Step 1.2: 运行测试确认失败
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/service/speaking/test_dialogue_service_greeting.py -v

Expected: 5 个 test 全部 ERROR 或 FAIL(AttributeError: 'DialogueService' object has no attribute 'create_session_only' 等)。

  • Step 1.3: 实现 create_session_onlygenerate_greeting

修改 app/service/speaking/dialogue_service.py。先看现有 create_session(约 44-97 行),保留原方法不要删(后面任务 2 才改 API 层);新增两个方法并让 create_session 委派给它们以便零破坏。

class DialogueService 里插入两个新方法(比如放在 create_session 之前,44 行上方):

    async def create_session_only(
        self,
        db: AsyncSession,
        topic: str,
        total_rounds: int = 3,
        duration_seconds: int | None = None,
        role_config: dict | None = None,
        user_id: str | None = None,
    ) -> dict:
        """只建会话记录,不调 LLM。"""
        system_prompt = SYSTEM_PROMPT_TEMPLATE.format(topic=topic)
        expires_at = None
        if duration_seconds:
            expires_at = datetime.now() + timedelta(seconds=duration_seconds)

        session = DialogueSession(
            user_id=user_id,
            topic=topic,
            role_config=role_config,
            total_rounds=total_rounds,
            current_round=1,
            status="active",
            system_prompt=system_prompt,
            expires_at=expires_at,
        )
        db.add(session)
        await db.commit()
        await db.refresh(session)

        return {
            "sessionId": session.uuid,
            "totalRounds": total_rounds,
            "currentRound": 1,
            "expiresAt": expires_at.isoformat() if expires_at else None,
        }

    async def generate_greeting(
        self,
        db: AsyncSession,
        session_uuid: str,
    ) -> dict:
        """为已存在的 session 生成开场白。幂等:已有 round=1 AI 消息直接返回。

        Raises:
            LookupError: session 不存在
            ValueError: session 非 active
        """
        # 行锁 serialize 同一 session 的并发请求
        result = await db.execute(
            select(DialogueSession)
            .where(DialogueSession.uuid == session_uuid)
            .with_for_update()
        )
        session = result.scalar_one_or_none()
        if session is None:
            raise LookupError(f"Session not found: {session_uuid}")
        if session.status != "active":
            raise ValueError(f"Session is not active: status={session.status}")

        # 幂等:查 round=1 的 AI 消息
        existing = await db.execute(
            select(DialogueMessage)
            .where(DialogueMessage.session_id == session.id)
            .where(DialogueMessage.round == 1)
            .where(DialogueMessage.role == "ai")
        )
        existing_msg = existing.scalar_one_or_none()
        if existing_msg is not None:
            await db.commit()  # 释放行锁
            return {"aiMessage": existing_msg.content}

        # 生成开场白
        messages = [
            {"role": "system", "content": session.system_prompt},
            {"role": "user", "content": f"Start a conversation about: {session.topic}"},
        ]
        ai_greeting = await self.llm.chat(messages, model="")

        ai_msg = DialogueMessage(
            session_id=session.id,
            round=1,
            role="ai",
            content=ai_greeting,
        )
        db.add(ai_msg)
        await db.commit()

        return {"aiMessage": ai_greeting}
  • Step 1.4: 运行测试确认通过
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/service/speaking/test_dialogue_service_greeting.py -v

Expected: 全部 5 个 test PASS。

  • Step 1.5: 确认现有 dialogue_service 测试仍通过
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/service/speaking/ -v

Expected: 所有既有测试 + 新测试都 PASS。

  • Step 1.6: 提交
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
git add app/service/speaking/dialogue_service.py tests/service/speaking/test_dialogue_service_greeting.py
git commit -m "feat(speaking): split create_session into create_session_only + generate_greeting

New methods on DialogueService:
- create_session_only: only inserts session row, returns sessionId (no LLM)
- generate_greeting: idempotent greeting generation with SELECT ... FOR UPDATE
  row lock; returns existing round=1 AI message if already present

Old create_session() left untouched — API layer still uses it. Will be
removed in the next task when /session endpoint switches over."

Task 2: 后端 - API 路由拆分

Files:

  • Modify: /Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py (/session 改响应,新增 /session/{id}/greeting 路由)
  • Test: /Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py (新建)
  • Create: /Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/__init__.py (空文件)

  • [ ] Step 2.1: 创建 api 测试包目录

cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
mkdir -p tests/api
touch tests/api/__init__.py
  • Step 2.2: 写 API 层集成测试(用 httpx.AsyncClient + ASGITransport)

新建 tests/api/test_dialogue_greeting.py:

"""End-to-end HTTP tests for POST /session and POST /session/{id}/greeting.

Uses httpx.AsyncClient + ASGITransport so everything runs on a single asyncio
event loop — no threading / TestClient contortions when we want to mutate DB
state between requests.
"""

from unittest.mock import AsyncMock, MagicMock

import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool

from app.api.dialogue import get_dialogue_service
from app.main import app
from app.models.database import get_db
from app.models.dialogue import Base, DialogueSession
from app.service.speaking.dialogue_service import DialogueService


@pytest_asyncio.fixture
async def test_env():
    """Bring up in-memory SQLite + dependency overrides + async HTTP client."""
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        poolclass=StaticPool,
        connect_args={"check_same_thread": False},
    )
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

    async def _override_db():
        async with SessionLocal() as s:
            yield s

    def _override_service():
        llm = MagicMock()
        llm.chat = AsyncMock(return_value="Hi! Ready to talk?")
        return DialogueService(
            asr=MagicMock(), llm=llm, assessor=MagicMock(), storage=MagicMock(),
        )

    app.dependency_overrides[get_db] = _override_db
    app.dependency_overrides[get_dialogue_service] = _override_service

    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        yield client, SessionLocal

    app.dependency_overrides.clear()
    await engine.dispose()


@pytest.mark.asyncio
async def test_post_session_does_not_include_ai_message(test_env):
    client, _ = test_env
    r = await client.post("/api/speaking/dialogue/session", json={
        "topic": "My favorite animal", "totalRounds": 3,
    })
    assert r.status_code == 200
    body = r.json()
    assert "sessionId" in body
    assert "aiMessage" not in body


@pytest.mark.asyncio
async def test_post_greeting_happy_path(test_env):
    client, _ = test_env
    r = await client.post("/api/speaking/dialogue/session", json={
        "topic": "Sports", "totalRounds": 3,
    })
    session_id = r.json()["sessionId"]

    g = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
    assert g.status_code == 200
    assert g.json() == {"aiMessage": "Hi! Ready to talk?"}


@pytest.mark.asyncio
async def test_post_greeting_idempotent(test_env):
    client, _ = test_env
    r = await client.post("/api/speaking/dialogue/session", json={
        "topic": "X", "totalRounds": 3,
    })
    session_id = r.json()["sessionId"]

    g1 = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
    g2 = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
    assert g1.status_code == 200
    assert g2.status_code == 200
    assert g1.json() == g2.json()


@pytest.mark.asyncio
async def test_post_greeting_session_not_found_returns_404(test_env):
    client, _ = test_env
    g = await client.post(
        "/api/speaking/dialogue/session/00000000-0000-0000-0000-000000000000/greeting"
    )
    assert g.status_code == 404


@pytest.mark.asyncio
async def test_post_greeting_non_active_session_returns_409(test_env):
    client, SessionLocal = test_env
    r = await client.post("/api/speaking/dialogue/session", json={
        "topic": "X", "totalRounds": 3,
    })
    session_id = r.json()["sessionId"]

    # 直接在共享 engine 上更新 session 状态为 completed
    async with SessionLocal() as s:
        await s.execute(
            update(DialogueSession)
            .where(DialogueSession.uuid == session_id)
            .values(status="completed")
        )
        await s.commit()

    g = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
    assert g.status_code == 409
  • Step 2.3: 运行测试确认失败
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/api/test_dialogue_greeting.py -v

Expected: 5 个 test 全部 FAIL(路由不存在 / 响应含 aiMessage 等)。

  • Step 2.4: 修改 /session 端点使用 create_session_only

编辑 app/api/dialogue.py,找到 @router.post("/session")(约 51-67 行),替换 service.create_session(...)service.create_session_only(...)(签名更干净,去掉了内部不用的 role_config):

@router.post("/session")
async def create_session(
    req: CreateSessionRequest,
    db: AsyncSession = Depends(get_db),
    service: DialogueService = Depends(get_dialogue_service),
):
    """创建对话 session(不生成开场白)。开场白由 POST /session/{id}/greeting 触发。"""
    logger.info(f"Creating session: topic={req.topic}, rounds={req.totalRounds}")
    result = await service.create_session_only(
        db=db,
        topic=req.topic,
        total_rounds=req.totalRounds,
        duration_seconds=req.durationSeconds,
        user_id=req.userId,
    )
    logger.info(f"Session created: {result['sessionId']}")
    return result
  • Step 2.5: 新增 /session/{session_id}/greeting 路由

app/api/dialogue.py 中,/speak 路由上方(约 70 行之前)插入:

@router.post("/session/{session_id}/greeting")
async def generate_greeting(
    session_id: str,
    db: AsyncSession = Depends(get_db),
    service: DialogueService = Depends(get_dialogue_service),
):
    """为已存在的 session 生成开场白(幂等)。"""
    logger.info(f"Generating greeting: session={session_id}")
    try:
        result = await service.generate_greeting(db=db, session_uuid=session_id)
    except LookupError:
        raise HTTPException(status_code=404, detail="Session not found")
    except ValueError:
        raise HTTPException(status_code=409, detail="Session is not active")
    logger.info(f"Greeting ready: session={session_id}")
    return result
  • Step 2.6: 运行 API 测试确认通过
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/api/test_dialogue_greeting.py -v

Expected: 5 个 test 全部 PASS。

  • Step 2.7: 删除(或废弃)旧的 create_session 方法

回到 app/service/speaking/dialogue_service.py,删除原 create_session 方法(约 44-97 行)—— 现在没有调用方了。

  • Step 2.8: 全量回归测试
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest -v

Expected: 所有测试 PASS;没有 create_session 相关测试残留报错。

  • Step 2.9: 提交
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
git add app/api/dialogue.py app/service/speaking/dialogue_service.py tests/api/
git commit -m "feat(speaking): split session endpoint - /session creates only, /session/{id}/greeting generates opening

Breaking change for frontend:
- POST /session response no longer contains aiMessage field
- New POST /session/{id}/greeting endpoint for the AI opening message
  - 200 + {aiMessage}: success or idempotent hit
  - 404: session not found
  - 409: session not active

Old DialogueService.create_session removed (replaced by create_session_only +
generate_greeting, see previous commit)."

Task 3: 前端 - 类型与 API 层

Files:

  • Modify: /Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts (SessionInfoaiMessage;PreviewChatMessageunrecoverable;DialogueAPIgenerateGreeting)
  • Modify: /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts (DialogueApiError, createDialogueApi 工厂,Real/Mock 都加 generateGreeting)

  • [ ] Step 3.1: 更新类型定义

编辑 /Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts:

  1. 找到 PreviewChatMessage 接口(约 201-227 行),在 error?: string 下方加:
  /** true 表示这个错误无法重试(如 session 404/409),UI 应提示用户重开 */
  unrecoverable?: boolean
  1. 找到 SessionInfo 接口(约 247-251 行),改为:
// 对话会话信息 (createSession 返回)
export interface SessionInfo {
  sessionId: string
  totalRounds: number
  currentRound: number
  expiresAt: string | null
}

// 开场白生成结果 (generateGreeting 返回)
export interface GreetingInfo {
  aiMessage: string
}
  1. 找到 DialogueAPI 接口(约 259-263 行),改为:
// 对话 API 接口
export interface DialogueAPI {
  createSession(config: SessionConfig): Promise<SessionInfo>
  generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo>
  speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent>
  getReport(sessionId: string): Promise<DialogueReport>
}
  • Step 3.2: 扩展 llmService.ts

编辑 /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts

  1. 在文件顶部 import 行下方加 DialogueApiErrorGreetingInfo 引用(GreetingInfo 已在 types 里,引进来):
import type {
  DialogueAPI,
  SSEEvent,
  SessionConfig,
  SessionInfo,
  GreetingInfo,
  DialogueReport,
  SentenceEvaluation,
} from '@/types/englishSpeaking'
  1. const API_BASE = ... 下方定义 error 类与工厂(约第 4 行后):
export class DialogueApiError extends Error {
  status: number
  constructor(message: string, status: number) {
    super(message)
    this.status = status
    this.name = 'DialogueApiError'
  }
}

export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI {
  return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
}
  1. 替换 RealDialogueAPI.createSession(约 137-151 行):
  async createSession(config: SessionConfig): Promise<SessionInfo> {
    const res = await fetch(`${API_BASE}/session`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify({
        topic: config.topic,
        totalRounds: config.totalRounds,
        roleId: config.roleId,
      }),
    })
    if (!res.ok) {
      throw new DialogueApiError(`createSession failed: ${res.status}`, res.status)
    }
    const body = await res.json()
    return {
      sessionId: body.sessionId,
      totalRounds: body.totalRounds,
      currentRound: body.currentRound,
      expiresAt: body.expiresAt ?? null,
    }
  }
  1. RealDialogueAPI 里,createSession 方法之后新加:
  async generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
    const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, {
      method: 'POST',
      credentials: 'include',
      signal,
    })
    if (!res.ok) {
      const text = await res.text().catch(() => '')
      throw new DialogueApiError(
        `greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`,
        res.status,
      )
    }
    return res.json()
  }
  1. 替换 MockDialogueAPI.createSession(约 191-197 行):
  async createSession(_config: SessionConfig): Promise<SessionInfo> {
    this.roundIndex = 0
    return {
      sessionId: 'mock-session-' + Date.now(),
      totalRounds: _config.totalRounds,
      currentRound: 1,
      expiresAt: null,
    }
  }

  async generateGreeting(_sessionId: string, _signal?: AbortSignal): Promise<GreetingInfo> {
    // 模拟 300ms 延迟
    await new Promise(r => setTimeout(r, 300))
    return { aiMessage: "Hi! What's your favorite animal?" }
  }
  • Step 3.3: 类型检查
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -30

Expected: 没有 error;如果报 useDialogueEngine.ts:39 附近 aiMessage 访问错,留到 Task 4 里一起修(预期行为)。

如有其它地方引用了 SessionInfo.aiMessage,搜一遍:

grep -rn "\.aiMessage\b" /Users/buoy/Development/gitrepo/PPT/src

Expected:只剩 useDialogueEngine.ts 一处(本次任务不改它,Task 4 处理)。

  • Step 3.4: 提交
cd /Users/buoy/Development/gitrepo/PPT
git add src/types/englishSpeaking.ts src/views/Editor/EnglishSpeaking/services/llmService.ts
git commit -m "feat(speaking): add generateGreeting to DialogueAPI + DialogueApiError type

- SessionInfo no longer carries aiMessage (moved to separate GreetingInfo)
- PreviewChatMessage has new optional unrecoverable flag
- DialogueApiError carries HTTP status so callers can distinguish 404/409
- createDialogueApi factory for shared API instantiation

useDialogueEngine still references old SessionInfo shape — fixed in next task."

Task 4: 前端 - useDialogueEngine 拆分

Files:

  • Modify: /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts (拆 initSession,新增 attachSession / generateGreeting / retryGreeting,含 inflight 锁 + AbortController)

  • [ ] Step 4.1: 更新 imports

编辑 useDialogueEngine.ts,把第 2 行 import 扩展(加入工厂和 error 类):

import { MockDialogueAPI, RealDialogueAPI, createDialogueApi, DialogueApiError } from '../services/llmService'

保留现有 MockDialogueAPI / RealDialogueAPI 的 import 不强求,但用工厂就不需要它们了 —— 建议清掉改为:

import { createDialogueApi, DialogueApiError } from '../services/llmService'

第 13 行把 let api: DialogueAPI = mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI() 改为:

  const api: DialogueAPI = createDialogueApi(mode)
  • Step 4.2: 新增 greeting 相关 state + 方法,并删除老 initSession

删除原 initSession 函数(约 23-42 行),在同一位置插入:

  // ==================== Session Attach ====================

  const greetingInflight = ref(false)
  let greetingAbortController: AbortController | null = null

  /** 仅附加已建好的 session 到 engine,不发任何网络请求。 */
  function attachSession(info: { sessionId: string; expiresAt?: string | null }) {
    sessionId.value = info.sessionId
    expiresAt.value = info.expiresAt ?? null
    if (info.expiresAt) startCountdown(info.expiresAt)
  }

  /** 触发开场白生成:push loading 占位 AI 消息 → 请求 /greeting → 成功填内容,失败标 error。 */
  async function generateGreeting() {
    if (!sessionId.value || greetingInflight.value) return
    greetingInflight.value = true
    greetingAbortController = new AbortController()

    const aiMsg = reactive<PreviewChatMessage>({
      id: crypto.randomUUID(),
      role: 'ai',
      content: '',
      timestamp: new Date(),
      status: 'loading',
    })
    messages.value.push(aiMsg)

    try {
      const { aiMessage } = await api.generateGreeting(sessionId.value, greetingAbortController.signal)
      aiMsg.content = aiMessage
      aiMsg.status = 'done'
      speakTTS(aiMessage)
    } catch (err: any) {
      if (err?.name === 'AbortError') return  // 组件卸载:不改 UI
      aiMsg.status = 'error'
      aiMsg.error = friendlyErrorMessage(err?.message)
      const status = err instanceof DialogueApiError ? err.status : undefined
      aiMsg.unrecoverable = status === 404 || status === 409
    } finally {
      greetingInflight.value = false
      greetingAbortController = null
    }
  }

  /** 重试失败的开场白:移除 error 消息,再走一遍 generateGreeting(同 sessionId)。 */
  async function retryGreeting() {
    if (greetingInflight.value) return
    const firstAi = messages.value.find(m => m.role === 'ai')
    if (firstAi?.status !== 'error' || firstAi.unrecoverable) return
    messages.value = messages.value.filter(m => m.id !== firstAi.id)
    await generateGreeting()
  }
  • Step 4.3: onUnmounted 追加 greeting abort

找到 onUnmounted(约 391 行),把原本的:

  onUnmounted(() => {
    abort()
    cancelTTS()
    stopCountdown()
  })

改为:

  onUnmounted(() => {
    abort()
    greetingAbortController?.abort()
    cancelTTS()
    stopCountdown()
  })
  • Step 4.4: 更新 return 出去的接口

找到返回对象(约 397 行),把 initSession 行替换为三个新方法:

  return {
    messages,
    sessionId,
    currentRound,
    isComplete,
    isProcessing,
    canRecord,
    countdownSeconds,
    greetingInflight,   // 新增:UI 可用来 disable 重试按钮

    attachSession,      // 替代 initSession
    generateGreeting,
    retryGreeting,
    sendStudentMessage,
    beginStudentStream,
    streamFallback,
    retryMessage,
    regenerateAiMessage,
    getReport,
    abort,
    cancelTTS,
  }
  • Step 4.5: 类型检查
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -40

Expected:在 DialogueChatView.vue 里会报 engine.initSession is not a function 之类的错(下一个任务修);useDialogueEngine.ts 本身应干净。

  • Step 4.6: 提交
cd /Users/buoy/Development/gitrepo/PPT
git add src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts
git commit -m "refactor(speaking): split initSession into attachSession + generateGreeting + retryGreeting

- attachSession: sync method, only wires sessionId/expiresAt into engine refs
- generateGreeting: posts to /greeting, pushes loading AI placeholder, handles
  DialogueApiError status to flag 404/409 as unrecoverable
- retryGreeting: removes the failed AI message and re-runs generateGreeting
- greetingInflight ref guards concurrent requests
- onUnmounted aborts in-flight greeting fetch

Next task: wire DialogueChatView/TopicDiscussionPreview to the new API."

Task 5: 前端 - DialogueChatView 接入新 API

Files:

  • Modify: /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue (新 sessionInfo prop + restart emit;onMounted 改写;error-card 按钮分支;handleRestart 改 emit)

  • [ ] Step 5.1: 新增 prop 和 emit

编辑 DialogueChatView.vue。找到 interface Props(约 491 行),在最后加一行:

interface Props {
  topic?: string
  keywords?: string[]
  aiName?: string
  aiAvatar?: string
  totalRounds?: number
  mode?: 'preview' | 'real'
  showEnglishText?: boolean
  showChineseText?: boolean
  sessionInfo?: { sessionId: string; expiresAt: string | null } | null
}

withDefaults 调用(约 502 行)对应加 sessionInfo: null:

const props = withDefaults(defineProps<Props>(), {
  topic: '我最喜欢的动物',
  keywords: () => ['animal', 'zoo', 'cute', 'favorite'],
  aiName: 'Tom',
  aiAvatar: '😊',
  totalRounds: 3,
  mode: 'preview',
  showEnglishText: true,
  showChineseText: false,
  sessionInfo: null,
})

找到 const emit = defineEmits<...>(约 513 行),扩展为:

const emit = defineEmits<{
  complete: [report: DialogueReport | null]
  restart: []
}>()
  • Step 5.2: 替换 onMounted 逻辑

找到 onMounted(约 950 行)。把内部 engine.initSession(...) 调用整体替换:

onMounted(() => {
  if (props.sessionInfo) {
    engine.attachSession(props.sessionInfo)
    engine.generateGreeting()
  }
  // 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件)

  totalTimer = setInterval(() => {
    if (engine.countdownSeconds.value == null) totalSeconds.value += 1
  }, 1000)
})
  • Step 5.3: 添加 retryAiMessage 帮助函数

script setuphandleRetry 附近(约 721 行之后)插入:

function retryAiMessage(message: PreviewChatMessage) {
  const idx = engine.messages.value.indexOf(message)
  const hasPrevStudent = engine.messages.value.slice(0, idx).some(m => m.role === 'student')

  // 第一条 AI 消息(前面没有学生消息)= greeting
  if (!hasPrevStudent) {
    if (message.unrecoverable) {
      emit('restart')
      return
    }
    engine.retryGreeting()
    return
  }

  // 非首条:沿用原 regenerate
  engine.regenerateAiMessage(message.id)
}
  • Step 5.4: 替换模板里 AI error-card 的重试按钮

找到模板里 AI 消息的 error-card 块(约 85-88 行):

<!-- AI 错误 -->
<div v-if="message.status === 'error'" class="error-card">
  <span class="error-text">{{ message.error || '生成失败' }}</span>
  <button class="retry-btn" @click="engine.regenerateAiMessage(message.id)">重新生成</button>
</div>

替换为:

<!-- AI 错误 -->
<div v-if="message.status === 'error'" class="error-card">
  <span class="error-text">{{ message.error || '生成失败' }}</span>
  <button
    class="retry-btn"
    :disabled="engine.greetingInflight.value"
    @click="retryAiMessage(message)"
  >{{ message.unrecoverable ? '返回重开' : '重新生成' }}</button>
</div>
  • Step 5.5: 改写 handleRestart

找到 handleRestart(约 817 行),替换为:

function handleRestart() {
  showExitConfirm.value = false
  engine.abort()
  engine.cancelTTS()
  emit('restart')
}

(父组件 TopicDiscussionPreview 会在收到 emit 后清状态并回 ready 页,session 重建由用户再点"开始对话"触发。)

  • Step 5.6: 类型检查
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -30

Expected:DialogueChatView 内部应干净;在 TopicDiscussionPreview.vue 处可能还报 prop/emit 未使用的警告(下一个任务修)。

  • Step 5.7: 提交
cd /Users/buoy/Development/gitrepo/PPT
git add src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
git commit -m "feat(speaking): wire DialogueChatView to new sessionInfo prop + greeting flow

- New sessionInfo prop (from parent) replaces engine.initSession auto-trigger
- On mount: attachSession + generateGreeting → user sees typing bubble immediately
- Error retry button: three branches (retryGreeting / restart / regenerateAi)
  driven by message.unrecoverable flag and presence of prior student message
- handleRestart emits 'restart' instead of recreating session internally;
  parent returns to ready stage"

Task 6: 前端 - TopicDiscussionPreview 异步启动

Files:

  • Modify: /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue (async startDialogue,loading/error UI,监听 @restart)

  • [ ] Step 6.1: 更新 imports

编辑 TopicDiscussionPreview.vue,在 import 区域(约 63-69 行)加:

import { createDialogueApi, DialogueApiError } from '../services/llmService'
  • Step 6.2: 新增 session 创建状态 ref

dialogueState ref 定义(约 89 行)之后加:

const sessionCreating = ref(false)
const sessionError = ref<string | null>(null)
const preparedSession = ref<{ sessionId: string; expiresAt: string | null } | null>(null)
  • Step 6.3: 改写 startDialogue

找到 startDialogue 函数(约 198-200 行):

function startDialogue() {
  dialogueState.value = 'chatting'
}

替换为:

async function startDialogue() {
  if (sessionCreating.value) return
  sessionCreating.value = true
  sessionError.value = null
  try {
    const api = createDialogueApi(props.mode)
    const info = await api.createSession({
      topic: speakingStore.config.topic || props.topic,
      totalRounds: speakingStore.config.practice.rounds || props.totalRounds,
      roleId: 'tom',
      vocabulary: speakingStore.config.learningGoals.vocabulary,
    })
    preparedSession.value = {
      sessionId: info.sessionId,
      expiresAt: info.expiresAt,
    }
    dialogueState.value = 'chatting'
  } catch (err: unknown) {
    if (err instanceof DialogueApiError) {
      sessionError.value = `创建会话失败(${err.status}),请重试`
    } else {
      sessionError.value = '创建会话失败,请重试'
    }
  } finally {
    sessionCreating.value = false
  }
}
  • Step 6.4: 扩展 resetPreviewpreparedSession

找到 resetPreview(约 207 行):

function resetPreview() {
  dialogueState.value = 'ready'
  realEvaluation.value = null
}

替换为:

function resetPreview() {
  dialogueState.value = 'ready'
  realEvaluation.value = null
  preparedSession.value = null
  sessionError.value = null
  sessionCreating.value = false
}
  • Step 6.5: 模板里传 sessionInfo + 监听 restart

找到模板中 <DialogueChatView> 标签(约 33-42 行):

<DialogueChatView
  v-else-if="dialogueState === 'chatting'"
  :topic="speakingStore.config.topic || topic"
  :keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
  :ai-name="mockRole.name"
  :ai-avatar="mockRole.avatar"
  :total-rounds="speakingStore.config.practice.rounds || totalRounds"
  :mode="mode"
  @complete="handleDialogueComplete"
/>

扩展为:

<DialogueChatView
  v-else-if="dialogueState === 'chatting'"
  :topic="speakingStore.config.topic || topic"
  :keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
  :ai-name="mockRole.name"
  :ai-avatar="mockRole.avatar"
  :total-rounds="speakingStore.config.practice.rounds || totalRounds"
  :mode="mode"
  :session-info="preparedSession"
  @complete="handleDialogueComplete"
  @restart="resetPreview"
/>
  • Step 6.6: 改按钮 UI + 加错误提示

找到 ready 阶段的 start-btn(约 18-29 行):

<div class="ready-footer">
  <button class="start-btn" @click="startDialogue">
    <svg ...><!-- 麦克风图标 --></svg>
    开始对话
  </button>
</div>

替换为:

<div class="ready-footer">
  <button class="start-btn" :disabled="sessionCreating" @click="startDialogue">
    <svg v-if="!sessionCreating" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
      stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
      <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
      <line x1="12" y1="19" x2="12" y2="23" />
      <line x1="8" y1="23" x2="16" y2="23" />
    </svg>
    <span v-else class="start-btn-spinner" />
    {{ sessionCreating ? '创建中…' : '开始对话' }}
  </button>
  <p v-if="sessionError" class="session-error-text">{{ sessionError }}</p>
</div>
  • Step 6.7: 样式补丁

<style lang="scss" scoped> 区(找 .start-btn 样式附近,约第 312 行)下方追加:

.start-btn:disabled {
  opacity: 0.6;
  cursor: wait;
  background: #fb923c;
}
.start-btn-spinner {
  width: 14px;
  height: 14px;
  border: 2px solid rgba(255, 255, 255, 0.4);
  border-top-color: #fff;
  border-radius: 50%;
  animation: start-btn-spin 0.8s linear infinite;
  display: inline-block;
}
@keyframes start-btn-spin {
  to { transform: rotate(360deg); }
}
.session-error-text {
  margin: 8px 0 0;
  font-size: 12px;
  color: #dc2626;
  text-align: center;
}
  • Step 6.8: 类型检查
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -30

Expected: 无 error,无 warning。

  • Step 6.9: 提交
cd /Users/buoy/Development/gitrepo/PPT
git add src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
git commit -m "feat(speaking): TopicDiscussionPreview creates session on start button click

- 'Start dialogue' button now triggers POST /session before navigating
- Button shows loading spinner while creating; disabled during in-flight
- On failure: error text below button, button re-enabled for retry
- On success: passes sessionInfo prop to DialogueChatView so it can attach
  and trigger greeting immediately
- Listens to child 'restart' emit to return to ready stage"

Task 7: 联调与验收

  • Step 7.1: 启动后端
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run uvicorn app.main:app --reload --port 8000

Expected: uvicorn started,无异常日志。

  • Step 7.2: 启动前端
cd /Users/buoy/Development/gitrepo/PPT
pnpm dev
  • Step 7.3: 手动测 - 正常流
  1. 打开浏览器到 PPT,编辑模式进入包含英语口语元素的 PPT
  2. 切到预览 → ready 页 → 点击"开始对话"
  3. 按钮短暂变"创建中…" + spinner
  4. 进入聊天页 → 立即看到 typing-bubble
  5. ~2 秒后 greeting 文字出现 + TTS 朗读

打开浏览器 DevTools Network 面板验证请求顺序:

  • POST /api/speaking/dialogue/session → 200,响应体 不含 aiMessage
  • POST /api/speaking/dialogue/session/{id}/greeting → 200,响应体 {"aiMessage":"..."}

  • [ ] Step 7.4: 手动测 - session 创建失败重试

  1. 临时停掉后端(Ctrl+C)
  2. 回到 ready 页(可刷新)→ 点"开始对话"
  3. 验证:按钮短暂 loading,错误文案显示在按钮下方,按钮恢复可点
  4. 启动后端,再次点击"开始对话"
  5. 验证:成功进入聊天页
  • Step 7.5: 手动测 - greeting 500 失败重试

OneHubLLM.chat 里临时 raise Exception(如 raise RuntimeError("forced fail")),重启后端。

  1. ready 页点"开始对话"→ 进入聊天页 → 看到 typing-bubble
  2. 几秒后 typing-bubble 变成 error-card,按钮文案"重新生成"
  3. 恢复 OneHubLLM.chat,重启后端
  4. 点击"重新生成"→ 再次 loading → greeting 成功

验证 Network 面板:第二次 /greeting 请求命中同一个 sessionId。

  • Step 7.6: 手动测 - greeting 404 不可恢复
  1. 进入聊天页拿到 sessionId(Network 里看)
  2. 后端通过 SQL 删除该 session DELETE FROM dialogue_session WHERE uuid = '...'
  3. 模拟失败场景:打开新标签,手动 POST 一次 /session/{删除的id}/greeting(curl 即可),看是否返 404
  4. 回到聊天页 → 刷新 → 再次进入对话页 → 此时 attachSession 会拿新 sessionId,所以没法直接复现 404
  5. 更好的重现:在 RealDialogueAPI.generateGreeting 临时返固定的 404 mock 错误,点击"重新生成"按钮,观察文案变成"返回重开",点击后应回到 ready 页

或更简单:在 DevTools Console 手动操控消息状态:

// 找到第一条 AI 消息,把 unrecoverable 改 true,status 改 error
// (仅用于 smoke 检查 UI 分支)
  • Step 7.7: 手动测 - 幂等(并发模拟)

在 Network 面板打开 Throttling → Slow 3G。

  1. 走正常流,聊天页 typing-bubble 期间迅速(2 秒内)点击退出弹窗→"重新开始"
  2. resetPreview 回 ready → 再立刻点"开始对话"进新 session
  3. 观察老 session 的 greeting 请求被 abort(Network 面板 cancelled)

验证 DB:新老两个 session 各自 round=1 AI 只有一条。

或用 curl 并发验证:

# 建个 session
SID=$(curl -s -X POST http://localhost:8000/api/speaking/dialogue/session \
  -H 'Content-Type: application/json' \
  -d '{"topic":"test","totalRounds":3}' | jq -r .sessionId)
echo "session=$SID"

# 并发两次 greeting
(curl -s -X POST http://localhost:8000/api/speaking/dialogue/session/$SID/greeting &
 curl -s -X POST http://localhost:8000/api/speaking/dialogue/session/$SID/greeting &
 wait) | tee /tmp/greetings.log

# 应当两个响应 aiMessage 完全相同
cat /tmp/greetings.log

# DB 只有一条 round=1 AI 消息
mysql -u root speaking -e \
  "SELECT COUNT(*) FROM dialogue_message m JOIN dialogue_session s ON m.session_id=s.id \
   WHERE s.uuid='$SID' AND m.role='ai' AND m.round=1"
# → 1
  • Step 7.8: 手动测 - unmount 时 abort
  1. ready 页点"开始对话"→ 进入聊天页
  2. greeting 请求 in-flight 时(Slow 3G 下有时间),点退出弹窗→"重新开始"
  3. Network 面板:原 greeting 请求 status 变 (canceled)
  • Step 7.9: 回归测 - 原有对话流程
  1. 完整走完一次 3 轮对话
  2. 验证:录音 → STT → AI 回复 → 评分的流程全部正常
  3. 中间某一轮 AI 回复若失败,error-card 上按钮仍是"重新生成"(非"返回重开"),点击后 regenerateAiMessage 照旧工作
  4. 报告页按既有行为出现
  • Step 7.10: 全量回归后端单测
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest -v

Expected: 所有 test PASS。

  • Step 7.11: 验收清单核对

对着 /Users/buoy/Development/gitrepo/PPT/doc/DialogueSessionSplitDesign.md §6 的 11 条验收标准逐条打勾。


附录:回滚策略

如果上线后 LLM 失败率暴增导致用户大量走"重新生成"路径挤压 LLM 配额,可以通过以下方式紧急回滚:

  1. 后端回滚到 Task 2 之前的提交(保留 Task 1 的 service 层 create_session_only / generate_greeting,但恢复 /session 的旧 bundled 行为)—— 因为类型和前端已经不兼容,实际回滚路径是回滚前端+后端两边
  2. 所有修改都在 feature branch(feat/english-speaking)上,反转 commits 用 git revert <hash> 即可。

没有 DB schema 变更,回滚不涉及数据清理。