4 Commits 055483e0da ... 675f389f34

Author SHA1 Message Date
  jimmylee 675f389f34 feat: restore student speaking sessions 5 months ago
  jimmylee 9da141e043 feat: seed speaking chat from historical session 5 months ago
  jimmylee 60fb62cbe9 docs: plan student speaking session history 5 months ago
  jimmylee 857abf0627 docs: design student speaking session history 5 months ago

+ 982 - 0
docs/superpowers/plans/2026-04-27-speaking-student-session-history.md

@@ -0,0 +1,982 @@
+# Speaking Student Session History Implementation Plan
+
+> **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:** Formal student mode restores the latest speaking session for the same `configId + userId`, while editor preview modes keep creating isolated preview sessions.
+
+**Architecture:** Backend persists `config_id` on `dialogue_session`, keeps `POST /session` as always-create, and adds a latest lookup endpoint that returns session metadata plus message history. Frontend reads URL `mode` and `userid` inside `TopicDiscussionPreview`, only calls latest lookup for `mode=student`, and initializes `DialogueChatView` with historical messages for active sessions.
+
+**Tech Stack:** Vue 3 + TypeScript + Pinia frontend in `/Users/buoy/Development/gitrepo/PPT`; FastAPI + SQLAlchemy async + pytest backend in `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`.
+
+---
+
+## File Structure
+
+Backend repository: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`
+
+- Modify: `app/models/dialogue.py`
+  - Add nullable `DialogueSession.config_id`.
+- Modify: `init.sql`
+  - Add `config_id` to fresh installs and add lookup index.
+- Create: `migrations/2026-04-27-add-dialogue-session-config-id.sql`
+  - Manual migration for existing deployments.
+- Modify: `app/service/speaking/dialogue_service.py`
+  - Accept/store `config_id`.
+  - Add helper to adapt `DialogueMessage` history for latest response.
+- Modify: `app/api/dialogue.py`
+  - Accept `configId` in create request.
+  - Add `GET /sessions/latest` route.
+- Modify: `tests/api/test_dialogue_greeting.py`
+  - Cover create storage and latest lookup behavior using the existing ASGI test harness.
+
+Frontend repository: `/Users/buoy/Development/gitrepo/PPT`
+
+- Modify: `src/types/englishSpeaking.ts`
+  - Add `configId`, `userId`, historical message/session types, and `checking-history` preview state.
+- Modify: `src/views/Editor/EnglishSpeaking/services/llmService.ts`
+  - Send `configId/userId` on create.
+  - Add `getLatestSession(configId, userId)`.
+- Modify: `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`
+  - Let `attachSession` seed initial messages and current round.
+- Modify: `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
+  - Pass historical messages into the engine.
+  - Skip greeting generation when history already includes messages.
+- Modify: `src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`
+  - Read URL `mode/userid`.
+  - Query latest only in `mode=student`.
+  - Open active history in chat and completed history in report.
+
+---
+
+## Task 1: Backend Session Ownership Schema
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/models/dialogue.py`
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/init.sql`
+- Create: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/migrations/2026-04-27-add-dialogue-session-config-id.sql`
+- Test: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py`
+
+- [ ] **Step 1: Add failing API test for create storing `config_id` and `user_id`**
+
+Append this test to `tests/api/test_dialogue_greeting.py`:
+
+```python
+@pytest.mark.asyncio
+async def test_post_session_stores_config_and_user(test_env):
+    client, SessionLocal = test_env
+    r = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Animals",
+        "grade": "grade5-1",
+        "totalRounds": 3,
+        "configId": "config-abc",
+        "userId": "student-001",
+    })
+    assert r.status_code == 200
+
+    async with SessionLocal() as db:
+        result = await db.execute(select(DialogueSession))
+        session = result.scalar_one()
+
+    assert session.config_id == "config-abc"
+    assert session.user_id == "student-001"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py::test_post_session_stores_config_and_user -q
+```
+
+Expected: fail with an attribute error or assertion failure because `DialogueSession.config_id` does not exist or is not populated.
+
+- [ ] **Step 3: Add ORM field**
+
+In `app/models/dialogue.py`, add `config_id` directly after `user_id`:
+
+```python
+    user_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
+    config_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True)
+    topic: Mapped[str] = mapped_column(String(255))
+```
+
+- [ ] **Step 4: Update fresh install schema**
+
+In `init.sql`, change `dialogue_session` to include `config_id` after `user_id`, and add the lookup index:
+
+```sql
+    user_id VARCHAR(64) NULL,
+    config_id VARCHAR(36) NULL,
+    topic VARCHAR(255) NOT NULL,
+```
+
+Add this index after `UNIQUE INDEX uk_uuid (uuid)`:
+
+```sql
+    INDEX idx_dialogue_session_config_user_created (config_id, user_id, created_at)
+```
+
+The resulting tail of `dialogue_session` should have comma placement like:
+
+```sql
+    completed_at DATETIME NULL,
+    UNIQUE INDEX uk_uuid (uuid),
+    INDEX idx_dialogue_session_config_user_created (config_id, user_id, created_at)
+);
+```
+
+- [ ] **Step 5: Add existing deployment migration**
+
+Create `migrations/2026-04-27-add-dialogue-session-config-id.sql`:
+
+```sql
+USE speaking;
+
+ALTER TABLE dialogue_session
+ADD COLUMN config_id VARCHAR(36) NULL AFTER user_id;
+
+CREATE INDEX idx_dialogue_session_config_user_created
+ON dialogue_session (config_id, user_id, created_at);
+```
+
+- [ ] **Step 6: Thread `config_id` through service**
+
+In `app/service/speaking/dialogue_service.py`, update the `create_session_only` signature:
+
+```python
+    async def create_session_only(
+        self,
+        db: AsyncSession,
+        topic: str,
+        grade: str,
+        vocabulary: list[str],
+        sentences: list[str],
+        total_rounds: int = 3,
+        duration_seconds: int | None = None,
+        role_config: dict | None = None,
+        user_id: str | None = None,
+        config_id: str | None = None,
+    ) -> dict:
+```
+
+In the `DialogueSession(...)` constructor, add:
+
+```python
+            user_id=user_id,
+            config_id=config_id,
+            topic=topic,
+```
+
+- [ ] **Step 7: Accept `configId` in create API**
+
+In `app/api/dialogue.py`, update `CreateSessionRequest`:
+
+```python
+class CreateSessionRequest(BaseModel):
+    topic: str
+    grade: str
+    vocabulary: list[str] = []
+    sentences: list[str] = []
+    totalRounds: int = 3
+    # 时长(分钟)。与 totalRounds 是并存的两个结束条件,先满足谁就结束。
+    # 后端据此计算并持久化 expires_at,一经写入不会被修改 —— 关页面/刷新/换设备重进同一 session 都接续同一截止时刻。
+    durationMinutes: int | None = None
+    roleId: str | None = None
+    userId: str | None = None
+    configId: str | None = None
+```
+
+In `create_session`, pass `config_id=req.configId`:
+
+```python
+    result = await service.create_session_only(
+        db=db,
+        topic=req.topic,
+        grade=req.grade,
+        vocabulary=req.vocabulary,
+        sentences=req.sentences,
+        total_rounds=req.totalRounds,
+        duration_seconds=duration_seconds,
+        user_id=req.userId,
+        config_id=req.configId,
+    )
+```
+
+- [ ] **Step 8: Run the targeted backend test**
+
+Run:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py::test_post_session_stores_config_and_user -q
+```
+
+Expected: pass.
+
+- [ ] **Step 9: Commit backend schema/create changes**
+
+Run:
+
+```bash
+git add app/models/dialogue.py app/service/speaking/dialogue_service.py app/api/dialogue.py init.sql migrations/2026-04-27-add-dialogue-session-config-id.sql tests/api/test_dialogue_greeting.py
+git commit -m "feat: store speaking config on dialogue sessions"
+```
+
+---
+
+## Task 2: Backend Latest Session Lookup
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py`
+- Modify: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py`
+- Test: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py`
+
+- [ ] **Step 1: Add failing latest lookup tests**
+
+Append these tests to `tests/api/test_dialogue_greeting.py`:
+
+```python
+@pytest.mark.asyncio
+async def test_latest_session_returns_null_when_missing(test_env):
+    client, _ = test_env
+    r = await client.get(
+        "/api/speaking/dialogue/sessions/latest",
+        params={"configId": "missing-config", "userId": "student-001"},
+    )
+    assert r.status_code == 200
+    assert r.json() == {"session": None}
+
+
+@pytest.mark.asyncio
+async def test_latest_session_returns_newest_exact_config_user_pair(test_env):
+    client, SessionLocal = test_env
+
+    older = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Older",
+        "grade": "grade5-1",
+        "totalRounds": 2,
+        "configId": "config-abc",
+        "userId": "student-001",
+    })
+    newer = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Newer",
+        "grade": "grade5-1",
+        "totalRounds": 4,
+        "configId": "config-abc",
+        "userId": "student-001",
+    })
+    other_user = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Other user",
+        "grade": "grade5-1",
+        "totalRounds": 9,
+        "configId": "config-abc",
+        "userId": "student-002",
+    })
+    other_config = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "Other config",
+        "grade": "grade5-1",
+        "totalRounds": 8,
+        "configId": "config-other",
+        "userId": "student-001",
+    })
+
+    assert older.status_code == newer.status_code == other_user.status_code == other_config.status_code == 200
+    expected_session_id = newer.json()["sessionId"]
+
+    r = await client.get(
+        "/api/speaking/dialogue/sessions/latest",
+        params={"configId": "config-abc", "userId": "student-001"},
+    )
+    assert r.status_code == 200
+    body = r.json()
+    assert body["session"]["sessionId"] == expected_session_id
+    assert body["session"]["status"] == "active"
+    assert body["session"]["totalRounds"] == 4
+    assert body["session"]["currentRound"] == 1
+    assert body["session"]["messages"] == []
+
+
+@pytest.mark.asyncio
+async def test_latest_session_includes_message_history(test_env):
+    client, _ = test_env
+    created = await client.post("/api/speaking/dialogue/session", json={
+        "topic": "History",
+        "grade": "grade5-1",
+        "totalRounds": 3,
+        "configId": "config-history",
+        "userId": "student-001",
+    })
+    session_id = created.json()["sessionId"]
+
+    greeting = await client.post(
+        f"/api/speaking/dialogue/session/{session_id}/greeting",
+        json={"turnId": "history-greeting-turn"},
+    )
+    assert greeting.status_code == 200
+
+    latest = await client.get(
+        "/api/speaking/dialogue/sessions/latest",
+        params={"configId": "config-history", "userId": "student-001"},
+    )
+    assert latest.status_code == 200
+    messages = latest.json()["session"]["messages"]
+    assert messages == [
+        {
+            "id": messages[0]["id"],
+            "round": 1,
+            "role": "ai",
+            "content": "Hi! Ready to talk?",
+            "audioUrl": None,
+            "clientTurnId": "history-greeting-turn",
+        }
+    ]
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py::test_latest_session_returns_null_when_missing tests/api/test_dialogue_greeting.py::test_latest_session_returns_newest_exact_config_user_pair tests/api/test_dialogue_greeting.py::test_latest_session_includes_message_history -q
+```
+
+Expected: fail with 404 because `/api/speaking/dialogue/sessions/latest` does not exist.
+
+- [ ] **Step 3: Add message/session adapter helper**
+
+In `app/service/speaking/dialogue_service.py`, add this method inside `DialogueService`, immediately before `get_report`:
+
+```python
+    async def get_latest_session_for_config_user(
+        self,
+        db: AsyncSession,
+        config_id: str,
+        user_id: str,
+    ) -> dict:
+        result = await db.execute(
+            select(DialogueSession)
+            .where(DialogueSession.config_id == config_id)
+            .where(DialogueSession.user_id == user_id)
+            .order_by(DialogueSession.created_at.desc(), DialogueSession.id.desc())
+            .limit(1)
+        )
+        session = result.scalar_one_or_none()
+        if session is None:
+            return {"session": None}
+
+        messages_result = await db.execute(
+            select(DialogueMessage)
+            .where(DialogueMessage.session_id == session.id)
+            .order_by(DialogueMessage.created_at, DialogueMessage.id)
+        )
+        messages = messages_result.scalars().all()
+
+        return {
+            "session": {
+                "sessionId": session.uuid,
+                "status": session.status,
+                "totalRounds": session.total_rounds,
+                "currentRound": session.current_round,
+                "expiresAt": session.expires_at.isoformat() if session.expires_at else None,
+                "createdAt": session.created_at.isoformat() if session.created_at else None,
+                "completedAt": session.completed_at.isoformat() if session.completed_at else None,
+                "messages": [
+                    {
+                        "id": msg.uuid,
+                        "round": msg.round,
+                        "role": msg.role,
+                        "content": msg.content,
+                        "audioUrl": msg.audio_url,
+                        "clientTurnId": msg.client_turn_id,
+                    }
+                    for msg in messages
+                ],
+            }
+        }
+```
+
+- [ ] **Step 4: Add latest route**
+
+In `app/api/dialogue.py`, add this route after `create_session` and before `/session/{session_id}/greeting` so fixed paths remain unambiguous:
+
+```python
+@router.get("/sessions/latest")
+async def get_latest_session(
+    configId: str,
+    userId: str,
+    db: AsyncSession = Depends(get_db),
+    service: DialogueService = Depends(get_dialogue_service),
+):
+    """Return the latest session for one speaking config and one student."""
+    if not configId.strip():
+        raise HTTPException(status_code=400, detail="configId is required")
+    if not userId.strip():
+        raise HTTPException(status_code=400, detail="userId is required")
+    return await service.get_latest_session_for_config_user(
+        db=db,
+        config_id=configId,
+        user_id=userId,
+    )
+```
+
+- [ ] **Step 5: Run latest lookup tests**
+
+Run:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py::test_latest_session_returns_null_when_missing tests/api/test_dialogue_greeting.py::test_latest_session_returns_newest_exact_config_user_pair tests/api/test_dialogue_greeting.py::test_latest_session_includes_message_history -q
+```
+
+Expected: pass.
+
+- [ ] **Step 6: Run all backend API tests touched by this feature**
+
+Run:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py tests/api/test_dialogue_task_hint.py -q
+```
+
+Expected: pass.
+
+- [ ] **Step 7: Commit latest lookup**
+
+Run:
+
+```bash
+git add app/api/dialogue.py app/service/speaking/dialogue_service.py tests/api/test_dialogue_greeting.py
+git commit -m "feat: add latest speaking session lookup"
+```
+
+---
+
+## Task 3: Frontend API Types and Engine History Seeding
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/types/englishSpeaking.ts`
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts`
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`
+
+- [ ] **Step 1: Extend frontend types**
+
+In `src/types/englishSpeaking.ts`, update `SessionConfig`:
+
+```ts
+export interface SessionConfig {
+  topic: string
+  grade: string
+  roleId: string
+  totalRounds: number
+  /** 时长(分钟)。与 totalRounds 是并存的两个结束条件,后端据此计算 expiresAt */
+  durationMinutes: number
+  vocabulary?: string[]
+  sentences?: string[]
+  configId?: string | null
+  userId?: string | null
+}
+```
+
+Add these interfaces after `SessionStartInfo`:
+
+```ts
+export interface HistoricalDialogueMessage {
+  id: string
+  round: number
+  role: 'ai' | 'student'
+  content: string
+  audioUrl?: string | null
+  clientTurnId?: string | null
+}
+
+export interface LatestSessionInfo extends SessionStartInfo {
+  status: 'active' | 'completed' | 'abandoned'
+  totalRounds: number
+  currentRound: number
+  createdAt: string | null
+  completedAt: string | null
+  messages: HistoricalDialogueMessage[]
+}
+
+export interface LatestSessionResponse {
+  session: LatestSessionInfo | null
+}
+```
+
+Update `SessionStartInfo`:
+
+```ts
+export interface SessionStartInfo {
+  sessionId: string
+  expiresAt: string | null
+  currentRound?: number
+  messages?: HistoricalDialogueMessage[]
+}
+```
+
+Update preview state:
+
+```ts
+export type PreviewDialogueState = 'checking-history' | 'ready' | 'chatting' | 'completed'
+```
+
+Update `DialogueAPI`:
+
+```ts
+export interface DialogueAPI {
+  createSession(config: SessionConfig): Promise<SessionInfo>
+  getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse>
+  completeSession(sessionId: string): Promise<void>
+  /** Throws DOMException('AbortError') on signal abort; throws DialogueApiError on non-OK HTTP. */
+  generateGreeting(sessionId: string, turnId: string, signal?: AbortSignal): Promise<GreetingInfo>
+  generateTaskHint(sessionId: string): Promise<TaskHint>
+  speak(sessionId: string, audioBlob: Blob, signal: AbortSignal, turnId: string): AsyncGenerator<SSEEvent>
+  getReport(sessionId: string): Promise<DialogueReport>
+}
+```
+
+- [ ] **Step 2: Extend real API client**
+
+In `src/views/Editor/EnglishSpeaking/services/llmService.ts`, add `LatestSessionResponse` to the type import list.
+
+In `RealDialogueAPI.createSession`, include `configId` and `userId` in the JSON body:
+
+```ts
+      body: JSON.stringify({
+        topic: config.topic,
+        grade: config.grade,
+        vocabulary: config.vocabulary ?? [],
+        sentences: config.sentences ?? [],
+        totalRounds: config.totalRounds,
+        durationMinutes: config.durationMinutes,
+        roleId: config.roleId,
+        configId: config.configId ?? null,
+        userId: config.userId ?? null,
+      }),
+```
+
+Add this method in `RealDialogueAPI` after `createSession`:
+
+```ts
+  async getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse> {
+    const params = new URLSearchParams({ configId, userId })
+    const res = await fetch(`${API_BASE}/sessions/latest?${params.toString()}`, {
+      method: 'GET',
+      credentials: 'include',
+    })
+    if (!res.ok) {
+      throw new DialogueApiError(`getLatestSession failed: ${res.status}`, res.status)
+    }
+    return res.json()
+  }
+```
+
+- [ ] **Step 3: Seed engine from historical messages**
+
+In `src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts`, update imports:
+
+```ts
+import type { PreviewChatMessage, DialogueAPI, DialogueReport, HistoricalDialogueMessage } from '@/types/englishSpeaking'
+```
+
+Add helper near the session attach section:
+
+```ts
+function toPreviewMessage(message: HistoricalDialogueMessage): PreviewChatMessage {
+  return {
+    id: message.id,
+    role: message.role,
+    content: message.content,
+    timestamp: new Date(),
+    status: 'done',
+    turnId: message.clientTurnId ?? undefined,
+    audioUrl: message.audioUrl ?? undefined,
+  }
+}
+```
+
+Update `attachSession`:
+
+```ts
+  function attachSession(info: {
+    sessionId: string
+    expiresAt?: string | null
+    totalRounds: number
+    currentRound?: number
+    messages?: HistoricalDialogueMessage[]
+  }) {
+    sessionId.value = info.sessionId
+    expiresAt.value = info.expiresAt ?? null
+    totalRounds.value = info.totalRounds
+    currentRound.value = info.currentRound ?? 1
+    if (info.messages?.length) {
+      messages.value = info.messages.map(toPreviewMessage)
+    }
+    if (info.expiresAt) startCountdown(info.expiresAt)
+  }
+```
+
+- [ ] **Step 4: Pass history into chat view and skip greeting when restored**
+
+In `src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue`, update the `onMounted` block:
+
+```ts
+onMounted(() => {
+  if (props.sessionInfo) {
+    const hasHistory = !!props.sessionInfo.messages?.length
+    engine.attachSession({
+      sessionId: props.sessionInfo.sessionId,
+      expiresAt: props.sessionInfo.expiresAt,
+      totalRounds: props.totalRounds,
+      currentRound: props.sessionInfo.currentRound,
+      messages: props.sessionInfo.messages,
+    })
+    if (!hasHistory) engine.generateGreeting()
+  } else {
+    console.warn('[DialogueChatView] mounted without sessionInfo; chat is inert. Parent must createSession before mounting.')
+  }
+  // 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件)
+})
+```
+
+- [ ] **Step 5: Run frontend typecheck**
+
+Run:
+
+```bash
+pnpm vue-tsc --noEmit
+```
+
+Expected: pass. If the repo does not define `vue-tsc`, run:
+
+```bash
+pnpm tsc --noEmit
+```
+
+Expected: pass.
+
+- [ ] **Step 6: Commit frontend API/engine changes**
+
+Run:
+
+```bash
+git add src/types/englishSpeaking.ts src/views/Editor/EnglishSpeaking/services/llmService.ts src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue
+git commit -m "feat: seed speaking chat from historical session"
+```
+
+---
+
+## Task 4: Frontend Student History Flow
+
+**Files:**
+- Modify: `/Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue`
+
+- [ ] **Step 1: Add runtime context helpers**
+
+In `TopicDiscussionPreview.vue`, after `preparedSession`, add:
+
+```ts
+const historyChecked = ref(false)
+
+const runtimeParams = computed(() => {
+  const params = new URLSearchParams(window.location.search)
+  return {
+    mode: params.get('mode'),
+    userId: params.get('userid'),
+  }
+})
+
+const isStudentRuntime = computed(() => runtimeParams.value.mode === 'student')
+const runtimeUserId = computed(() => runtimeParams.value.userId || '')
+```
+
+- [ ] **Step 2: Add history loader**
+
+Add this function after `startDialogue`:
+
+```ts
+async function loadLatestStudentSession() {
+  if (historyChecked.value) return
+  historyChecked.value = true
+  if (!isStudentRuntime.value) return
+
+  if (!props.configId) {
+    sessionError.value = '口语工具配置缺失,请联系老师重新发布。'
+    return
+  }
+  if (!runtimeUserId.value) {
+    sessionError.value = '学生身份缺失,请从课程入口重新进入。'
+    return
+  }
+
+  dialogueState.value = 'checking-history'
+  sessionError.value = null
+  try {
+    const api = createDialogueApi()
+    const { session } = await api.getLatestSession(props.configId, runtimeUserId.value)
+    if (!session) {
+      dialogueState.value = 'ready'
+      return
+    }
+
+    preparedSession.value = {
+      sessionId: session.sessionId,
+      expiresAt: session.expiresAt,
+      currentRound: session.currentRound,
+      messages: session.messages,
+    }
+
+    if (session.status === 'completed') {
+      const report = await api.getReport(session.sessionId)
+      handleDialogueComplete(report)
+      return
+    }
+
+    dialogueState.value = 'chatting'
+  } catch (err: unknown) {
+    console.error('[speaking] load latest session failed:', err)
+    dialogueState.value = 'ready'
+    if (err instanceof DialogueApiError) {
+      sessionError.value = `读取历史会话失败(${err.status}),请刷新重试`
+    } else {
+      sessionError.value = '读取历史会话失败,请刷新重试'
+    }
+  }
+}
+```
+
+- [ ] **Step 3: Pass identity when creating sessions**
+
+In `startDialogue`, add `configId` and `userId` to the `api.createSession` payload:
+
+```ts
+    const info = await api.createSession({
+      topic: speakingStore.config.topic || props.topic,
+      grade: speakingStore.config.grade,
+      totalRounds: speakingStore.config.practice.rounds ?? props.totalRounds,
+      durationMinutes: speakingStore.config.practice.duration,
+      roleId: mockRole.id,
+      vocabulary: speakingStore.config.learningGoals.vocabulary,
+      sentences: speakingStore.config.learningGoals.sentences,
+      configId: props.configId || null,
+      userId: isStudentRuntime.value ? runtimeUserId.value : null,
+    })
+```
+
+Update `preparedSession.value`:
+
+```ts
+    preparedSession.value = {
+      sessionId: info.sessionId,
+      expiresAt: info.expiresAt,
+      currentRound: info.currentRound,
+    }
+```
+
+- [ ] **Step 4: Render checking state**
+
+In the template, above the ready-stage block, add:
+
+```vue
+    <div v-if="dialogueState === 'checking-history'" class="ready-stage">
+      <div class="ready-header">
+        <h1 class="ready-title">
+          <span class="ready-title-icon">💬</span>
+          Topic Discussion
+        </h1>
+        <p class="ready-subtitle">正在读取你的练习记录...</p>
+      </div>
+      <div class="ready-body">
+        <span class="start-btn-spinner" />
+      </div>
+    </div>
+```
+
+Then change the existing ready block from:
+
+```vue
+    <div v-if="dialogueState === 'ready'" class="ready-stage">
+```
+
+to:
+
+```vue
+    <div v-else-if="dialogueState === 'ready'" class="ready-stage">
+```
+
+- [ ] **Step 5: Trigger history lookup after config load**
+
+Update `loadConfigFromBackend`:
+
+```ts
+async function loadConfigFromBackend(id: string) {
+  if (!id) {
+    await loadLatestStudentSession()
+    return
+  }
+  try {
+    const { config } = await getSpeakingConfig(id)
+    speakingStore.$patch({ config })
+  } catch (err) {
+    console.error('[speaking] load config failed:', err)
+  } finally {
+    await loadLatestStudentSession()
+  }
+}
+```
+
+Update the `props.configId` watcher so a new config can reset the history check:
+
+```ts
+watch(() => props.configId, (id) => {
+  historyChecked.value = false
+  loadConfigFromBackend(id)
+})
+```
+
+- [ ] **Step 6: Run frontend typecheck**
+
+Run:
+
+```bash
+pnpm vue-tsc --noEmit
+```
+
+Expected: pass. If unavailable, run:
+
+```bash
+pnpm tsc --noEmit
+```
+
+Expected: pass.
+
+- [ ] **Step 7: Commit student history flow**
+
+Run:
+
+```bash
+git add src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
+git commit -m "feat: restore student speaking sessions"
+```
+
+---
+
+## Task 5: End-to-End Verification
+
+**Files:**
+- Verify backend repository: `/Users/buoy/Development/gitrepo/cococlass-english-speaking-api`
+- Verify frontend repository: `/Users/buoy/Development/gitrepo/PPT`
+
+- [ ] **Step 1: Run backend tests**
+
+Run in backend repo:
+
+```bash
+uv run pytest tests/api/test_dialogue_greeting.py tests/api/test_dialogue_task_hint.py tests/service/speaking/test_dialogue_service_greeting.py tests/service/speaking/test_dialogue_service_report.py -q
+```
+
+Expected: pass.
+
+- [ ] **Step 2: Run frontend typecheck**
+
+Run in frontend repo:
+
+```bash
+pnpm vue-tsc --noEmit
+```
+
+Expected: pass. If unavailable, run:
+
+```bash
+pnpm tsc --noEmit
+```
+
+Expected: pass.
+
+- [ ] **Step 3: Manual API smoke test for latest lookup**
+
+With the backend running, create a session:
+
+```bash
+curl -s -X POST http://localhost:8000/api/speaking/dialogue/session \
+  -H 'Content-Type: application/json' \
+  -d '{"topic":"Smoke","grade":"grade5-1","totalRounds":2,"configId":"smoke-config","userId":"smoke-user"}'
+```
+
+Expected JSON includes `"sessionId"`.
+
+Then query latest:
+
+```bash
+curl -s 'http://localhost:8000/api/speaking/dialogue/sessions/latest?configId=smoke-config&userId=smoke-user'
+```
+
+Expected JSON:
+
+```json
+{
+  "session": {
+    "sessionId": "...",
+    "status": "active",
+    "totalRounds": 2,
+    "currentRound": 1,
+    "expiresAt": null,
+    "createdAt": "...",
+    "completedAt": null,
+    "messages": []
+  }
+}
+```
+
+- [ ] **Step 4: Manual frontend runtime checks**
+
+Use browser URLs that match existing app behavior:
+
+```text
+/?mode=editor3&courseid=smoke-course&userid=smoke-user
+```
+
+Expected: preview does not call `/sessions/latest`; start creates a new preview session only when clicked. Use a real course id in place of `smoke-course` when running against non-seeded data.
+
+```text
+/?mode=student&courseid=smoke-course&userid=smoke-user
+```
+
+Expected: formal student mode calls `/sessions/latest?configId=<the type 77 frame url value>&userId=smoke-user` before showing the start button. If the endpoint returns a session, the first-time start button is skipped.
+
+- [ ] **Step 5: Commit verification notes if any test fixtures changed**
+
+If verification required code or fixture changes, commit them:
+
+```bash
+git status --short
+git add tests/api/test_dialogue_greeting.py src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue
+git commit -m "test: verify student speaking session history"
+```
+
+If no files changed, do not create an empty commit.
+
+---
+
+## Self-Review
+
+Spec coverage:
+
+- `mode=student` versus `mode=editor3` runtime behavior is covered in Task 4 and Task 5.
+- `config_id` persistence and migration are covered in Task 1.
+- latest lookup with message history is covered in Task 2.
+- frontend `configId + userId` create and history lookup are covered in Tasks 3 and 4.
+- active history message restoration is covered in Task 3.
+- completed history routing to report is covered in Task 4.
+- "practice again" is intentionally out of scope and documented as not implemented.
+
+Placeholder scan:
+
+- The plan contains no placeholder markers or undefined placeholder steps.
+
+Type consistency:
+
+- Backend API field names use `configId` and `userId`.
+- Database and ORM field names use `config_id` and `user_id`.
+- Frontend reads URL `userid` and maps it to backend `userId`.
+- latest route uses `/api/speaking/dialogue/sessions/latest` consistently.

+ 223 - 0
docs/superpowers/specs/2026-04-27-speaking-student-session-history-design.md

@@ -0,0 +1,223 @@
+# Speaking Student Session History Design
+
+## Goal
+
+Each student should have independent English speaking dialogue sessions for each configured speaking tool in the PPT. The speaking tool configuration id already exists in the PPT JSON as the type 77 frame element's `url`. Student identity already exists on the student page as the URL `userid`.
+
+The system should use `configId + userId` to find a student's historical session for a specific speaking tool only in formal student mode. If history exists, the student view should open that historical session directly. If no history exists, the student should see the first-time start button and clicking it should create a session.
+
+URL `mode` is the runtime discriminator:
+
+- `mode=student`: formal student usage; read historical sessions by `configId + userId`.
+- `mode=editor3`: editor preview; do not read historical sessions.
+- other editor/screen modes: preview/display behavior; do not read historical sessions unless a later requirement explicitly enables it.
+
+## Current State
+
+Frontend:
+
+- `TopicDiscussionPreview.vue` receives `configId` from `elementInfo.url`.
+- `DialogueChatView.vue` correctly receives a prepared `sessionInfo` and does not create sessions itself.
+- `mode=student` controls whether `App.vue` renders the `Student` page. It is the runtime mode for formal student usage, but it is not a session ownership field.
+- The student `userid` is already read from the URL and passed into `Student`, but it is not currently passed through the slide rendering chain to `TopicDiscussionPreview`.
+- `createSession` currently sends topic, grade, rounds, duration, role, vocabulary, and sentences, but not `configId` or `userId`.
+
+Backend:
+
+- `DialogueSession` already has `user_id`.
+- `POST /api/speaking/dialogue/session` already accepts `userId` and stores it through `create_session_only`, but the frontend does not send it.
+- `DialogueSession` does not yet have `config_id`.
+- `SpeakingConfig.uuid` already exists and matches the frontend `configId`.
+
+## Proposed Behavior
+
+Student mode:
+
+1. `TopicDiscussionPreview` receives `configId` from the frame element and reads URL `userid` internally.
+2. On mount, if URL `mode=student` and both values are present, the component queries the backend for the latest session for that pair.
+3. If a latest session exists:
+   - active sessions open directly into chat using the returned `sessionInfo` and restored message history;
+   - completed sessions open directly into the report view using `GET /report?sessionId=...`.
+4. If no session exists, the ready screen shows the start button.
+5. Clicking start always creates a new session and stores `configId + userId` on that session.
+
+Editor / preview mode:
+
+- URL `mode=editor3` is preview and must not query historical sessions.
+- URL `userid` may be absent or present, but history lookup is disabled unless `mode=student`.
+- The component keeps the current behavior: show ready screen, create a preview session only when start is clicked.
+- Preview sessions may omit `userId` and can omit `configId` if the config id is unavailable, though passing `configId` is acceptable for traceability.
+
+## Backend Contract
+
+Extend create session request:
+
+```json
+{
+  "configId": "speaking-config-uuid",
+  "userId": "student-user-id",
+  "topic": "...",
+  "grade": "...",
+  "vocabulary": [],
+  "sentences": [],
+  "totalRounds": 3,
+  "durationMinutes": 5,
+  "roleId": "tom"
+}
+```
+
+`POST /api/speaking/dialogue/session` keeps its create semantics: every call creates a new session.
+
+Add latest lookup:
+
+```text
+GET /api/speaking/dialogue/session/latest?configId=...&userId=...
+```
+
+If a future session detail route is added, avoid route ambiguity with path parameters. Either define `/session/latest` before `/session/{sessionId}` in FastAPI, or use an unambiguous plural route such as:
+
+```text
+GET /api/speaking/dialogue/sessions/latest?configId=...&userId=...
+```
+
+Recommended response when found:
+
+```json
+{
+  "session": {
+    "sessionId": "...",
+    "status": "active",
+    "totalRounds": 3,
+    "currentRound": 1,
+    "expiresAt": "2026-04-27T10:00:00",
+    "createdAt": "2026-04-27T09:55:00",
+    "completedAt": null,
+    "messages": [
+      {
+        "id": "...",
+        "round": 1,
+        "role": "ai",
+        "content": "..."
+      }
+    ]
+  }
+}
+```
+
+Recommended response when not found:
+
+```json
+{
+  "session": null
+}
+```
+
+Using a nullable payload avoids making normal first-time student entry look like an error path.
+
+Active historical sessions must restore enough message history for the chat UI to match backend state. The latest lookup may return `messages` directly, or the backend may add a dedicated detail endpoint such as:
+
+```text
+GET /api/speaking/dialogue/session/{sessionId}
+```
+
+The first implementation should prefer returning `messages` from the latest lookup to keep the frontend flow simple.
+
+## Data Model
+
+Add `config_id` to `dialogue_session`:
+
+```sql
+config_id VARCHAR(36) NULL
+```
+
+Add an index optimized for latest lookup:
+
+```sql
+CREATE INDEX idx_dialogue_session_config_user_created
+ON dialogue_session (config_id, user_id, created_at);
+```
+
+The lookup should order by `created_at DESC, id DESC` so a student who uses "practice again" later gets the latest attempt.
+
+Existing deployments need a migration in addition to `init.sql`:
+
+```sql
+ALTER TABLE dialogue_session ADD COLUMN config_id VARCHAR(36) NULL;
+CREATE INDEX idx_dialogue_session_config_user_created
+ON dialogue_session (config_id, user_id, created_at);
+```
+
+## Frontend Data Flow
+
+Read runtime context in `TopicDiscussionPreview` from the URL:
+
+```ts
+const params = new URLSearchParams(window.location.search)
+const mode = params.get('mode')
+const userId = params.get('userid')
+```
+
+This avoids prop drilling through `Student -> ScreenSlideList -> ScreenSlide -> ScreenElement -> BaseFrameElement`. `configId` still comes from PPT JSON via `elementInfo.url`.
+
+For editor rendering:
+
+```text
+FrameElement.index.vue
+-> TopicDiscussionPreview configId only
+```
+
+Extend the dialogue API client:
+
+- `SessionConfig` adds optional `configId` and `userId`.
+- `RealDialogueAPI.createSession` sends both fields when present.
+- Add `getLatestSession(configId, userId)`.
+- Use URL `userid` as backend `userId`.
+- `DialogueChatView` / `useDialogueEngine` must support initial historical messages so active session history returned by the backend is visible before the student continues speaking.
+
+## UI States
+
+`TopicDiscussionPreview` should add a small loading state before ready/chat/report:
+
+- `checking-history`: loading historical session only when URL `mode=student`.
+- `ready`: no history; show first-time start button.
+- `chatting`: active historical session or newly created session.
+- `completed`: completed historical session or completed current session, including existing report statuses such as `evaluating`, `ready`, `failed`, and `incomplete`.
+
+If the latest lookup fails because of network/server error, keep the user on ready and show a concise retryable error. Do not silently create a new session, because that would hide history and fragment student records.
+
+## Error Handling
+
+- Missing `configId` when `mode=student`: show ready with an error because the speaking tool cannot be associated with a configured task.
+- Missing URL `userid` when `mode=student`: show ready with an error because student history cannot be isolated.
+- `mode=editor3`: never query latest session, even if `userid` exists in the URL.
+- Latest lookup returns null: normal first-time path.
+- Latest lookup returns active session whose `expiresAt` has passed: backend should treat subsequent interaction as completed; frontend should still use the returned session and let existing session APIs enforce status.
+
+## Testing
+
+Backend:
+
+- Create session stores `config_id` and `user_id`.
+- Migration adds `config_id` and the `(config_id, user_id, created_at)` index for existing databases.
+- Latest lookup returns null when no record exists.
+- Latest lookup returns the newest session for the exact `configId + userId` pair.
+- Latest lookup returns active session messages, or a detail endpoint exists to fetch them before rendering chat.
+- Latest lookup does not leak sessions across students or configs.
+
+Frontend:
+
+- `mode=student` with `configId + userid` queries latest before showing the start button.
+- `mode=editor3` with the same `configId + userid` does not query latest.
+- `TopicDiscussionPreview` reads URL `mode` and `userid` directly.
+- Existing student history skips first-time start and opens the returned session.
+- No history shows the start button and create sends `configId + userId`.
+- Editor preview remains usable without URL `userid`.
+
+## Out of Scope
+
+- Full historical attempt list UI.
+- A "practice again" button. The backend create endpoint already supports this later because every `POST /session` creates a new session.
+- Teacher dashboard/report aggregation.
+- Deleting or resetting student attempts.
+- Changing the existing `DialogueChatView` session ownership boundary.
+- Server-side authentication or validation of URL `userid`; current implementation may trust existing URL identity behavior.

+ 29 - 1
src/types/englishSpeaking.ts

@@ -204,6 +204,7 @@ export interface PreviewChatMessage {
   turnId?: string                                     // set when message is created via begin/commit
   recovery?: 'retry' | 'rerecord' | 'restart'        // set on error; drives the button matrix
   audioBlob?: Blob
+  audioUrl?: string
   evaluation?: {
     dimensions: {
       accuracy: 'excellent' | 'good' | 'improve'
@@ -242,6 +243,8 @@ export interface SessionConfig {
   durationMinutes: number
   vocabulary?: string[]
   sentences?: string[]
+  configId?: string | null
+  userId?: string | null
 }
 
 // 对话会话信息 (createSession 返回)
@@ -257,12 +260,36 @@ export interface GreetingInfo {
   aiMessage: string
 }
 
+export interface HistoricalDialogueMessage {
+  id: string
+  round: number
+  role: 'ai' | 'student'
+  content: string
+  audioUrl?: string | null
+  clientTurnId?: string | null
+}
+
 // 用于启动 chat view 的最小 session 信息(父组件 createSession 后传给 DialogueChatView)
 // expiresAt 由后端基于 createSession 时传入的 durationMinutes 计算下发 —— 后端权威,
 // 关页面/刷新/换设备重进都接续同一个截止时刻,确保倒计时不会因任何原因暂停。
 export interface SessionStartInfo {
   sessionId: string
   expiresAt: string | null
+  currentRound?: number
+  messages?: HistoricalDialogueMessage[]
+}
+
+export interface LatestSessionInfo extends SessionStartInfo {
+  status: 'active' | 'completed' | 'abandoned'
+  totalRounds: number
+  currentRound: number
+  createdAt: string | null
+  completedAt: string | null
+  messages: HistoricalDialogueMessage[]
+}
+
+export interface LatestSessionResponse {
+  session: LatestSessionInfo | null
 }
 
 // 对话报告
@@ -290,6 +317,7 @@ export interface TaskHint {
 // 对话 API 接口
 export interface DialogueAPI {
   createSession(config: SessionConfig): Promise<SessionInfo>
+  getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse>
   completeSession(sessionId: string): Promise<void>
   /** Throws DOMException('AbortError') on signal abort; throws DialogueApiError on non-OK HTTP. */
   generateGreeting(sessionId: string, turnId: string, signal?: AbortSignal): Promise<GreetingInfo>
@@ -308,4 +336,4 @@ export interface BadgeAchievement {
 }
 
 // 预览对话状态
-export type PreviewDialogueState = 'ready' | 'chatting' | 'completed'
+export type PreviewDialogueState = 'checking-history' | 'ready' | 'chatting' | 'completed'

+ 17 - 1
src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts

@@ -1,5 +1,5 @@
 import { ref, reactive, computed, onUnmounted } from 'vue'
-import type { PreviewChatMessage, DialogueAPI, DialogueReport } from '@/types/englishSpeaking'
+import type { PreviewChatMessage, DialogueAPI, DialogueReport, HistoricalDialogueMessage } from '@/types/englishSpeaking'
 import { createDialogueApi, DialogueApiError } from '../services/llmService'
 import { buildSpeakingWsUrl } from '../services/speakingApiConfig'
 
@@ -30,14 +30,30 @@ export function useDialogueEngine() {
    *  expiresAt 由后端在 createSession 时基于 durationMinutes 计算下发,前端只读 ——
    *  保证关页面/刷新/换设备重进都能接续同一个截止时刻,倒计时不暂停。
    */
+  function toPreviewMessage(message: HistoricalDialogueMessage): PreviewChatMessage {
+    return {
+      id: message.id,
+      role: message.role,
+      content: message.content,
+      timestamp: new Date(),
+      status: 'done',
+      turnId: message.clientTurnId ?? undefined,
+      audioUrl: message.audioUrl ?? undefined,
+    }
+  }
+
   function attachSession(info: {
     sessionId: string
     expiresAt?: string | null
     totalRounds: number
+    currentRound?: number
+    messages?: HistoricalDialogueMessage[]
   }) {
     sessionId.value = info.sessionId
     expiresAt.value = info.expiresAt ?? null
     totalRounds.value = info.totalRounds
+    currentRound.value = info.currentRound ?? 1
+    messages.value = (info.messages ?? []).map(toPreviewMessage)
     if (info.expiresAt) startCountdown(info.expiresAt)
   }
 

+ 10 - 1
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -973,6 +973,7 @@ watch(
 // 自动播放:AI 消息流式 done 后,合成并播一次。
 // 用 Set 去重防止 watcher 因为不相关重渲染重复触发。
 const autoPlayedIds = new Set<string>()
+const seededHistoricalMessageIds = new Set<string>()
 watch(
   () => engine.messages.value.map(m => `${m.id}:${m.status}`).join('|'),
   () => {
@@ -981,6 +982,7 @@ watch(
         m.role === 'ai' &&
         m.status === 'done' &&
         m.content &&
+        !seededHistoricalMessageIds.has(m.id) &&
         !autoPlayedIds.has(m.id)
       ) {
         autoPlayedIds.add(m.id)
@@ -1054,12 +1056,19 @@ watch(
 
 onMounted(() => {
   if (props.sessionInfo) {
+    const hasHistory = !!props.sessionInfo.messages?.length
+    seededHistoricalMessageIds.clear()
+    for (const message of props.sessionInfo.messages ?? []) {
+      seededHistoricalMessageIds.add(message.id)
+    }
     engine.attachSession({
       sessionId: props.sessionInfo.sessionId,
       expiresAt: props.sessionInfo.expiresAt,
       totalRounds: props.totalRounds,
+      currentRound: props.sessionInfo.currentRound,
+      messages: props.sessionInfo.messages,
     })
-    engine.generateGreeting()
+    if (!hasHistory) engine.generateGreeting()
   } else {
     console.warn('[DialogueChatView] mounted without sessionInfo; chat is inert. Parent must createSession before mounting.')
   }

+ 159 - 4
src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue

@@ -1,7 +1,20 @@
 <template>
   <div class="topic-discussion-preview">
     <!-- Ready 阶段:极简首页(参照 enspeak 布局) -->
-    <div v-if="dialogueState === 'ready'" class="ready-stage">
+    <div v-if="dialogueState === 'checking-history'" class="ready-stage">
+      <div class="ready-header">
+        <h1 class="ready-title">
+          <span class="ready-title-icon">💬</span>
+          Topic Discussion
+        </h1>
+        <p class="ready-subtitle">正在读取你的练习记录...</p>
+      </div>
+      <div class="ready-body">
+        <span class="start-btn-spinner" />
+      </div>
+    </div>
+
+    <div v-else-if="dialogueState === 'ready'" class="ready-stage">
       <div class="ready-header">
         <h1 class="ready-title">
           <span class="ready-title-icon">💬</span>
@@ -96,6 +109,19 @@ const dialogueState = ref<PreviewDialogueState>('ready')
 const sessionCreating = ref(false)
 const sessionError = ref<string | null>(null)
 const preparedSession = ref<SessionStartInfo | null>(null)
+const historyChecked = ref(false)
+const historyLoadToken = ref(0)
+
+const runtimeParams = computed(() => {
+  const params = new URLSearchParams(window.location.search)
+  return {
+    mode: params.get('mode'),
+    userId: params.get('userid'),
+  }
+})
+
+const isStudentRuntime = computed(() => runtimeParams.value.mode === 'student')
+const runtimeUserId = computed(() => runtimeParams.value.userId || '')
 
 const mockRole: PreviewAIRole = {
   id: 'tom',
@@ -215,8 +241,27 @@ const shouldShowOverallReport = computed(() => {
 const overallEvaluationForDisplay = computed(() => shouldShowOverallReport.value ? displayEvaluation.value : null)
 const displaySentenceEvaluations = computed(() => displayEvaluation.value?.sentenceEvaluations ?? [])
 
+function isHistoryTokenCurrent(token: number) {
+  return token === historyLoadToken.value
+}
+
+function nextHistoryLoadToken() {
+  historyLoadToken.value += 1
+  return historyLoadToken.value
+}
+
 async function startDialogue() {
   if (sessionCreating.value) return
+  if (isStudentRuntime.value) {
+    if (!props.configId) {
+      sessionError.value = '口语工具配置缺失,请联系老师重新发布。'
+      return
+    }
+    if (!runtimeUserId.value) {
+      sessionError.value = '学生身份缺失,请从课程入口重新进入。'
+      return
+    }
+  }
   sessionCreating.value = true
   sessionError.value = null
   reportFetchFailed.value = false
@@ -230,10 +275,13 @@ async function startDialogue() {
       roleId: mockRole.id,
       vocabulary: speakingStore.config.learningGoals.vocabulary,
       sentences: speakingStore.config.learningGoals.sentences,
+      configId: props.configId || null,
+      userId: isStudentRuntime.value ? runtimeUserId.value : null,
     })
     preparedSession.value = {
       sessionId: info.sessionId,
       expiresAt: info.expiresAt,
+      currentRound: info.currentRound,
     }
     dialogueState.value = 'chatting'
   } catch (err: unknown) {
@@ -247,6 +295,96 @@ async function startDialogue() {
   }
 }
 
+async function waitForCompletedHistoryReport(
+  api: ReturnType<typeof createDialogueApi>,
+  sessionId: string,
+  token: number,
+): Promise<DialogueReport | null> {
+  const maxAttempts = 30
+  const intervalMs = 2000
+
+  try {
+    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+      if (!isHistoryTokenCurrent(token)) return null
+      const report = await api.getReport(sessionId)
+      if (!isHistoryTokenCurrent(token)) return null
+      if (report.status === 'ready' || report.status === 'failed' || report.status === 'incomplete') {
+        return report
+      }
+      await new Promise(resolve => setTimeout(resolve, intervalMs))
+      if (!isHistoryTokenCurrent(token)) return null
+    }
+  } catch (err) {
+    console.error('[speaking] load completed history report failed:', err)
+    return null
+  }
+
+  return null
+}
+
+async function loadLatestStudentSession(token = nextHistoryLoadToken()) {
+  if (historyChecked.value) return
+  historyChecked.value = true
+  if (!isStudentRuntime.value) return
+
+  if (!props.configId) {
+    if (!isHistoryTokenCurrent(token)) return
+    sessionError.value = '口语工具配置缺失,请联系老师重新发布。'
+    return
+  }
+  if (!runtimeUserId.value) {
+    if (!isHistoryTokenCurrent(token)) return
+    sessionError.value = '学生身份缺失,请从课程入口重新进入。'
+    return
+  }
+
+  if (!isHistoryTokenCurrent(token)) return
+  dialogueState.value = 'checking-history'
+  sessionError.value = null
+  try {
+    const api = createDialogueApi()
+    const { session } = await api.getLatestSession(props.configId, runtimeUserId.value)
+    if (!isHistoryTokenCurrent(token)) return
+    if (!session) {
+      preparedSession.value = null
+      dialogueState.value = 'ready'
+      return
+    }
+
+    preparedSession.value = {
+      sessionId: session.sessionId,
+      expiresAt: session.expiresAt,
+      currentRound: session.currentRound,
+      messages: session.messages,
+    }
+
+    if (session.status === 'completed') {
+      const report = await waitForCompletedHistoryReport(api, session.sessionId, token)
+      if (!isHistoryTokenCurrent(token)) return
+      handleDialogueComplete(report)
+      return
+    }
+
+    if (session.status !== 'active') {
+      preparedSession.value = null
+      sessionError.value = '上次练习已失效,请重新开始。'
+      dialogueState.value = 'ready'
+      return
+    }
+
+    dialogueState.value = 'chatting'
+  } catch (err: unknown) {
+    if (!isHistoryTokenCurrent(token)) return
+    console.error('[speaking] load latest session failed:', err)
+    dialogueState.value = 'ready'
+    if (err instanceof DialogueApiError) {
+      sessionError.value = `读取历史会话失败(${err.status}),请刷新重试`
+    } else {
+      sessionError.value = '读取历史会话失败,请刷新重试'
+    }
+  }
+}
+
 function handleDialogueComplete(report: DialogueReport | null) {
   reportFetchFailed.value = !report
   reportStatus.value = report?.status ?? null
@@ -259,6 +397,7 @@ function handleDialogueComplete(report: DialogueReport | null) {
 }
 
 function resetPreview() {
+  nextHistoryLoadToken()
   dialogueState.value = 'ready'
   realEvaluation.value = null
   reportStatus.value = null
@@ -283,22 +422,38 @@ watch(
 
 // ── 根据 configId 从后端拉回配置注入 store ──
 async function loadConfigFromBackend(id: string) {
-  if (!id) return
+  const token = nextHistoryLoadToken()
+  if (!id) {
+    await loadLatestStudentSession(token)
+    return
+  }
   try {
     const { config } = await getSpeakingConfig(id)
+    if (!isHistoryTokenCurrent(token)) return
     speakingStore.$patch({ config })
   } catch (err) {
+    if (!isHistoryTokenCurrent(token)) return
     console.error('[speaking] load config failed:', err)
+  } finally {
+    if (isHistoryTokenCurrent(token)) {
+      await loadLatestStudentSession(token)
+    }
   }
 }
 
-watch(() => props.configId, (id) => { loadConfigFromBackend(id) })
+watch(() => props.configId, (id) => {
+  historyChecked.value = false
+  loadConfigFromBackend(id)
+})
 
 onMounted(() => {
   speakingStore.setPreviewState(dialogueState.value)
   loadConfigFromBackend(props.configId)
 })
-onUnmounted(() => { speakingStore.setPreviewState('ready') })
+onUnmounted(() => {
+  nextHistoryLoadToken()
+  speakingStore.setPreviewState('ready')
+})
 </script>
 
 <style lang="scss" scoped>

+ 15 - 0
src/views/Editor/EnglishSpeaking/services/llmService.ts

@@ -1,5 +1,6 @@
 import type {
   DialogueAPI,
+  LatestSessionResponse,
   SSEEvent,
   SessionConfig,
   SessionInfo,
@@ -209,6 +210,8 @@ export class RealDialogueAPI implements DialogueAPI {
         totalRounds: config.totalRounds,
         durationMinutes: config.durationMinutes,
         roleId: config.roleId,
+        configId: config.configId ?? null,
+        userId: config.userId ?? null,
       }),
     })
     if (!res.ok) {
@@ -223,6 +226,18 @@ export class RealDialogueAPI implements DialogueAPI {
     }
   }
 
+  async getLatestSession(configId: string, userId: string): Promise<LatestSessionResponse> {
+    const params = new URLSearchParams({ configId, userId })
+    const res = await fetch(`${API_BASE}/sessions/latest?${params.toString()}`, {
+      method: 'GET',
+      credentials: 'include',
+    })
+    if (!res.ok) {
+      throw new DialogueApiError(`getLatestSession failed: ${res.status}`, res.status)
+    }
+    return res.json()
+  }
+
   async completeSession(sessionId: string): Promise<void> {
     const res = await fetch(`${API_BASE}/session/${encodeURIComponent(sessionId)}/complete`, {
       method: 'POST',