From 78cfde67340aaccea8e8edc2505136cfcf865eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 29 Sep 2026 19:43:45 +0800 Subject: [PATCH] Add teacher microphone and per-question model selection --- .../20260929-teacher-composer-4f829ac1.md | 56 ++++++++ README.md | 1 + electron/api/routes/coding-teacher.ts | 11 +- electron/coding-teacher/cloud-runner.ts | 1 + electron/coding-teacher/model-catalog.ts | 38 +++++ electron/coding-teacher/service.ts | 20 ++- shared/coding-teacher.ts | 8 ++ src/hooks/use-teacher-model-choice.ts | 20 +++ src/lib/coding-teacher.ts | 2 + src/pages/Chat/CodingComposer.tsx | 27 +--- src/pages/Chat/ComposerVoiceButton.tsx | 17 +++ src/pages/Chat/TeacherChatPanel.tsx | 51 +++++-- src/pages/Chat/TeacherModelSelect.tsx | 60 ++++++++ tests/e2e/fixtures/teacher-reply-api.ts | 1 + tests/e2e/teacher-reply-layout.spec.ts | 17 +++ tests/unit/coding-teacher-cloud.test.ts | 16 +++ .../unit/coding-teacher-model-catalog.test.ts | 40 ++++++ tests/unit/coding-teacher-ui.test.tsx | 18 +++ tests/unit/coding-teacher.test.ts | 20 +++ tests/unit/teacher-composer-voice.test.tsx | 130 ++++++++++++++++++ 20 files changed, 517 insertions(+), 37 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260929-teacher-composer-4f829ac1.md create mode 100644 electron/coding-teacher/model-catalog.ts create mode 100644 src/hooks/use-teacher-model-choice.ts create mode 100644 src/pages/Chat/ComposerVoiceButton.tsx create mode 100644 src/pages/Chat/TeacherModelSelect.tsx create mode 100644 tests/unit/coding-teacher-model-catalog.test.ts create mode 100644 tests/unit/teacher-composer-voice.test.tsx diff --git a/.project-docs/30-worklog/tasks/20260929-teacher-composer-4f829ac1.md b/.project-docs/30-worklog/tasks/20260929-teacher-composer-4f829ac1.md new file mode 100644 index 00000000..860db9cf --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260929-teacher-composer-4f829ac1.md @@ -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. diff --git a/README.md b/README.md index 03151c9e..9ae8fe05 100644 --- a/README.md +++ b/README.md @@ -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` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。 diff --git a/electron/api/routes/coding-teacher.ts b/electron/api/routes/coding-teacher.ts index 23bd9de0..f45777dd 100644 --- a/electron/api/routes/coding-teacher.ts +++ b/electron/api/routes/coding-teacher.ts @@ -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; diff --git a/electron/coding-teacher/cloud-runner.ts b/electron/coding-teacher/cloud-runner.ts index 3958ff05..5c213b10 100644 --- a/electron/coding-teacher/cloud-runner.ts +++ b/electron/coding-teacher/cloud-runner.ts @@ -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, diff --git a/electron/coding-teacher/model-catalog.ts b/electron/coding-teacher/model-catalog.ts new file mode 100644 index 00000000..7a592dd0 --- /dev/null +++ b/electron/coding-teacher/model-catalog.ts @@ -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 { + 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 })) }; +} diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index b255fe80..fd34da2d 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -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; } 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 { 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, diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index 00b21150..46f6cde3 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -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; diff --git a/src/hooks/use-teacher-model-choice.ts b/src/hooks/use-teacher-model-choice.ts new file mode 100644 index 00000000..8b280cc1 --- /dev/null +++ b/src/hooks/use-teacher-model-choice.ts @@ -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 }; +} + diff --git a/src/lib/coding-teacher.ts b/src/lib/coding-teacher.ts index a543bcac..2d1267c7 100644 --- a/src/lib/coding-teacher.ts +++ b/src/lib/coding-teacher.ts @@ -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('/api/coding/teacher/models?runtime=' + runtime), legacyHistory: (projectId: string) => hostApiFetch(`/api/coding/projects/${encodeURIComponent(projectId)}/agent-history`), legacyRead: (projectId: string, item: TeacherTopicList['items'][number]) => hostApiFetch( `/api/coding/projects/${encodeURIComponent(projectId)}/agent-history/${encodeURIComponent(item.id)}?source=${encodeURIComponent(item.legacySourceId ?? 'project')}&friend=${item.legacyRole === 'friend'}`), diff --git a/src/pages/Chat/CodingComposer.tsx b/src/pages/Chat/CodingComposer.tsx index 4c65f534..b9ed8023 100644 --- a/src/pages/Chat/CodingComposer.tsx +++ b/src/pages/Chat/CodingComposer.tsx @@ -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} /> )} - + ; +} diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index 87e610c8..ea6301a3 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -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' && <> } - {['failed', 'cancelled', 'interrupted'].includes(request.status) && } + {['failed', 'cancelled', 'interrupted'].includes(request.status) && } : request.response ? :
{['running', 'preparing'].includes(request.status) ? {label}正在想… : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
} @@ -584,12 +610,17 @@ export function TeacherChatPanel({