Add teacher microphone and per-question model selection
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
40
tests/unit/coding-teacher-model-catalog.test.ts
Normal file
40
tests/unit/coding-teacher-model-catalog.test.ts
Normal file
@@ -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' }] });
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
tests/unit/teacher-composer-voice.test.tsx
Normal file
130
tests/unit/teacher-composer-voice.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user