# 对话 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` 行锁并发保护。前端 `useDialogueEngine` 拆 `initSession` → `attachSession` + `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`;`PreviewChatMessage` 加 `unrecoverable?: boolean` | | `src/views/Editor/EnglishSpeaking/services/llmService.ts` | 新增 `DialogueApiError` + `createDialogueApi()` 工厂;`DialogueAPI` 接口扩 `generateGreeting`;Real/Mock 都实现 | | `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts` | 拆 `initSession` → `attachSession` + `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`: ```python """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: 运行测试确认失败** ```bash 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_only` 和 `generate_greeting`** 修改 `app/service/speaking/dialogue_service.py`。先看现有 `create_session`(约 44-97 行),**保留**原方法不要删(后面任务 2 才改 API 层);新增两个方法并让 `create_session` 委派给它们以便零破坏。 在 `class DialogueService` 里插入两个新方法(比如放在 `create_session` 之前,44 行上方): ```python 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: 运行测试确认通过** ```bash 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 测试仍通过** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest tests/service/speaking/ -v ``` Expected: 所有既有测试 + 新测试都 PASS。 - [ ] **Step 1.6: 提交** ```bash 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 测试包目录** ```bash 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`: ```python """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: 运行测试确认失败** ```bash 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): ```python @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 行之前)插入: ```python @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 测试确认通过** ```bash 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: 全量回归测试** ```bash cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api uv run pytest -v ``` Expected: 所有测试 PASS;没有 `create_session` 相关测试残留报错。 - [ ] **Step 2.9: 提交** ```bash 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` (`SessionInfo` 去 `aiMessage`;`PreviewChatMessage` 加 `unrecoverable`;`DialogueAPI` 扩 `generateGreeting`) - 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` 下方加: ```typescript /** true 表示这个错误无法重试(如 session 404/409),UI 应提示用户重开 */ unrecoverable?: boolean ``` 2. 找到 `SessionInfo` 接口(约 247-251 行),改为: ```typescript // 对话会话信息 (createSession 返回) export interface SessionInfo { sessionId: string totalRounds: number currentRound: number expiresAt: string | null } // 开场白生成结果 (generateGreeting 返回) export interface GreetingInfo { aiMessage: string } ``` 3. 找到 `DialogueAPI` 接口(约 259-263 行),改为: ```typescript // 对话 API 接口 export interface DialogueAPI { createSession(config: SessionConfig): Promise generateGreeting(sessionId: string, signal?: AbortSignal): Promise speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator getReport(sessionId: string): Promise } ``` - [ ] **Step 3.2: 扩展 `llmService.ts`** 编辑 `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts`。 1. 在文件顶部 import 行下方加 `DialogueApiError` 和 `GreetingInfo` 引用(GreetingInfo 已在 types 里,引进来): ```typescript import type { DialogueAPI, SSEEvent, SessionConfig, SessionInfo, GreetingInfo, DialogueReport, SentenceEvaluation, } from '@/types/englishSpeaking' ``` 2. 在 `const API_BASE = ...` 下方定义 error 类与工厂(约第 4 行后): ```typescript 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() } ``` 3. 替换 `RealDialogueAPI.createSession`(约 137-151 行): ```typescript async createSession(config: SessionConfig): Promise { 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, } } ``` 4. 在 `RealDialogueAPI` 里,`createSession` 方法之后新加: ```typescript async generateGreeting(sessionId: string, signal?: AbortSignal): Promise { 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() } ``` 5. 替换 `MockDialogueAPI.createSession`(约 191-197 行): ```typescript async createSession(_config: SessionConfig): Promise { this.roundIndex = 0 return { sessionId: 'mock-session-' + Date.now(), totalRounds: _config.totalRounds, currentRound: 1, expiresAt: null, } } async generateGreeting(_sessionId: string, _signal?: AbortSignal): Promise { // 模拟 300ms 延迟 await new Promise(r => setTimeout(r, 300)) return { aiMessage: "Hi! What's your favorite animal?" } } ``` - [ ] **Step 3.3: 类型检查** ```bash cd /Users/buoy/Development/gitrepo/PPT pnpm type-check 2>&1 | tail -30 ``` Expected: 没有 error;如果报 `useDialogueEngine.ts:39` 附近 `aiMessage` 访问错,留到 Task 4 里一起修(预期行为)。 如有其它地方引用了 `SessionInfo.aiMessage`,搜一遍: ```bash grep -rn "\.aiMessage\b" /Users/buoy/Development/gitrepo/PPT/src ``` Expected:只剩 `useDialogueEngine.ts` 一处(本次任务不改它,Task 4 处理)。 - [ ] **Step 3.4: 提交** ```bash 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 类): ```typescript import { MockDialogueAPI, RealDialogueAPI, createDialogueApi, DialogueApiError } from '../services/llmService' ``` 保留现有 MockDialogueAPI / RealDialogueAPI 的 import 不强求,但用工厂就不需要它们了 —— 建议清掉改为: ```typescript import { createDialogueApi, DialogueApiError } from '../services/llmService' ``` 第 13 行把 `let api: DialogueAPI = mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()` 改为: ```typescript const api: DialogueAPI = createDialogueApi(mode) ``` - [ ] **Step 4.2: 新增 greeting 相关 state + 方法,并删除老 `initSession`** 删除原 `initSession` 函数(约 23-42 行),在同一位置插入: ```typescript // ==================== 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({ 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 行),把原本的: ```typescript onUnmounted(() => { abort() cancelTTS() stopCountdown() }) ``` 改为: ```typescript onUnmounted(() => { abort() greetingAbortController?.abort() cancelTTS() stopCountdown() }) ``` - [ ] **Step 4.4: 更新 return 出去的接口** 找到返回对象(约 397 行),把 `initSession` 行替换为三个新方法: ```typescript 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: 类型检查** ```bash 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: 提交** ```bash 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 行),在最后加一行: ```typescript 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`: ```typescript const props = withDefaults(defineProps(), { topic: '我最喜欢的动物', keywords: () => ['animal', 'zoo', 'cute', 'favorite'], aiName: 'Tom', aiAvatar: '😊', totalRounds: 3, mode: 'preview', showEnglishText: true, showChineseText: false, sessionInfo: null, }) ``` 找到 `const emit = defineEmits<...>`(约 513 行),扩展为: ```typescript const emit = defineEmits<{ complete: [report: DialogueReport | null] restart: [] }>() ``` - [ ] **Step 5.2: 替换 `onMounted` 逻辑** 找到 `onMounted`(约 950 行)。把内部 `engine.initSession(...)` 调用整体替换: ```typescript 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 setup` 里 `handleRetry` 附近(约 721 行之后)插入: ```typescript 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 行): ```vue
{{ message.error || '生成失败' }}
``` 替换为: ```vue
{{ message.error || '生成失败' }}
``` - [ ] **Step 5.5: 改写 `handleRestart`** 找到 `handleRestart`(约 817 行),替换为: ```typescript function handleRestart() { showExitConfirm.value = false engine.abort() engine.cancelTTS() emit('restart') } ``` (父组件 `TopicDiscussionPreview` 会在收到 emit 后清状态并回 ready 页,session 重建由用户再点"开始对话"触发。) - [ ] **Step 5.6: 类型检查** ```bash cd /Users/buoy/Development/gitrepo/PPT pnpm type-check 2>&1 | tail -30 ``` Expected:DialogueChatView 内部应干净;在 `TopicDiscussionPreview.vue` 处可能还报 prop/emit 未使用的警告(下一个任务修)。 - [ ] **Step 5.7: 提交** ```bash 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 行)加: ```typescript import { createDialogueApi, DialogueApiError } from '../services/llmService' ``` - [ ] **Step 6.2: 新增 session 创建状态 ref** 在 `dialogueState` ref 定义(约 89 行)之后加: ```typescript const sessionCreating = ref(false) const sessionError = ref(null) const preparedSession = ref<{ sessionId: string; expiresAt: string | null } | null>(null) ``` - [ ] **Step 6.3: 改写 `startDialogue`** 找到 `startDialogue` 函数(约 198-200 行): ```typescript function startDialogue() { dialogueState.value = 'chatting' } ``` 替换为: ```typescript 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: 扩展 `resetPreview` 清 `preparedSession`** 找到 `resetPreview`(约 207 行): ```typescript function resetPreview() { dialogueState.value = 'ready' realEvaluation.value = null } ``` 替换为: ```typescript function resetPreview() { dialogueState.value = 'ready' realEvaluation.value = null preparedSession.value = null sessionError.value = null sessionCreating.value = false } ``` - [ ] **Step 6.5: 模板里传 sessionInfo + 监听 restart** 找到模板中 `` 标签(约 33-42 行): ```vue ``` 扩展为: ```vue ``` - [ ] **Step 6.6: 改按钮 UI + 加错误提示** 找到 ready 阶段的 `start-btn`(约 18-29 行): ```vue ``` 替换为: ```vue ``` - [ ] **Step 6.7: 样式补丁** 在 `