|
@@ -0,0 +1,1424 @@
|
|
|
|
|
+# 对话 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<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 行下方加 `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<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,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+4. 在 `RealDialogueAPI` 里,`createSession` 方法之后新加:
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+ 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()
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+5. 替换 `MockDialogueAPI.createSession`(约 191-197 行):
|
|
|
|
|
+
|
|
|
|
|
+```typescript
|
|
|
|
|
+ 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: 类型检查**
|
|
|
|
|
+
|
|
|
|
|
+```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<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 行),把原本的:
|
|
|
|
|
+
|
|
|
|
|
+```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<Props>(), {
|
|
|
|
|
+ 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
|
|
|
|
|
+<!-- 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>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+替换为:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<!-- 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 行),替换为:
|
|
|
|
|
+
|
|
|
|
|
+```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<string | null>(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**
|
|
|
|
|
+
|
|
|
|
|
+找到模板中 `<DialogueChatView>` 标签(约 33-42 行):
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<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"
|
|
|
|
|
+/>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+扩展为:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<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 行):
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<div class="ready-footer">
|
|
|
|
|
+ <button class="start-btn" @click="startDialogue">
|
|
|
|
|
+ <svg ...><!-- 麦克风图标 --></svg>
|
|
|
|
|
+ 开始对话
|
|
|
|
|
+ </button>
|
|
|
|
|
+</div>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+替换为:
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<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 行)下方追加:
|
|
|
|
|
+
|
|
|
|
|
+```scss
|
|
|
|
|
+.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: 类型检查**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/PPT
|
|
|
|
|
+pnpm type-check 2>&1 | tail -30
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: 无 error,无 warning。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6.9: 提交**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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: 启动后端**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
|
|
|
|
|
+uv run uvicorn app.main:app --reload --port 8000
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Expected: uvicorn started,无异常日志。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7.2: 启动前端**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 手动操控消息状态:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+// 找到第一条 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 并发验证:
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+# 建个 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: 全量回归后端单测**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 变更,回滚不涉及数据清理。
|