Add teacher microphone and per-question model selection

This commit is contained in:
鲨鱼辣椒 committed 2026-09-29 19:43:45 +08:00
1 parent cd1ba41e21
commit 78cfde6734
20 files changed
+517 -37

No files matched your search

@@ -0,0 +1,56 @@
# Task: Add teacher voice input and model selection
## Identity
- Task ID: 20260929-teacher-composer-4f829ac1
- Mode: Feature
- Branch: codex/20260929-teacher-composer-4f829ac1-teacher-composer
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-teacher-composer-4f829ac1
- Base commit: cd1ba41e214ebe4a1f698be0dde14591bb7b87df
- Owner: codex
- Status: Ready for Integration
## Scope
- Add microphone transcription and model selection to every teacher composer using operation-composer patterns. Explicit selection uses the chosen model's default thinking settings; no new reasoning override protocol.
## Intent And Constraints
- Preserve account/teacher history, drafts, timestamps, per-turn published configuration and student billing; no subagents, paid test prompts, cloud publishing, main merge/push or desktop activation.
## Outcome
- Shared operation/teacher microphone button and voice hook; transcripts append to the latest draft and never auto-send. Scope/account changes and unmount cancel late results. Toolbar wraps at narrow widths.
- Main exposes a credential-free catalog intersecting cloud capability and account text models; each send revalidates the choice. Per-account/teacher selection is remembered, and requests freeze modelId with existing retry identity. Default follows the published teacher; persona, resources, history, versions and billing remain intact. Automatic check-ins keep their cloud configuration.
- Paired Yuxi commit 489549c and WS commit 0509e0ba implement optional model_id, immutable run snapshots, idempotency and signed teacher_model_id billing. They are not merged, pushed or deployed. Old cloud/gateway capability is explicit and never silently ignores a choice.
- Trial updated in the background, PID 32583, preserving its existing empty-work overlay 0167d36 in a separate build artifact. Peer worktrees remain unchanged. Live API reports selection unsupported until cloud rollout; microphone and selector UI are present. No real recording, paid prompt, cloud configuration publishing or desktop activation performed.
## Verification
- Typecheck, full lint (0 errors / 8 existing warnings), production Vite/Main build passed. Full suite: 267 files / 2694 tests passed / 3 skipped, followed by 1 pressure test passed. After the final toolbar wrap adjustment and one extra model-catalog filter test: 83 focused tests and both 319px/508px browser layout cases passed. No new full-suite failures remain.
- Trial artifact including the already-running empty-work overlay: typecheck/build and 65 preview regressions passed. Same account, selected teacher, route, 14 operation messages, teacher history and 14 draft entries verified; no desktop focus acquisition during monitored restart. Browser screenshots and live composer screenshot inspected.
- Server verification: Yuxi 26 relevant unit/HTTP/SDK tests, engineering gate and 62 validator tests; WS 79 relevant tests passed / 1 PostgreSQL-environment case skipped. Complete PostgreSQL/Redis/worker integration and paid provider calls are not run. No independent subagent review, per user instruction.
## Follow-ups
- Cloud rollout needs separate authorization beyond the temporary app: WS API first, then Yuxi API/worker. Complete service-environment integration before production release. No teacher republish or history migration is needed. Keep the running trial source and profile.
## Promotion Candidates
- Target: teacher/model capability ADR and architecture. Proposal: explicit per-question teacher model with cloud capability gating, unchanged persona/resources/version/payer; voice is draft-only. Evidence: service/UI/billing tests above. Future impact: three-part rollout and immutable retry choice. Earlier cloud-only model ownership is extended by this user's explicit selection request; canonical promotion belongs to integration.
## Project Context Loaded
- Task 20260929-teacher-composer-4f829ac1, feature, owns this worktree and branch per Identity at base cd1ba41. App worktree creation returned Not a git repository for the parent chat directory; official task_context isolated from the verified previous trial source. Start/status passed.
- Read entry/memory index, active task, positioning, current integrated snapshot, decision index and teacher/model-capability ADRs, system overview/module map/data flow, domain/success criteria and evidence/reflection/commitment indexes. Read every peer scope/constraints/promotion record through status registry. Current source includes newer approved timestamp/bubble/community changes than the integrated snapshot.
- Teacher capability/persona is cloud-owned, with per-account/teacher local history, student-paid runs and per-question version/project/read-only context. The user now explicitly wants client model selection, extending the earlier cloud-only model choice while preserving cloud persona/resources and billing. Main remains the validation and credential boundary.
- Peers: earlier trial/community work retained for running app; teacher-help-label and formal application release work remain separate; empty-work-preview and operation language work are not in this baseline. No identified semantic conflict; retain all current labels/UI and leave peer checkouts unchanged.
- Unknown dependency: current cloud /questions client has no model override field; inspect the committed Yuxi contract before deciding server support. No cosmetic model dropdown that silently keeps another model.
- Gate: Passed for client work and dependency investigation.
## Plan
1. Reuse the operation voice hook/button and input safeguards, scoped to account/teacher/context; keep transcription as an editable draft.
2. Trace cloud model capability/override support and implement an authoritative model selection contract, preserving request identity, version binding, student billing and history; selected models use their defaults.
3. Verify account/teacher switches, requests/retries, unavailable models and responsive composer layout; run required checks and update the trial in the background when the working integration is ready.
+1
View File
@@ -188,6 +188,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
- 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或回复协议超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。
- `tests/fixtures/teacher-guidance-examples.json` 保留人工样例作为旧回复的读取兼容资料,仅验证正文和快捷回复投影,不加载到运行时,也不代表真实模型教学效果已验收。云端定义由 Yuxi 维护并由运营显式同步下发;本地代码修改不会自动改写云端配置或学生已接受问题的固定发布版本。
- 下发智能体的图标随发布版本保存,顶栏、咨询面板、欢迎区和消息使用定义中的 `icon_url`;未设置或图片加载失败时使用内置头像。运营在 Yuxi 更换图标后需同步下发配置,联系人使用当前下发的名称和图标;每轮执行仍记录实际发布版本。相对图片地址由服务端使用 Yuxi 公网入口补全。
- 老师输入框复用操作 Agent 的麦克风,语音识别只追加当前草稿,不自动发送;切换账号、老师、项目或来源、关闭面板时丢弃过期识别。模型选择按账号和老师记忆,默认跟随老师配置;显式模型仅用于后续手动提问,思考使用模型默认设置。每轮冻结选择及幂等身份,保留发布人设、资源、版本与学生计费;主动检查继续遵循云端老师配置。模型目录与准入由 Main/云端验证,旧云服务显示待更新,不静默忽略选择。需先更新 WS 模型授权接口,再更新 Yuxi API/worker,客户端不会修改或发布云端配置。
- 咨询聊天和文字草稿按当前云端账号、已下发智能体保存;切换项目或操作对话仍延续同一个聊天,每轮发送冻结当时的项目、来源和发布版本并检查启停。引用保留原项目/来源,切换后必须移除失配引用再发送;旧组件仅保留原始归档,不恢复交互。历史按50轮分页,流式事件只传当前轮;本机每轮独立原子落盘,索引保存消息摘要,旧轮次可通过只读工具按ID读取。可确认归属的旧话题按来源ID幂等迁入,原文件保留;“旧记录”入口只读查看升级前项目/来源话题。项目、Pi来源或版本变化,以及首次从旧组件协议升级时,切换内部云线程并带入有预算的近期正文和快捷回复,同一段由Yuxi原生历史压缩管理上下文。此版聊天历史在本机保存,不提供跨设备同步。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。云端断线按游标和持久状态恢复;停止或退出账号会取消原云端问题,下次提问先停止旧的未完成问题再绑定新上下文。只接收云端主线程回答,子智能体文本不混入正文。没有操作对话时也可咨询,但无操作文本上下文。
- 删除操作对话会停止正在使用该来源的回复,保留项目级咨询历史;归档来源需先恢复后才能引用。原有来源会话级智能体 API 与存储保持兼容,原路径历史不自动迁移到新项目级咨询栏。运营草稿试聊仍使用 `niancode://coding-teacher/preview?draft_revision=N` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。
+9 -2
View File
@@ -32,10 +32,11 @@ export async function handleCodingTeacherRoutes(
const role = projectTopics?.[2] === 'friend' ? 'friend' : undefined;
const config = url.pathname === '/api/coding/teacher/config' || url.pathname === '/api/coding/friend/config';
const catalog = url.pathname === '/api/coding/teacher/teachers';
const models = url.pathname === '/api/coding/teacher/models';
const draft = url.pathname === '/api/coding/teacher-preview';
const pending = url.pathname === '/api/coding/teacher-preview/pending-link';
if (!legacy && !conversation && !source && !preview && !projectTopics && !checkIn && !config && !catalog && !draft && !pending) return false;
if ((config || catalog || draft || pending) && req.method !== 'GET') {
if (!legacy && !conversation && !source && !preview && !projectTopics && !checkIn && !config && !catalog && !models && !draft && !pending) return false;
if ((config || catalog || models || draft || pending) && req.method !== 'GET') {
sendJson(res, 405, { error: '不支持此操作。' });
return true;
}
@@ -78,6 +79,12 @@ export async function handleCodingTeacherRoutes(
));
return true;
}
if (models) {
const runtime = url.searchParams.get('runtime');
if (runtime !== 'local' && runtime !== 'yuxi') throw new TeacherError(422, 'teacher_runtime_invalid', '模型来源无效。');
sendJson(res, 200, await service.models(runtime));
return true;
}
if (catalog) {
sendJson(res, 200, await service.catalog());
return true;
+1
View File
@@ -251,6 +251,7 @@ export function prepareCloudTeacher(
'/questions',
{
teacher_version: currentRequest?.teacherVersion ?? topic.version,
...(currentRequest?.modelId ? { model_id: currentRequest.modelId } : {}),
thread_id: currentRequest?.runtimeThreadId ?? topic.id,
request_id: requestId,
query,
+38
View File
@@ -0,0 +1,38 @@
import type { TeacherModelCatalog } from '../../shared/coding-teacher';
import { normalizeManagedModelCatalog } from '../../shared/managed-model-capabilities';
import { teacherCloudRequest, assertTeacherAccount, TeacherError, type TeacherAccount } from './config-client';
import { teacherCloudTransport } from './cloud-runner';
export function validateTeacherModelId(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || !value.trim() || value.length > 200 || value !== value.trim())
throw new TeacherError(422, 'teacher_model_invalid', '请选择有效的模型。');
return value;
}
/** Main strips credentials; each selected model is revalidated when the question is accepted. */
export async function teacherModelCatalog(account: TeacherAccount, runtime: 'local' | 'yuxi'): Promise<TeacherModelCatalog> {
if (runtime === 'yuxi') {
let result;
try { result = await teacherCloudTransport(account).json('/models'); }
catch (error) {
if (error instanceof TeacherError && error.status === 404) return {
supported: false, models: [], message: '老师服务更新后即可切换模型,目前使用老师配置。',
};
throw error;
}
assertTeacherAccount(account);
if (result.model_selection === false) return { supported: false, models: [], message: '老师服务更新后即可切换模型,目前使用老师配置。' };
if (result.model_selection !== true || !Array.isArray(result.models)
|| result.models.some(item => !item || typeof item !== 'object' || typeof item.id !== 'string' || !item.id || item.id.length > 200))
throw new TeacherError(502, 'teacher_models_invalid', '模型列表暂不可用,请重试。');
const local = await teacherModelCatalog(account, 'local');
const textModels = new Set(local.models.map(item => item.id));
return { supported: true, models: result.models.filter(item => textModels.has(item.id)).map(item => ({ id: item.id as string })) };
}
const config = await teacherCloudRequest<{ models: string[]; model_capabilities_v2: unknown }>(account, '/api/auth/me/model-config');
const catalog = normalizeManagedModelCatalog(config.model_capabilities_v2, config.models);
assertTeacherAccount(account);
return { supported: true, models: config.models.filter(id => catalog?.models[id]?.inputModalities?.includes('text')
&& catalog.models[id]?.outputModalities?.includes('text')).map(id => ({ id })) };
}
+19 -1
View File
@@ -32,6 +32,7 @@ import { TeacherConversationStore } from './conversation-store';
import { readJsonFile } from '../coding-projects/atomic-json';
import { compileTeacherContext } from './context';
import { prepareTeacherModel } from './model-runner';
import { teacherModelCatalog, validateTeacherModelId } from './model-catalog';
import { prepareCloudTeacher } from './cloud-runner';
import { readTeacherSource } from './source-reader';
import { applyTeacherReply, teacherReplyInstructions } from './reply';
@@ -59,6 +60,7 @@ export interface TeacherServiceOptions {
prepareModel?: typeof prepareTeacherModel;
prepareCloud?: typeof prepareCloudTeacher;
catalog?: typeof teacherCatalog;
models?: typeof teacherModelCatalog;
readSource?(scope: TeacherScope): Promise<TeacherSourceContext>;
}
export class CodingTeacherService {
@@ -100,6 +102,9 @@ export class CodingTeacherService {
: null;
return { ...status, definition: published?.payload ?? null };
}
async models(runtime: 'local' | 'yuxi') {
return (this.options.models ?? teacherModelCatalog)(await this.account(), runtime);
}
async catalog() {
return (this.options.catalog ?? teacherCatalog)(await this.account());
}
@@ -415,6 +420,7 @@ export class CodingTeacherService {
checkIn?: { source: TeacherSourceContext; fingerprint: string }
): Promise<TeacherTopic> {
teacherTopicId(input.requestId);
const modelId = validateTeacherModelId(input.modelId);
const intent = input.intent === undefined ? 'question' : input.intent;
const structuredReply = intent !== 'check-in';
if (!['question', 'suggestions', 'guided-help', 'check-in'].includes(intent) || (intent === 'check-in' && !checkIn))
@@ -452,6 +458,7 @@ export class CodingTeacherService {
if (existing) {
if (
existing.text !== input.text ||
existing.modelId !== modelId ||
(scope.agentId && existing.projectId !== scope.projectId) ||
(existing.intent ?? 'question') !== intent ||
JSON.stringify(existing.references) !== JSON.stringify(refs) ||
@@ -479,6 +486,7 @@ export class CodingTeacherService {
const previous = topic.requests.at(-1);
const reuse = previous && !previous.origin && previous.projectId === scope.projectId
&& previous.sourceConversationId === input.sourceConversationId
&& previous.modelId === modelId
&& previous.presentation === 'reply-v1'
&& previous.teacherVersion === selected.version && previous.runtimeThreadId;
newConversationSegment = !reuse;
@@ -545,6 +553,15 @@ export class CodingTeacherService {
return structuredClone(ref);
});
const isCloud = topic.definition.runtime === 'yuxi';
if (modelId) {
const models = await (this.options.models ?? teacherModelCatalog)(account, isCloud ? 'yuxi' : 'local');
if (!models.supported || !models.models.some(item => item.id === modelId))
throw new TeacherError(422, 'teacher_model_unavailable', models.message ?? '这个模型暂不可用,请重新选择。');
this.assertAccount(account);
}
const modelDefinition = modelId
? { ...topic.definition, model: { model_id: modelId, reasoning_choice: { mode: 'default' as const } } }
: topic.definition;
if (!isCloud && !topic.definition.system_prompt.trim())
throw new TeacherError(422, 'teacher_definition_invalid', '请先配置智能体的系统提示词。');
const access = scope.projectId === 'preview' ? undefined : {
@@ -573,7 +590,7 @@ export class CodingTeacherService {
topic.revision++;
this.events.emit(key, structuredClone(topic));
})
: await (this.options.prepareModel ?? prepareTeacherModel)(account, topic.definition,
: await (this.options.prepareModel ?? prepareTeacherModel)(account, modelDefinition,
access,
{ finalOnly: structuredReply });
if (checkIn) this.assertAccount(account);
@@ -614,6 +631,7 @@ export class CodingTeacherService {
text: input.text,
references,
createdAt: new Date().toISOString(),
...(modelId ? { modelId } : {}),
sourceCursor: source.cursor,
sourceCapturedAt: source.capturedAt,
includedSourceMessageIds: compiled.includedSourceMessageIds,
+8
View File
@@ -71,6 +71,8 @@ export interface TeacherRequest {
projectId?: string;
projectName?: string;
teacherVersion?: number;
/** Explicit per-question model; absent follows the published teacher configuration. */
modelId?: string;
runtimeThreadId?: string;
origin?: { projectId: string; topicId: string; requestId: string };
/** discussion-v1 is retained only to identify historical replies. */
@@ -147,7 +149,13 @@ export interface TeacherTopicList {
items: Array<{ id: string; title: string; updatedAt: string; version: number; teacherId?: string; legacyRole?: 'friend'; legacySourceId?: string }>;
lastSelectedTopicId: string | null;
}
export interface TeacherModelCatalog {
supported: boolean;
models: Array<{ id: string }>;
message?: string;
}
export interface TeacherSend {
modelId?: string;
projectId?: string;
intent?: TeacherRequestIntent;
sourceConversationId?: string;
+20
View File
@@ -0,0 +1,20 @@
import { useState } from 'react';
function readModel(key: string): string | undefined {
try {
const value = localStorage.getItem(key);
return value && value.length <= 200 ? value : undefined;
} catch { return undefined; }
}
export function useTeacherModelChoice(scope: string) {
const key = 'makelore-teacher-model:' + scope;
const [choice, setChoice] = useState(() => ({ key, modelId: readModel(key) }));
const modelId = choice.key === key ? choice.modelId : readModel(key);
const select = (id: string | undefined) => {
setChoice({ key, modelId: id });
try { if (id) localStorage.setItem(key, id); else localStorage.removeItem(key); } catch { /* Current window retains the choice. */ }
};
return { modelId, select };
}
+2
View File
@@ -1,5 +1,6 @@
import { hostApiFetch, createHostEventSource, ensureHostApiToken } from './host-api';
import type {
TeacherModelCatalog,
TeacherAvailability,
TeacherCheckInInput,
TeacherCheckInResult,
@@ -24,6 +25,7 @@ export function legacyTopicBase(base: string, legacyFriend: boolean) {
return legacyFriend ? base.replace(/agent-topics$/, 'friend-topics') : base;
}
export const teacherApi = {
models: (runtime: 'local' | 'yuxi') => hostApiFetch<TeacherModelCatalog>('/api/coding/teacher/models?runtime=' + runtime),
legacyHistory: (projectId: string) => hostApiFetch<TeacherTopicList>(`/api/coding/projects/${encodeURIComponent(projectId)}/agent-history`),
legacyRead: (projectId: string, item: TeacherTopicList['items'][number]) => hostApiFetch<TeacherTopic>(
`/api/coding/projects/${encodeURIComponent(projectId)}/agent-history/${encodeURIComponent(item.id)}?source=${encodeURIComponent(item.legacySourceId ?? 'project')}&friend=${item.legacyRole === 'friend'}`),
+3 -24
View File
@@ -1,4 +1,4 @@
import { LoaderCircle, Mic, Plus, RotateCcw, Send, Square, X } from 'lucide-react';
import { LoaderCircle, Plus, RotateCcw, Send, Square, X } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { useProviderStore } from '@/stores/providers';
import { Button } from '@/components/ui/button';
@@ -14,6 +14,7 @@ import type {
} from '@/types/coding-conversation';
import type { CodingConversationMetadata } from '@/types/coding-project';
import { CodingComposerRuntimeControls } from './CodingComposerRuntimeControls';
import { ComposerVoiceButton } from './ComposerVoiceButton';
export interface CodingComposerProps {
value: string;
@@ -236,29 +237,7 @@ export function CodingComposer({
onRefresh={onRefreshRuntime}
/>
)}
<Button
type="button"
variant="ghost"
className={cn(
'h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground',
voiceInput.state !== 'idle' && 'bg-surface-subtle text-foreground',
)}
disabled={!voiceInput.canToggle}
aria-busy={voiceInput.state === 'transcribing'}
aria-label={voiceInput.state === 'recording'
? '停止语音输入'
: voiceInput.state === 'transcribing'
? '正在识别语音'
: '语音输入'}
title={voiceInput.supported ? '语音输入' : '当前环境不支持语音输入'}
onClick={() => void voiceInput.toggle()}
>
{voiceInput.state === 'transcribing'
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
: voiceInput.state === 'recording'
? <Square className="h-3.5 w-3.5 fill-current" aria-hidden="true" />
: <Mic className="h-4 w-4" aria-hidden="true" />}
</Button>
<ComposerVoiceButton voiceInput={voiceInput} />
<Button
type={showAbort ? 'button' : 'submit'}
className={cn(
+17
View File
@@ -0,0 +1,17 @@
import { LoaderCircle, Mic, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { useWorksVoiceInput } from '@/hooks/use-works-voice-input';
export function ComposerVoiceButton({ voiceInput }: { voiceInput: ReturnType<typeof useWorksVoiceInput> }) {
return <Button type="button" variant="ghost"
className={cn('h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground',
voiceInput.state !== 'idle' && 'bg-surface-subtle text-foreground')}
disabled={!voiceInput.canToggle} aria-busy={voiceInput.state === 'transcribing'}
aria-label={voiceInput.state === 'recording' ? '停止语音输入' : voiceInput.state === 'transcribing' ? '正在识别语音' : '语音输入'}
title={voiceInput.supported ? '语音输入' : '当前环境不支持语音输入'} onClick={() => void voiceInput.toggle()}>
{voiceInput.state === 'transcribing' ? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
: voiceInput.state === 'recording' ? <Square className="h-3.5 w-3.5 fill-current" aria-hidden="true" />
: <Mic className="h-4 w-4" aria-hidden="true" />}
</Button>;
}
+41 -10
View File
@@ -14,6 +14,10 @@ import type {
import { TeacherAvatar } from './TeacherAvatar';
import { TeacherReply } from './TeacherReply';
import { MessageTimestamp } from './MessageTimestamp';
import { ComposerVoiceButton } from './ComposerVoiceButton';
import { useWorksVoiceInput } from '@/hooks/use-works-voice-input';
import { TeacherModelSelect } from './TeacherModelSelect';
import { useTeacherModelChoice } from '@/hooks/use-teacher-model-choice';
// Preserve loaded pages and the reading anchor while moving between contacts.
// Durable messages remain Main-owned; this is only the current window's view.
@@ -24,8 +28,8 @@ const messageOrderKey = (request: TeacherTopic['requests'][number]) => request.o
function restorePendingSend(saved?: TeacherSend, legacyProjectId?: string): TeacherSend | null {
if (!saved) return null;
// Keep an interrupted send retryable without restoring retired UI protocol fields.
const { requestId, text, references, intent, sourceConversationId, projectId } = saved;
return { requestId, text, references, intent, sourceConversationId, ...(projectId || legacyProjectId ? { projectId: projectId ?? legacyProjectId } : {}) };
const { requestId, text, references, intent, sourceConversationId, projectId, modelId } = saved;
return { requestId, text, references, intent, sourceConversationId, ...(modelId ? { modelId } : {}), ...(projectId || legacyProjectId ? { projectId: projectId ?? legacyProjectId } : {}) };
}
export function TeacherChatPanel({
@@ -131,6 +135,8 @@ export function TeacherChatPanel({
? selectedTeacher?.definition ?? selectedAgent?.definition ?? localDefinition
: topic?.definition ?? (catalogState
? selectedTeacher?.definition ?? selectedAgent?.definition ?? catalogState.config.definition : localDefinition);
const modelScope = `${account}:${selectedAgentId ?? definition?.config_id ?? definition?.teacher_id ?? 'teacher'}:${draftRevision ?? 'published'}`;
const modelChoice = useTeacherModelChoice(modelScope);
const topicEnabled = legacyFriend || legacyView ? false : !topic ? enabled
: topic.definition.config_id
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
@@ -394,7 +400,7 @@ export function TeacherChatPanel({
const questionSourceId = retry
? retry.sourceConversationId ?? (continuous || topic?.sourceConversationId === 'project' ? undefined : topic?.sourceConversationId)
: sourceId;
if (!question.trim() || busy || sendInFlight.current || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
if (voiceInput.state !== 'idle' || !question.trim() || busy || sendInFlight.current || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
if (continuous && !retry && questionReferences.length && (referenceScope.projectId !== projectId || referenceScope.sourceId !== sourceId)) {
setError('引用来自另一个项目或操作对话,请切回来源,或移除引用后再提问。'); return;
}
@@ -441,6 +447,7 @@ export function TeacherChatPanel({
? previous
: {
requestId: crypto.randomUUID(), text: question, references: questionReferences,
...((retry ? retry.modelId : modelChoice.modelId) ? { modelId: retry ? retry.modelId : modelChoice.modelId } : {}),
...(continuous ? { projectId } : {}),
...(action?.intent ? { intent: action.intent } : {}),
...(questionSourceId ? { sourceConversationId: questionSourceId } : {}),
@@ -461,8 +468,16 @@ export function TeacherChatPanel({
}
if (!continuous) setTopics(await teacherApi.list(base));
} catch (e) {
if (version === generation.current)
if (version === generation.current) {
const code = (e as { details?: { backendCode?: string } } | null)?.details?.backendCode;
// These Main validation failures precede acceptance; allow correcting the selection.
// Uncertain transport failures retain the original request and model to avoid double charging.
if (code === 'teacher_model_unavailable' || code === 'teacher_model_invalid') {
pending.current = null;
persistDraft(draftRef.current.text, draftRef.current.references);
}
setError(e instanceof Error ? e.message : '提问失败,请保留问题后重试');
}
} finally {
sendInFlight.current = false;
if (version === generation.current) setBusy(false);
@@ -473,7 +488,18 @@ export function TeacherChatPanel({
);
const helpUnavailable = busy || !topicEnabled || Boolean(running);
const askForSuggestions = () => void send({ text: '帮我整体看🧠', intent: 'suggestions' });
const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); };
const changeText = (next: string) => { draftRef.current.text = next; setText(next); persistDraft(next, references, pending.current); };
const voiceInput = useWorksVoiceInput({
enabled: !busy && topicEnabled && !running,
scopeKey: `${modelScope}:${projectId}:${sourceId}:${topic?.id ?? 'new'}:${modelChoice.modelId ?? 'default'}`,
onTranscript: transcript => {
const current = draftRef.current.text;
const next = current.trim() ? `${current.trimEnd()}\n${transcript}` : transcript;
if (next.length > 6000) { setError('文字超过 6000 字,请缩短输入后重新录音。'); return; }
changeText(next);
},
});
const removeReferences = () => { setReferences([]); persistDraft(text, [], pending.current); };
const questions = definition?.suggested_questions ?? [];
return (
@@ -540,7 +566,7 @@ export function TeacherChatPanel({
: request.status === 'completed' && <>
<TeacherReply text={request.response} />
</>}
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-retry">帮我整体看🧠</button>}
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'} onClick={askForSuggestions} className="consultation-retry">帮我整体看🧠</button>}
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{label}正在想…</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
<MessageTimestamp value={request.finishedAt} pending={['running', 'preparing'].includes(request.status)} />
</div>
@@ -584,12 +610,17 @@ export function TeacherChatPanel({
<textarea ref={inputRef} aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder="和老师聊聊" rows={2}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void send(); } }}
className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
<div className="mt-2 flex min-h-[68px] items-center justify-between gap-2" data-testid="consultation-composer-actions">
<div className="min-w-0 flex-1">
{!draftRevision && !text.trim() && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-hint">帮我整体看🧠</button>}
<div className="mt-2 flex min-h-[68px] flex-wrap items-center justify-between gap-x-2 gap-y-2" data-testid="consultation-composer-actions">
<div className="min-w-0 grow">
{!draftRevision && !text.trim() && <button type="button" disabled={helpUnavailable || voiceInput.state !== 'idle'} onClick={askForSuggestions} className="consultation-hint">帮我整体看🧠</button>}
</div>
<div className="ml-auto flex min-w-0 max-w-full items-center gap-1" data-testid="teacher-composer-runtime-controls">
<TeacherModelSelect key={modelScope + (definition?.runtime ?? 'local')} runtime={definition?.runtime ?? 'local'} scope={modelScope} defaultModel={definition?.model.model_id}
modelId={modelChoice.modelId} onChange={modelChoice.select} disabled={busy || !topicEnabled || Boolean(running)} />
<ComposerVoiceButton voiceInput={voiceInput} />
{running ? <button type="button" className="rounded-lg border px-3 py-2 text-xs" onClick={() => void teacherApi.cancel(topicBase, endpointId, running.id).then(consume).catch(() => setError('暂时无法停止,请稍后重试。'))}>停止回复</button>
: <button type="submit" aria-label="提问" title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim()} className="consultation-send flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
: <button type="submit" aria-label="提问" title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim() || voiceInput.state !== 'idle'} className="consultation-send flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
</div>
</div>
</div>
</form>
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { Check, ChevronDown, LoaderCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { teacherApi } from '@/lib/coding-teacher';
import type { TeacherModelCatalog } from '../../../shared/coding-teacher';
export function TeacherModelSelect({ runtime, scope, defaultModel, modelId, disabled, onChange }: {
runtime: 'local' | 'yuxi'; scope: string; defaultModel?: string | null;
modelId?: string; disabled: boolean; onChange(id: string | undefined): void;
}) {
const [open, setOpen] = useState(false);
const [result, setResult] = useState<TeacherModelCatalog | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
// Fetch on opening so account changes, availability and a newly deployed service are respected.
useEffect(() => {
if (!open) return;
let alive = true;
void teacherApi.models(runtime).then(value => { if (alive) setResult(value); })
.catch(() => { if (alive) setError('模型列表暂不可用,请关闭后重试。'); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [open, runtime, scope]);
const label = modelId ?? defaultModel ?? '老师配置';
const itemClass = 'relative flex min-h-8 cursor-default select-none items-center rounded-xl px-2.5 pr-9 outline-none data-[highlighted]:bg-surface-subtle';
return <DropdownMenu.Root open={open} onOpenChange={value => {
if (value) { setLoading(true); setResult(null); setError(''); }
setOpen(value);
}}>
<DropdownMenu.Trigger asChild>
<Button type="button" variant="ghost" disabled={disabled}
className="h-8 min-w-0 max-w-48 gap-1 rounded-full bg-transparent px-2 text-xs font-normal shadow-none hover:bg-surface-subtle"
aria-label={`老师模型:${label}${modelId ? '' : ',跟随老师配置'}`} title={label}>
<span className="min-w-0 truncate text-foreground/85">{label}</span>
<span className="shrink-0 text-brand">{modelId ? '模型默认' : '老师配置'}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content side="top" align="end" sideOffset={8} collisionPadding={12} aria-label="选择老师模型"
className="z-[101] max-h-[min(24rem,var(--radix-dropdown-menu-content-available-height))] w-64 max-w-[calc(100vw-24px)] overflow-y-auto rounded-[18px] border border-border/80 bg-background p-1.5 text-sm shadow-[0_18px_50px_rgba(26,31,42,0.16)] outline-none">
<DropdownMenu.Label className="px-2.5 py-2 text-xs text-muted-foreground">用于接下来的老师提问</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={modelId ?? ''} onValueChange={value => onChange(value || undefined)}>
<DropdownMenu.RadioItem value="" className={itemClass}>跟随老师配置
<DropdownMenu.ItemIndicator className="absolute right-2.5"><Check className="h-4 w-4" /></DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
{result?.supported && result.models.map(model => <DropdownMenu.RadioItem key={model.id} value={model.id} className={itemClass} title={model.id}>
<span className="min-w-0 truncate">{model.id}</span>
<DropdownMenu.ItemIndicator className="absolute right-2.5"><Check className="h-4 w-4" /></DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>)}
</DropdownMenu.RadioGroup>
{loading && <div role="status" className="flex items-center gap-2 px-2.5 py-2 text-xs text-muted-foreground"><LoaderCircle className="h-3.5 w-3.5 animate-spin" />正在读取模型…</div>}
{result?.supported && !result.models.length && <p role="status" className="px-2.5 py-2 text-xs text-muted-foreground">暂无可切换的模型,可以继续使用老师配置。</p>}
{(error || result?.message || (result?.supported && modelId && !result.models.some(model => model.id === modelId))) &&
<p role="status" className="px-2.5 py-2 text-xs leading-5 text-muted-foreground">{error || result?.message || '已选模型暂不可用,请重新选择后再提问。'}</p>}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>;
}
+1
View File
@@ -45,6 +45,7 @@ const sentInputs: TeacherSend[] = [];
Object.defineProperty(window, '__teacherReplyInputs', { value: sentInputs });
const snapshot = () => { current.revision++; stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(current) })); };
export const teacherApi = {
models: async () => ({ supported: true, models: [{ id: 'deepseek-flash' }, { id: 'test-second-model' }] }),
conversation: async () => ({ topic: structuredClone(current), before: null }),
seenConversation: async (_agentId: string, requestId: string) => {
if (current.conversation) current.conversation.seenThrough = requestId;
+17
View File
@@ -189,3 +189,20 @@ for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'struct
expect(errors).toEqual([]);
});
}
for (const width of [319, 508]) test(`teacher model selector and microphone fit the composer at ${width}px`, async ({ page }) => {
await page.goto(`${url}?kind=rich-text&width=${width}`);
const panel = page.getByTestId('teacher-chat-panel');
const input = page.getByRole('textbox', { name: '向智能体提问' });
await input.fill('语音和选模测试草稿');
await panel.getByRole('button', { name: /老师模型/ }).click();
await page.getByRole('menuitemradio', { name: 'test-second-model' }).click();
await expect(input).toHaveValue('语音和选模测试草稿');
await expect(panel.getByRole('button', { name: '语音输入' })).toBeVisible();
expect(await panel.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const controls = panel.getByTestId('teacher-composer-runtime-controls');
expect(await controls.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await panel.screenshot({ path: test.info().outputPath(`teacher-composer-${width}.png`) });
await panel.getByRole('button', { name: '提问', exact: true }).click();
expect(await page.evaluate(() => (window as unknown as { __teacherReplyInputs: Array<{modelId?: string}> }).__teacherReplyInputs.at(-1)?.modelId)).toBe('test-second-model');
});
+16
View File
@@ -870,3 +870,19 @@ it('keeps only the cloud main-thread answer while advancing past child events on
expect(transport.events).toHaveBeenCalledTimes(2);
expect(streamUrls[1]).toContain('after_seq=2-0');
});
it('transmits the frozen per-question model while retaining the teacher version', async () => {
const f = await fixture();
f.topic.requests.push({ id: requestId, modelId: 'selected-model', teacherVersion: 9, text: '问题', references: [], status: 'running',
createdAt: 'now', sourceCapturedAt: 'now', sourceCursor: f.access.source.cursor,
includedSourceMessageIds: [], omittedMessages: 0, response: '' });
const transport: TeacherCloudTransport = {
json: vi.fn(async url => url === '/questions'
? { request_id: requestId, run_id: 'run' } : { status: 'completed', output: '已回复' }),
events: vi.fn(),
};
const model = prepareCloudTeacher(f.account, f.topic, requestId, f.access, f.progress, f.saveRequest, transport);
await model.run([{ role: 'user', content: '问题' }], new AbortController().signal, vi.fn());
expect(transport.json).toHaveBeenCalledWith('/questions', expect.objectContaining({ model_id: 'selected-model', teacher_version: f.topic.version }), expect.anything());
expect(f.topic.definition.model.model_id).toBe('deepseek-flash');
});
@@ -0,0 +1,40 @@
// @vitest-environment node
import { beforeEach, expect, it, vi } from 'vitest';
import { teacherModelCatalog, validateTeacherModelId } from '../../electron/coding-teacher/model-catalog';
import { TeacherError } from '../../electron/coding-teacher/config-client';
const mocks = vi.hoisted(() => ({ json: vi.fn(), request: vi.fn(), assert: vi.fn() }));
vi.mock('../../electron/coding-teacher/cloud-runner', () => ({ teacherCloudTransport: () => ({ json: mocks.json }) }));
vi.mock('../../electron/coding-teacher/config-client', async importOriginal => ({
...await importOriginal<typeof import('../../electron/coding-teacher/config-client')>(),
teacherCloudRequest: mocks.request, assertTeacherAccount: mocks.assert,
}));
const account = { id: 'student', binding: { accountKey: 'student', epoch: 1 } };
beforeEach(() => { vi.resetAllMocks(); });
it.each([false, undefined])('does not advertise selection on an older cloud or gateway (%s)', async flag => {
if (flag === undefined) mocks.json.mockRejectedValue(new TeacherError(404, 'not_found', 'Not Found'));
else mocks.json.mockResolvedValue({ model_selection: flag, models: [] });
expect(await teacherModelCatalog(account, 'yuxi')).toMatchObject({ supported: false, models: [] });
expect(mocks.request).not.toHaveBeenCalled();
});
it('does not turn authentication or transient failures into unsupported capability', async () => {
const error = new TeacherError(401, 'expired', '请登录');
mocks.json.mockRejectedValue(error);
await expect(teacherModelCatalog(account, 'yuxi')).rejects.toBe(error);
});
it.each([null, '', ' model ', 123, 'x'.repeat(201)])('rejects malformed explicit model %s', value => {
expect(() => validateTeacherModelId(value)).toThrow(TeacherError);
});
it('preserves absent model and exact provider ids', () => {
expect(validateTeacherModelId(undefined)).toBeUndefined();
expect(validateTeacherModelId('provider/model')).toBe('provider/model');
});
it('returns only shared text models and never exposes gateway credentials', async () => {
mocks.json.mockResolvedValue({ model_selection: true, models: [{ id: 'text-model' }, { id: 'image-model' }, { id: 'unlisted' }] });
mocks.request.mockResolvedValue({ api_key: 'private', base_url: 'https://private.invalid',
models: ['text-model', 'image-model'], model_capabilities_v2: { schema_version: 2, models: {
'text-model': { input_modalities: ['text'], output_modalities: ['text'], reasoning: { supported: false, can_disable: false, default_enabled: false, effort_values: [], control_format: 'none' } },
'image-model': { input_modalities: ['text'], output_modalities: ['image'], reasoning: { supported: false, can_disable: false, default_enabled: false, effort_values: [], control_format: 'none' } },
} } });
expect(await teacherModelCatalog(account, 'yuxi')).toEqual({ supported: true, models: [{ id: 'text-model' }] });
});
+18
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import type { TeacherDefinition, TeacherRequest, TeacherSend, TeacherTopic } from '../../shared/coding-teacher';
const api = vi.hoisted(() => ({
models: vi.fn(),
catalog: vi.fn(),
config: vi.fn(),
preview: vi.fn(),
@@ -70,6 +71,7 @@ beforeEach(() => {
vi.resetAllMocks();
localStorage.clear();
streams = new Map();
api.models.mockResolvedValue({ supported: true, models: [{ id: 'qwen' }, { id: 'other-model' }] });
api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition });
api.catalog.mockResolvedValue({ items: [{ teacher_id: 'one', version: 1, definition, is_default: true }] });
api.conversation.mockResolvedValue({ topic: null, before: null });
@@ -1008,3 +1010,19 @@ describe('merged cloud teacher classroom', () => {
expect(screen.getByRole('button', { name: '智能体新话题' })).toBeEnabled();
});
});
it('selects a teacher model for the next send and retains it after remount', async () => {
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const trigger = screen.getByRole('button', { name: /老师模型/ });
fireEvent.keyDown(trigger, { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitemradio', { name: 'other-model' }));
fireEvent.change(screen.getByRole('textbox', { name: '向智能体提问' }), { target: { value: '我的想法' } });
api.send.mockResolvedValue(first);
fireEvent.click(screen.getByRole('button', { name: '提问' }));
await waitFor(() => expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({ modelId: 'other-model', text: '我的想法' })));
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" />);
await screen.findByRole('button', { name: /老师模型:other-model/ });
expect(screen.getByRole('button', { name: '语音输入' })).toBeInTheDocument();
});
+20
View File
@@ -103,6 +103,7 @@ async function fixture({ durableSource = false, sourceContext = context, liveMod
const prepareCloud = vi.fn((..._args: unknown[]) => ({ inputLimit: 8000, run }));
const createService = () => new CodingTeacherService({
projects,
models: async () => ({ supported: true, models: [{ id: 'qwen' }, { id: 'other-model' }] }),
runtime: new InMemoryConversationRuntime(),
userDataDir: root,
account: async () => account,
@@ -1659,3 +1660,22 @@ describe('project consultations with selected cloud teachers', () => {
await vi.waitFor(async () => expect((await f.service.read(scope, topic.id)).requests[0].status).toBe('completed'));
});
});
it.each([false, true])('freezes explicit model without changing teacher definition (cloud=%s)', async cloudTeacher => {
const f = await fixture({ cloudTeacher, mockCloud: cloudTeacher });
const t = await f.service.create(f.scope, undefined, undefined, cloudTeacher ? 9 : undefined);
const input = { requestId: '22222222-2222-4222-8222-222222222222', text: '帮我理解', modelId: 'other-model' };
const accepted = await f.service.send(f.scope, t.id, input);
expect(accepted.requests[0].modelId).toBe('other-model');
expect(accepted.definition.model.model_id).toBe('qwen');
if (!cloudTeacher) expect(f.prepareModel.mock.calls[0][1].model).toEqual({ model_id: 'other-model', reasoning_choice: { mode: 'default' } });
await expect(f.service.send(f.scope, t.id, { ...input, modelId: 'qwen' })).rejects.toMatchObject({ code: 'teacher_request_conflict' });
await f.service.send(f.scope, t.id, input);
expect(f.run).toHaveBeenCalledTimes(1);
f.finish();
await vi.waitFor(async () => expect((await f.service.read(f.scope, t.id)).requests[0].status).toBe('completed'));
const restarted = await f.restart();
expect((await restarted.read(f.scope, t.id)).requests[0].modelId).toBe('other-model');
await expect(restarted.send(f.scope, t.id, { ...input, requestId: '33333333-3333-4333-8333-333333333333', modelId: 'unavailable' })).rejects.toMatchObject({ code: 'teacher_model_unavailable' });
expect(f.run).toHaveBeenCalledTimes(1);
});
+130
View File
@@ -0,0 +1,130 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import { useAuthStore } from '@/stores/auth';
const voiceApi = vi.hoisted(() => ({
blobToBase64: vi.fn(),
convertAudioBlobTo16kMonoWav: vi.fn(),
transcribeWorksSpeech: vi.fn(),
}));
vi.mock('@/lib/voice-recording', () => ({
blobToBase64: (...args: unknown[]) => voiceApi.blobToBase64(...args),
convertAudioBlobTo16kMonoWav: (...args: unknown[]) => (
voiceApi.convertAudioBlobTo16kMonoWav(...args)
),
}));
vi.mock('@/lib/works-square', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/works-square')>(),
transcribeWorksSpeech: (...args: unknown[]) => voiceApi.transcribeWorksSpeech(...args),
}));
const api = vi.hoisted(() => ({ send: vi.fn() }));
vi.mock('@/lib/coding-teacher', () => ({
teacherTopicsPath: () => '/teacher', legacyTopicBase: (base: string) => base,
teacherApi: {
config: async () => ({ enabled: true, definition: { schema_version: 1, teacher_id: 'teacher', name: '老师',
description: '', avatar_id: 'avatar-01', welcome_message: '欢迎', suggested_questions: [],
system_prompt: '你是老师', skills: [], model: { model_id: 'qwen', reasoning_choice: { mode: 'default' } },
limits: { max_input_tokens: 8000, max_output_tokens: 1500 } } }),
catalog: async () => ({ items: [] }), list: async () => ({ items: [], lastSelectedTopicId: null }),
send: api.send,
},
}));
const initialAuthState = useAuthStore.getState();
const initialMediaRecorder = window.MediaRecorder;
const initialMediaDevices = navigator.mediaDevices;
class MockMediaRecorder {
static latest: MockMediaRecorder | null = null;
static isTypeSupported(): boolean { return true; }
ondataavailable: ((event: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
state: RecordingState = 'inactive';
mimeType = 'audio/webm';
constructor() {
MockMediaRecorder.latest = this;
}
start(): void {
this.state = 'recording';
}
stop(): void {
this.state = 'inactive';
this.ondataavailable?.({ data: new Blob(['voice bytes'], { type: 'audio/webm' }) });
this.onstop?.();
}
}
describe('Teacher composer voice input', () => {
beforeEach(() => {
localStorage.clear();
MockMediaRecorder.latest = null;
voiceApi.convertAudioBlobTo16kMonoWav.mockResolvedValue(
new Blob(['wav bytes'], { type: 'audio/wav' }),
);
voiceApi.blobToBase64.mockResolvedValue('voice-base64');
voiceApi.transcribeWorksSpeech.mockResolvedValue({
text: '补充单元测试',
model: 'gpt-4o-mini-transcribe',
});
useAuthStore.setState({
initialized: true,
loading: false,
error: null,
accessToken: 'voice-access-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 60_000,
lastActiveAt: Date.now(),
canRefresh: true,
legacyRefreshToken: null,
user: {
username: 'coder',
userId: 'coder-1',
tenantId: null,
deptId: null,
authorities: [],
},
});
});
afterEach(() => {
useAuthStore.setState(initialAuthState);
Object.defineProperty(window, 'MediaRecorder', {
configurable: true,
value: initialMediaRecorder,
});
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: initialMediaDevices,
});
});
it('transcribes into the latest teacher draft without sending, then cancels on source change', async () => {
const stopTrack = vi.fn();
Object.defineProperty(navigator, 'mediaDevices', { configurable: true,
value: { getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: stopTrack }] }) } });
Object.defineProperty(window, 'MediaRecorder', { configurable: true, value: MockMediaRecorder });
const view = render(<TeacherChatPanel projectId="p" sourceId="source-one" />);
const input = await screen.findByRole('textbox', { name: '向智能体提问' });
await waitFor(() => expect(screen.getByRole('button', { name: '语音输入' })).toBeEnabled());
fireEvent.change(input, { target: { value: '原来的草稿' } });
fireEvent.click(screen.getByRole('button', { name: '语音输入' }));
await screen.findByRole('button', { name: '停止语音输入' });
fireEvent.change(input, { target: { value: '录音时继续写' } });
fireEvent.click(screen.getByRole('button', { name: '停止语音输入' }));
await waitFor(() => expect(input).toHaveValue('录音时继续写\n补充单元测试'));
expect(api.send).not.toHaveBeenCalled();
expect(stopTrack).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: '语音输入' }));
await screen.findByRole('button', { name: '停止语音输入' });
view.rerender(<TeacherChatPanel projectId="p" sourceId="source-two" />);
await waitFor(() => expect(stopTrack).toHaveBeenCalledTimes(2));
expect(voiceApi.transcribeWorksSpeech).toHaveBeenCalledTimes(1);
expect(api.send).not.toHaveBeenCalled();
});
});