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:
pnpm type-check + 手动联调)Spec: /Users/buoy/Development/gitrepo/PPT/doc/DialogueSessionSplitDesign.md
/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 |
Files:
/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/service/speaking/dialogue_service.py (拆 create_session 方法)Test: /Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/service/speaking/test_dialogue_service_greeting.py (新建)
[ ] Step 1.1: 写 service 层测试
新建 tests/service/speaking/test_dialogue_service_greeting.py:
"""Tests for split session creation + greeting generation."""
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.models.dialogue import Base, DialogueMessage, DialogueSession
from app.service.speaking.dialogue_service import DialogueService
@pytest_asyncio.fixture
async def db_session():
"""In-memory SQLite for fast tests. Uses SQLAlchemy core types that work on both MySQL and SQLite."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async with SessionLocal() as session:
yield session
await engine.dispose()
def _build_service(llm_response: str = "Hi! What's your favorite animal?") -> DialogueService:
llm = MagicMock()
llm.chat = AsyncMock(return_value=llm_response)
return DialogueService(
asr=MagicMock(),
llm=llm,
assessor=MagicMock(),
storage=MagicMock(),
)
@pytest.mark.asyncio
async def test_create_session_only_returns_session_without_llm_call(db_session: AsyncSession):
"""create_session_only 只建会话记录,不调 LLM,响应不含 aiMessage。"""
service = _build_service()
result = await service.create_session_only(
db=db_session,
topic="My favorite animal",
total_rounds=3,
)
assert "sessionId" in result
assert "aiMessage" not in result
assert result["totalRounds"] == 3
assert result["currentRound"] == 1
# DB 只有 session 无 message
sessions = (await db_session.execute(select(DialogueSession))).scalars().all()
assert len(sessions) == 1
messages = (await db_session.execute(select(DialogueMessage))).scalars().all()
assert len(messages) == 0
# LLM 未被调用
service.llm.chat.assert_not_called()
@pytest.mark.asyncio
async def test_generate_greeting_happy_path(db_session: AsyncSession):
"""对新建 session 首次生成开场白:调 LLM,落 round=1 AI 消息,返回内容。"""
service = _build_service(llm_response="Hello! Ready to chat?")
created = await service.create_session_only(
db=db_session, topic="Hobbies", total_rounds=3,
)
session_uuid = created["sessionId"]
result = await service.generate_greeting(db=db_session, session_uuid=session_uuid)
assert result["aiMessage"] == "Hello! Ready to chat?"
msgs = (await db_session.execute(
select(DialogueMessage).where(DialogueMessage.role == "ai")
)).scalars().all()
assert len(msgs) == 1
assert msgs[0].round == 1
assert msgs[0].content == "Hello! Ready to chat?"
service.llm.chat.assert_called_once()
@pytest.mark.asyncio
async def test_generate_greeting_idempotent_returns_existing(db_session: AsyncSession):
"""重复调 generate_greeting 不重复调 LLM,不重复落消息,返回相同内容。"""
service = _build_service(llm_response="First greeting")
created = await service.create_session_only(
db=db_session, topic="Sports", total_rounds=3,
)
session_uuid = created["sessionId"]
r1 = await service.generate_greeting(db=db_session, session_uuid=session_uuid)
# 把 mock 改成会抛异常的,证明二次调用不会走到 LLM
service.llm.chat = AsyncMock(side_effect=AssertionError("LLM should NOT be called on idempotent hit"))
r2 = await service.generate_greeting(db=db_session, session_uuid=session_uuid)
assert r1["aiMessage"] == "First greeting"
assert r2["aiMessage"] == "First greeting"
msgs = (await db_session.execute(
select(DialogueMessage).where(DialogueMessage.role == "ai")
)).scalars().all()
assert len(msgs) == 1
@pytest.mark.asyncio
async def test_generate_greeting_session_not_found(db_session: AsyncSession):
"""不存在的 session → raise LookupError。"""
service = _build_service()
with pytest.raises(LookupError):
await service.generate_greeting(
db=db_session, session_uuid="00000000-0000-0000-0000-000000000000",
)
@pytest.mark.asyncio
async def test_generate_greeting_session_not_active(db_session: AsyncSession):
"""completed session → raise ValueError('not active')。"""
service = _build_service()
created = await service.create_session_only(
db=db_session, topic="X", total_rounds=3,
)
session_uuid = created["sessionId"]
# 手动把 session 置为 completed
session = (await db_session.execute(
select(DialogueSession).where(DialogueSession.uuid == session_uuid)
)).scalar_one()
session.status = "completed"
await db_session.commit()
with pytest.raises(ValueError, match="not active"):
await service.generate_greeting(db=db_session, session_uuid=session_uuid)
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' 等)。
create_session_only 和 generate_greeting修改 app/service/speaking/dialogue_service.py。先看现有 create_session(约 44-97 行),保留原方法不要删(后面任务 2 才改 API 层);新增两个方法并让 create_session 委派给它们以便零破坏。
在 class DialogueService 里插入两个新方法(比如放在 create_session 之前,44 行上方):
async def create_session_only(
self,
db: AsyncSession,
topic: str,
total_rounds: int = 3,
duration_seconds: int | None = None,
role_config: dict | None = None,
user_id: str | None = None,
) -> dict:
"""只建会话记录,不调 LLM。"""
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(topic=topic)
expires_at = None
if duration_seconds:
expires_at = datetime.now() + timedelta(seconds=duration_seconds)
session = DialogueSession(
user_id=user_id,
topic=topic,
role_config=role_config,
total_rounds=total_rounds,
current_round=1,
status="active",
system_prompt=system_prompt,
expires_at=expires_at,
)
db.add(session)
await db.commit()
await db.refresh(session)
return {
"sessionId": session.uuid,
"totalRounds": total_rounds,
"currentRound": 1,
"expiresAt": expires_at.isoformat() if expires_at else None,
}
async def generate_greeting(
self,
db: AsyncSession,
session_uuid: str,
) -> dict:
"""为已存在的 session 生成开场白。幂等:已有 round=1 AI 消息直接返回。
Raises:
LookupError: session 不存在
ValueError: session 非 active
"""
# 行锁 serialize 同一 session 的并发请求
result = await db.execute(
select(DialogueSession)
.where(DialogueSession.uuid == session_uuid)
.with_for_update()
)
session = result.scalar_one_or_none()
if session is None:
raise LookupError(f"Session not found: {session_uuid}")
if session.status != "active":
raise ValueError(f"Session is not active: status={session.status}")
# 幂等:查 round=1 的 AI 消息
existing = await db.execute(
select(DialogueMessage)
.where(DialogueMessage.session_id == session.id)
.where(DialogueMessage.round == 1)
.where(DialogueMessage.role == "ai")
)
existing_msg = existing.scalar_one_or_none()
if existing_msg is not None:
await db.commit() # 释放行锁
return {"aiMessage": existing_msg.content}
# 生成开场白
messages = [
{"role": "system", "content": session.system_prompt},
{"role": "user", "content": f"Start a conversation about: {session.topic}"},
]
ai_greeting = await self.llm.chat(messages, model="")
ai_msg = DialogueMessage(
session_id=session.id,
round=1,
role="ai",
content=ai_greeting,
)
db.add(ai_msg)
await db.commit()
return {"aiMessage": ai_greeting}
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。
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/service/speaking/ -v
Expected: 所有既有测试 + 新测试都 PASS。
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."
Files:
/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/app/api/dialogue.py (/session 改响应,新增 /session/{id}/greeting 路由)/Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/test_dialogue_greeting.py (新建)Create: /Users/buoy/Development/gitrepo/cococlass-english-speaking-api/tests/api/__init__.py (空文件)
[ ] Step 2.1: 创建 api 测试包目录
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
mkdir -p tests/api
touch tests/api/__init__.py
新建 tests/api/test_dialogue_greeting.py:
"""End-to-end HTTP tests for POST /session and POST /session/{id}/greeting.
Uses httpx.AsyncClient + ASGITransport so everything runs on a single asyncio
event loop — no threading / TestClient contortions when we want to mutate DB
state between requests.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import update
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.api.dialogue import get_dialogue_service
from app.main import app
from app.models.database import get_db
from app.models.dialogue import Base, DialogueSession
from app.service.speaking.dialogue_service import DialogueService
@pytest_asyncio.fixture
async def test_env():
"""Bring up in-memory SQLite + dependency overrides + async HTTP client."""
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def _override_db():
async with SessionLocal() as s:
yield s
def _override_service():
llm = MagicMock()
llm.chat = AsyncMock(return_value="Hi! Ready to talk?")
return DialogueService(
asr=MagicMock(), llm=llm, assessor=MagicMock(), storage=MagicMock(),
)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_dialogue_service] = _override_service
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client, SessionLocal
app.dependency_overrides.clear()
await engine.dispose()
@pytest.mark.asyncio
async def test_post_session_does_not_include_ai_message(test_env):
client, _ = test_env
r = await client.post("/api/speaking/dialogue/session", json={
"topic": "My favorite animal", "totalRounds": 3,
})
assert r.status_code == 200
body = r.json()
assert "sessionId" in body
assert "aiMessage" not in body
@pytest.mark.asyncio
async def test_post_greeting_happy_path(test_env):
client, _ = test_env
r = await client.post("/api/speaking/dialogue/session", json={
"topic": "Sports", "totalRounds": 3,
})
session_id = r.json()["sessionId"]
g = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
assert g.status_code == 200
assert g.json() == {"aiMessage": "Hi! Ready to talk?"}
@pytest.mark.asyncio
async def test_post_greeting_idempotent(test_env):
client, _ = test_env
r = await client.post("/api/speaking/dialogue/session", json={
"topic": "X", "totalRounds": 3,
})
session_id = r.json()["sessionId"]
g1 = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
g2 = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
assert g1.status_code == 200
assert g2.status_code == 200
assert g1.json() == g2.json()
@pytest.mark.asyncio
async def test_post_greeting_session_not_found_returns_404(test_env):
client, _ = test_env
g = await client.post(
"/api/speaking/dialogue/session/00000000-0000-0000-0000-000000000000/greeting"
)
assert g.status_code == 404
@pytest.mark.asyncio
async def test_post_greeting_non_active_session_returns_409(test_env):
client, SessionLocal = test_env
r = await client.post("/api/speaking/dialogue/session", json={
"topic": "X", "totalRounds": 3,
})
session_id = r.json()["sessionId"]
# 直接在共享 engine 上更新 session 状态为 completed
async with SessionLocal() as s:
await s.execute(
update(DialogueSession)
.where(DialogueSession.uuid == session_id)
.values(status="completed")
)
await s.commit()
g = await client.post(f"/api/speaking/dialogue/session/{session_id}/greeting")
assert g.status_code == 409
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/api/test_dialogue_greeting.py -v
Expected: 5 个 test 全部 FAIL(路由不存在 / 响应含 aiMessage 等)。
/session 端点使用 create_session_only编辑 app/api/dialogue.py,找到 @router.post("/session")(约 51-67 行),替换 service.create_session(...) 为 service.create_session_only(...)(签名更干净,去掉了内部不用的 role_config):
@router.post("/session")
async def create_session(
req: CreateSessionRequest,
db: AsyncSession = Depends(get_db),
service: DialogueService = Depends(get_dialogue_service),
):
"""创建对话 session(不生成开场白)。开场白由 POST /session/{id}/greeting 触发。"""
logger.info(f"Creating session: topic={req.topic}, rounds={req.totalRounds}")
result = await service.create_session_only(
db=db,
topic=req.topic,
total_rounds=req.totalRounds,
duration_seconds=req.durationSeconds,
user_id=req.userId,
)
logger.info(f"Session created: {result['sessionId']}")
return result
/session/{session_id}/greeting 路由在 app/api/dialogue.py 中,/speak 路由上方(约 70 行之前)插入:
@router.post("/session/{session_id}/greeting")
async def generate_greeting(
session_id: str,
db: AsyncSession = Depends(get_db),
service: DialogueService = Depends(get_dialogue_service),
):
"""为已存在的 session 生成开场白(幂等)。"""
logger.info(f"Generating greeting: session={session_id}")
try:
result = await service.generate_greeting(db=db, session_uuid=session_id)
except LookupError:
raise HTTPException(status_code=404, detail="Session not found")
except ValueError:
raise HTTPException(status_code=409, detail="Session is not active")
logger.info(f"Greeting ready: session={session_id}")
return result
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest tests/api/test_dialogue_greeting.py -v
Expected: 5 个 test 全部 PASS。
create_session 方法回到 app/service/speaking/dialogue_service.py,删除原 create_session 方法(约 44-97 行)—— 现在没有调用方了。
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest -v
Expected: 所有测试 PASS;没有 create_session 相关测试残留报错。
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)."
Files:
/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:
PreviewChatMessage 接口(约 201-227 行),在 error?: string 下方加: /** true 表示这个错误无法重试(如 session 404/409),UI 应提示用户重开 */
unrecoverable?: boolean
SessionInfo 接口(约 247-251 行),改为:// 对话会话信息 (createSession 返回)
export interface SessionInfo {
sessionId: string
totalRounds: number
currentRound: number
expiresAt: string | null
}
// 开场白生成结果 (generateGreeting 返回)
export interface GreetingInfo {
aiMessage: string
}
DialogueAPI 接口(约 259-263 行),改为:// 对话 API 接口
export interface DialogueAPI {
createSession(config: SessionConfig): Promise<SessionInfo>
generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo>
speak(sessionId: string, audioBlob: Blob, signal: AbortSignal): AsyncGenerator<SSEEvent>
getReport(sessionId: string): Promise<DialogueReport>
}
llmService.ts编辑 /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/services/llmService.ts。
DialogueApiError 和 GreetingInfo 引用(GreetingInfo 已在 types 里,引进来):import type {
DialogueAPI,
SSEEvent,
SessionConfig,
SessionInfo,
GreetingInfo,
DialogueReport,
SentenceEvaluation,
} from '@/types/englishSpeaking'
const API_BASE = ... 下方定义 error 类与工厂(约第 4 行后):export class DialogueApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
this.name = 'DialogueApiError'
}
}
export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI {
return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
}
RealDialogueAPI.createSession(约 137-151 行): async createSession(config: SessionConfig): Promise<SessionInfo> {
const res = await fetch(`${API_BASE}/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
topic: config.topic,
totalRounds: config.totalRounds,
roleId: config.roleId,
}),
})
if (!res.ok) {
throw new DialogueApiError(`createSession failed: ${res.status}`, res.status)
}
const body = await res.json()
return {
sessionId: body.sessionId,
totalRounds: body.totalRounds,
currentRound: body.currentRound,
expiresAt: body.expiresAt ?? null,
}
}
RealDialogueAPI 里,createSession 方法之后新加: async generateGreeting(sessionId: string, signal?: AbortSignal): Promise<GreetingInfo> {
const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, {
method: 'POST',
credentials: 'include',
signal,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new DialogueApiError(
`greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`,
res.status,
)
}
return res.json()
}
MockDialogueAPI.createSession(约 191-197 行): async createSession(_config: SessionConfig): Promise<SessionInfo> {
this.roundIndex = 0
return {
sessionId: 'mock-session-' + Date.now(),
totalRounds: _config.totalRounds,
currentRound: 1,
expiresAt: null,
}
}
async generateGreeting(_sessionId: string, _signal?: AbortSignal): Promise<GreetingInfo> {
// 模拟 300ms 延迟
await new Promise(r => setTimeout(r, 300))
return { aiMessage: "Hi! What's your favorite animal?" }
}
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -30
Expected: 没有 error;如果报 useDialogueEngine.ts:39 附近 aiMessage 访问错,留到 Task 4 里一起修(预期行为)。
如有其它地方引用了 SessionInfo.aiMessage,搜一遍:
grep -rn "\.aiMessage\b" /Users/buoy/Development/gitrepo/PPT/src
Expected:只剩 useDialogueEngine.ts 一处(本次任务不改它,Task 4 处理)。
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."
Files:
Modify: /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/composables/useDialogueEngine.ts (拆 initSession,新增 attachSession / generateGreeting / retryGreeting,含 inflight 锁 + AbortController)
[ ] Step 4.1: 更新 imports
编辑 useDialogueEngine.ts,把第 2 行 import 扩展(加入工厂和 error 类):
import { MockDialogueAPI, RealDialogueAPI, createDialogueApi, DialogueApiError } from '../services/llmService'
保留现有 MockDialogueAPI / RealDialogueAPI 的 import 不强求,但用工厂就不需要它们了 —— 建议清掉改为:
import { createDialogueApi, DialogueApiError } from '../services/llmService'
第 13 行把 let api: DialogueAPI = mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI() 改为:
const api: DialogueAPI = createDialogueApi(mode)
initSession删除原 initSession 函数(约 23-42 行),在同一位置插入:
// ==================== Session Attach ====================
const greetingInflight = ref(false)
let greetingAbortController: AbortController | null = null
/** 仅附加已建好的 session 到 engine,不发任何网络请求。 */
function attachSession(info: { sessionId: string; expiresAt?: string | null }) {
sessionId.value = info.sessionId
expiresAt.value = info.expiresAt ?? null
if (info.expiresAt) startCountdown(info.expiresAt)
}
/** 触发开场白生成:push loading 占位 AI 消息 → 请求 /greeting → 成功填内容,失败标 error。 */
async function generateGreeting() {
if (!sessionId.value || greetingInflight.value) return
greetingInflight.value = true
greetingAbortController = new AbortController()
const aiMsg = reactive<PreviewChatMessage>({
id: crypto.randomUUID(),
role: 'ai',
content: '',
timestamp: new Date(),
status: 'loading',
})
messages.value.push(aiMsg)
try {
const { aiMessage } = await api.generateGreeting(sessionId.value, greetingAbortController.signal)
aiMsg.content = aiMessage
aiMsg.status = 'done'
speakTTS(aiMessage)
} catch (err: any) {
if (err?.name === 'AbortError') return // 组件卸载:不改 UI
aiMsg.status = 'error'
aiMsg.error = friendlyErrorMessage(err?.message)
const status = err instanceof DialogueApiError ? err.status : undefined
aiMsg.unrecoverable = status === 404 || status === 409
} finally {
greetingInflight.value = false
greetingAbortController = null
}
}
/** 重试失败的开场白:移除 error 消息,再走一遍 generateGreeting(同 sessionId)。 */
async function retryGreeting() {
if (greetingInflight.value) return
const firstAi = messages.value.find(m => m.role === 'ai')
if (firstAi?.status !== 'error' || firstAi.unrecoverable) return
messages.value = messages.value.filter(m => m.id !== firstAi.id)
await generateGreeting()
}
onUnmounted 追加 greeting abort找到 onUnmounted(约 391 行),把原本的:
onUnmounted(() => {
abort()
cancelTTS()
stopCountdown()
})
改为:
onUnmounted(() => {
abort()
greetingAbortController?.abort()
cancelTTS()
stopCountdown()
})
找到返回对象(约 397 行),把 initSession 行替换为三个新方法:
return {
messages,
sessionId,
currentRound,
isComplete,
isProcessing,
canRecord,
countdownSeconds,
greetingInflight, // 新增:UI 可用来 disable 重试按钮
attachSession, // 替代 initSession
generateGreeting,
retryGreeting,
sendStudentMessage,
beginStudentStream,
streamFallback,
retryMessage,
regenerateAiMessage,
getReport,
abort,
cancelTTS,
}
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -40
Expected:在 DialogueChatView.vue 里会报 engine.initSession is not a function 之类的错(下一个任务修);useDialogueEngine.ts 本身应干净。
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."
Files:
Modify: /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue (新 sessionInfo prop + restart emit;onMounted 改写;error-card 按钮分支;handleRestart 改 emit)
[ ] Step 5.1: 新增 prop 和 emit
编辑 DialogueChatView.vue。找到 interface Props(约 491 行),在最后加一行:
interface Props {
topic?: string
keywords?: string[]
aiName?: string
aiAvatar?: string
totalRounds?: number
mode?: 'preview' | 'real'
showEnglishText?: boolean
showChineseText?: boolean
sessionInfo?: { sessionId: string; expiresAt: string | null } | null
}
withDefaults 调用(约 502 行)对应加 sessionInfo: null:
const props = withDefaults(defineProps<Props>(), {
topic: '我最喜欢的动物',
keywords: () => ['animal', 'zoo', 'cute', 'favorite'],
aiName: 'Tom',
aiAvatar: '😊',
totalRounds: 3,
mode: 'preview',
showEnglishText: true,
showChineseText: false,
sessionInfo: null,
})
找到 const emit = defineEmits<...>(约 513 行),扩展为:
const emit = defineEmits<{
complete: [report: DialogueReport | null]
restart: []
}>()
onMounted 逻辑找到 onMounted(约 950 行)。把内部 engine.initSession(...) 调用整体替换:
onMounted(() => {
if (props.sessionInfo) {
engine.attachSession(props.sessionInfo)
engine.generateGreeting()
}
// 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件)
totalTimer = setInterval(() => {
if (engine.countdownSeconds.value == null) totalSeconds.value += 1
}, 1000)
})
retryAiMessage 帮助函数在 script setup 里 handleRetry 附近(约 721 行之后)插入:
function retryAiMessage(message: PreviewChatMessage) {
const idx = engine.messages.value.indexOf(message)
const hasPrevStudent = engine.messages.value.slice(0, idx).some(m => m.role === 'student')
// 第一条 AI 消息(前面没有学生消息)= greeting
if (!hasPrevStudent) {
if (message.unrecoverable) {
emit('restart')
return
}
engine.retryGreeting()
return
}
// 非首条:沿用原 regenerate
engine.regenerateAiMessage(message.id)
}
找到模板里 AI 消息的 error-card 块(约 85-88 行):
<!-- AI 错误 -->
<div v-if="message.status === 'error'" class="error-card">
<span class="error-text">{{ message.error || '生成失败' }}</span>
<button class="retry-btn" @click="engine.regenerateAiMessage(message.id)">重新生成</button>
</div>
替换为:
<!-- AI 错误 -->
<div v-if="message.status === 'error'" class="error-card">
<span class="error-text">{{ message.error || '生成失败' }}</span>
<button
class="retry-btn"
:disabled="engine.greetingInflight.value"
@click="retryAiMessage(message)"
>{{ message.unrecoverable ? '返回重开' : '重新生成' }}</button>
</div>
handleRestart找到 handleRestart(约 817 行),替换为:
function handleRestart() {
showExitConfirm.value = false
engine.abort()
engine.cancelTTS()
emit('restart')
}
(父组件 TopicDiscussionPreview 会在收到 emit 后清状态并回 ready 页,session 重建由用户再点"开始对话"触发。)
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -30
Expected:DialogueChatView 内部应干净;在 TopicDiscussionPreview.vue 处可能还报 prop/emit 未使用的警告(下一个任务修)。
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"
Files:
Modify: /Users/buoy/Development/gitrepo/PPT/src/views/Editor/EnglishSpeaking/preview/TopicDiscussionPreview.vue (async startDialogue,loading/error UI,监听 @restart)
[ ] Step 6.1: 更新 imports
编辑 TopicDiscussionPreview.vue,在 import 区域(约 63-69 行)加:
import { createDialogueApi, DialogueApiError } from '../services/llmService'
在 dialogueState ref 定义(约 89 行)之后加:
const sessionCreating = ref(false)
const sessionError = ref<string | null>(null)
const preparedSession = ref<{ sessionId: string; expiresAt: string | null } | null>(null)
startDialogue找到 startDialogue 函数(约 198-200 行):
function startDialogue() {
dialogueState.value = 'chatting'
}
替换为:
async function startDialogue() {
if (sessionCreating.value) return
sessionCreating.value = true
sessionError.value = null
try {
const api = createDialogueApi(props.mode)
const info = await api.createSession({
topic: speakingStore.config.topic || props.topic,
totalRounds: speakingStore.config.practice.rounds || props.totalRounds,
roleId: 'tom',
vocabulary: speakingStore.config.learningGoals.vocabulary,
})
preparedSession.value = {
sessionId: info.sessionId,
expiresAt: info.expiresAt,
}
dialogueState.value = 'chatting'
} catch (err: unknown) {
if (err instanceof DialogueApiError) {
sessionError.value = `创建会话失败(${err.status}),请重试`
} else {
sessionError.value = '创建会话失败,请重试'
}
} finally {
sessionCreating.value = false
}
}
resetPreview 清 preparedSession找到 resetPreview(约 207 行):
function resetPreview() {
dialogueState.value = 'ready'
realEvaluation.value = null
}
替换为:
function resetPreview() {
dialogueState.value = 'ready'
realEvaluation.value = null
preparedSession.value = null
sessionError.value = null
sessionCreating.value = false
}
找到模板中 <DialogueChatView> 标签(约 33-42 行):
<DialogueChatView
v-else-if="dialogueState === 'chatting'"
:topic="speakingStore.config.topic || topic"
:keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
:ai-name="mockRole.name"
:ai-avatar="mockRole.avatar"
:total-rounds="speakingStore.config.practice.rounds || totalRounds"
:mode="mode"
@complete="handleDialogueComplete"
/>
扩展为:
<DialogueChatView
v-else-if="dialogueState === 'chatting'"
:topic="speakingStore.config.topic || topic"
:keywords="speakingStore.config.learningGoals.vocabulary.length ? speakingStore.config.learningGoals.vocabulary : keywords"
:ai-name="mockRole.name"
:ai-avatar="mockRole.avatar"
:total-rounds="speakingStore.config.practice.rounds || totalRounds"
:mode="mode"
:session-info="preparedSession"
@complete="handleDialogueComplete"
@restart="resetPreview"
/>
找到 ready 阶段的 start-btn(约 18-29 行):
<div class="ready-footer">
<button class="start-btn" @click="startDialogue">
<svg ...><!-- 麦克风图标 --></svg>
开始对话
</button>
</div>
替换为:
<div class="ready-footer">
<button class="start-btn" :disabled="sessionCreating" @click="startDialogue">
<svg v-if="!sessionCreating" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
<span v-else class="start-btn-spinner" />
{{ sessionCreating ? '创建中…' : '开始对话' }}
</button>
<p v-if="sessionError" class="session-error-text">{{ sessionError }}</p>
</div>
在 <style lang="scss" scoped> 区(找 .start-btn 样式附近,约第 312 行)下方追加:
.start-btn:disabled {
opacity: 0.6;
cursor: wait;
background: #fb923c;
}
.start-btn-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: start-btn-spin 0.8s linear infinite;
display: inline-block;
}
@keyframes start-btn-spin {
to { transform: rotate(360deg); }
}
.session-error-text {
margin: 8px 0 0;
font-size: 12px;
color: #dc2626;
text-align: center;
}
cd /Users/buoy/Development/gitrepo/PPT
pnpm type-check 2>&1 | tail -30
Expected: 无 error,无 warning。
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"
cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run uvicorn app.main:app --reload --port 8000
Expected: uvicorn started,无异常日志。
cd /Users/buoy/Development/gitrepo/PPT
pnpm dev
打开浏览器 DevTools Network 面板验证请求顺序:
POST /api/speaking/dialogue/session → 200,响应体 不含 aiMessagePOST /api/speaking/dialogue/session/{id}/greeting → 200,响应体 {"aiMessage":"..."}
[ ] Step 7.4: 手动测 - session 创建失败重试
在 OneHubLLM.chat 里临时 raise Exception(如 raise RuntimeError("forced fail")),重启后端。
OneHubLLM.chat,重启后端验证 Network 面板:第二次 /greeting 请求命中同一个 sessionId。
DELETE FROM dialogue_session WHERE uuid = '...'/session/{删除的id}/greeting(curl 即可),看是否返 404RealDialogueAPI.generateGreeting 临时返固定的 404 mock 错误,点击"重新生成"按钮,观察文案变成"返回重开",点击后应回到 ready 页或更简单:在 DevTools Console 手动操控消息状态:
// 找到第一条 AI 消息,把 unrecoverable 改 true,status 改 error
// (仅用于 smoke 检查 UI 分支)
在 Network 面板打开 Throttling → Slow 3G。
验证 DB:新老两个 session 各自 round=1 AI 只有一条。
或用 curl 并发验证:
# 建个 session
SID=$(curl -s -X POST http://localhost:8000/api/speaking/dialogue/session \
-H 'Content-Type: application/json' \
-d '{"topic":"test","totalRounds":3}' | jq -r .sessionId)
echo "session=$SID"
# 并发两次 greeting
(curl -s -X POST http://localhost:8000/api/speaking/dialogue/session/$SID/greeting &
curl -s -X POST http://localhost:8000/api/speaking/dialogue/session/$SID/greeting &
wait) | tee /tmp/greetings.log
# 应当两个响应 aiMessage 完全相同
cat /tmp/greetings.log
# DB 只有一条 round=1 AI 消息
mysql -u root speaking -e \
"SELECT COUNT(*) FROM dialogue_message m JOIN dialogue_session s ON m.session_id=s.id \
WHERE s.uuid='$SID' AND m.role='ai' AND m.round=1"
# → 1
(canceled)regenerateAiMessage 照旧工作cd /Users/buoy/Development/gitrepo/cococlass-english-speaking-api
uv run pytest -v
Expected: 所有 test PASS。
对着 /Users/buoy/Development/gitrepo/PPT/doc/DialogueSessionSplitDesign.md §6 的 11 条验收标准逐条打勾。
如果上线后 LLM 失败率暴增导致用户大量走"重新生成"路径挤压 LLM 配额,可以通过以下方式紧急回滚:
create_session_only / generate_greeting,但恢复 /session 的旧 bundled 行为)—— 因为类型和前端已经不兼容,实际回滚路径是回滚前端+后端两边。feat/english-speaking)上,反转 commits 用 git revert <hash> 即可。没有 DB schema 变更,回滚不涉及数据清理。