Add contextual teacher help entry and simplify consultation panel

This commit is contained in:
鲨鱼辣椒 committed 2026-09-22 16:55:07 +08:00
1 parent 68e676cb62
commit 12d800ec51
11 files changed
+601 -48

No files matched your search

@@ -56,3 +56,12 @@
2. Integrate prototype layout, character entry points, resizable consultation, project drafts, and central real work preview.
3. Verify roles/project isolation, explicit-only submission, draft persistence, source bounds, existing Code behavior; typecheck, focused tests, build and native trial inspection.
4. Point the trial launcher at this combined source for future iterations and document current behavior.
## Approved Interaction Follow-up (2026-09-22)
- Same task, branch and worktree resumed with matching ownership; no other active task owners. Gate passed after reloading task record, relevant project memory, teacher ADR, README and AGENTS. Current baseline is 68e676c; the recorded task base remains e5d271b.
- User explicitly approves this iteration after discussion: remove redundant project-context strip from the project-scoped consultation; support free questions and a deliberate “老师,帮我看看” action that generates project-contextual discussion questions. Offer “我也说不清,你带我看看” to start a concrete guided conversation. No demonstration view, memo, consensus or automatic implementation actions.
- Keep the existing quiet sage/sand UI; generic skill design-system results do not override the approved design. Add request intent and structured generated suggestions through the existing Main-owned consultation pipeline, then update Renderer, focused tests, README and the same temporary application.
- Implemented: removed redundant project strip from both consultation roles; student teacher empty state offers free input and “老师,帮我看看”. Explicit help requests carry per-turn intent through the existing model pipeline, validate a short introduction plus 2–3 contextual questions, and render questions as deliberate send actions. “我也说不清,你带我看看” starts one concrete conversational prompt; the help entry remains available during ongoing chat. Quick actions preserve free-text/quoted drafts; ordinary sends still clear accepted drafts. Reopened history and live snapshots reconcile accepted request identities, so unknown delivery can retry safely and known terminal requests can start a fresh request.
- Verification: 55 backend tests and 46 teacher/chat UI tests passed (101 total), with structured output validation, scope/intent/idempotency, streaming JSON suppression, failed/reopened/live-snapshot retries, draft retention and legacy friend/admin compatibility. Full typecheck, scoped ESLint, whitespace and production build passed. E2E fixture/spec updated but E2E runner not executed. Existing isolated trial restarted from the new build; native app visually confirmed the teacher project strip is gone and the ongoing-conversation help entry is present. One deliberate real help request reached the existing model path and ended with “老师暂时无法回复,请稍后重试。”; verified the retry action and restored editable composer. Successful real model-generated questions were not verified. Existing user conversation history was preserved.
- Service follow-up (outside this frontend iteration): the current generic model error does not prove a sign-in failure. Read-only comparison found teacher model-runner differs from normal provider/AI proxy handling of credential_mode, bare gateway base URL /v1 normalization, credential refresh, and upstream error categorization. These differences are possible investigation leads, not a confirmed root cause. No runtime credentials were inspected or changed, and model-runner remains untouched.
+2 -1
View File
@@ -156,7 +156,8 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
### 老师与朋友咨询
- 标题栏的“老师/朋友”由学生主动打开右侧咨询栏,可调整宽度、关闭和重新打开。老师引导思考、教方法,朋友分享作品体验中的感受、好奇与困惑。入口与建议问题都不会自动发送;建议只填入咨询草稿。界面不提供“记一下”、共识或自动待办,“我去试一试”只返回操作输入区,由学生自己表达和实施。
- 标题栏的“老师/朋友”由学生主动打开右侧咨询栏,可调整宽度、关闭和重新打开;咨询已属于当前项目,面板不重复展示项目条。老师引导思考、教方法,朋友分享作品体验中的感受、好奇与困惑。打开面板只读取配置和历史,不触发模型。
- 老师同时支持自由提问与“老师,帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让老师从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。朋友及运营预览原有固定建议仍只填入草稿。界面不提供独立示范页面、“记一下”、共识或自动待办,“我去试一试”只返回操作输入区,由学生自己表达和实施。
- 老师保留运营发布的提示词、文本教学 Skills、模型和原生思考选项。首期朋友使用同一发布版本的模型、预算与启停配置,采用独立的体验交流提示词,不继承老师 Skills;具体教学方向和角色提示词后续另行讨论。两者均只读取按预算截取的当前操作对话已完成分支文本及明确引用,不运行工具,也不能声称实际看到了画面或试玩过作品。Main 直接调用既有计费网关,不创建 Pi worker。
- 咨询话题及未发送草稿按当前云端账号、项目与角色分别保存;切换操作对话仍延续该项目下的咨询,每轮发送时绑定当时的操作对话来源。创建话题时固定发布版本,每轮重新检查启停。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。没有操作对话时也可咨询,但无操作文本上下文。
- 删除操作对话会停止正在使用该来源的回复,保留项目级咨询历史;归档来源需先恢复后才能引用。原有来源会话级老师 API 与存储保持兼容,原路径历史不自动迁移到新项目级咨询栏。运营草稿试聊仍使用 `niancode://coding-teacher/preview?draft_revision=N` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。
+21 -2
View File
@@ -3,6 +3,7 @@ import type {
TeacherDefinition,
TeacherReference,
TeacherRequest,
TeacherRequestIntent,
TeacherSourceContext,
} from '../../shared/coding-teacher';
import { TeacherError } from './config-client';
@@ -44,7 +45,8 @@ export function compileTeacherContext(
history: TeacherRequest[],
question: string,
references: TeacherReference[],
maxInputTokens = definition.limits.max_input_tokens
maxInputTokens = definition.limits.max_input_tokens,
intent: TeacherRequestIntent = 'question'
) {
const system: TeacherModelMessage = {
role: 'system',
@@ -69,6 +71,15 @@ export function compileTeacherContext(
ref.text
),
'当前问题:\n' + question,
...(intent === 'suggestions'
? [
'本轮交互要求(仅本轮):依据当前来源操作对话和本咨询历史,邀请学生选择一个可以一起讨论的问题。只返回 JSON 对象 {"intro":string,"questions":string[]},不要附加其他文字。intro 是简短、自然的邀请,不超过 400 字;questions 必须有 2–3 个互不重复、具体贴近当前进展的问题,每个不超过 120 字,用学生自己的口吻表达。问题应帮助学生思考或理解方法,而不是替学生安排待办。没有可用上下文时,坦诚说明目前还不了解项目,从学生想做什么、希望谁来用等构思切入;不要编造学生已经完成的功能、作品表现或项目进展,不要生成待办。',
]
: intent === 'guided-help'
? [
'本轮交互要求(仅本轮):学生暂时说不清想问什么。依据当前来源操作对话和本咨询历史,只发起一个具体、容易回答的交流起点,帮助学生开口。用正常、简短的中文文字回答,不返回 JSON,不列出多个问题或一串任务。没有可用上下文时,坦诚从构思切入,不假定学生已经完成了任何功能。',
]
: []),
].join('\n\n'),
};
const sourceMessages = [...source.messages];
@@ -93,7 +104,15 @@ export function compileTeacherContext(
'\n\n'
),
},
{ role: 'assistant' as const, content: request.response },
{
role: 'assistant' as const,
content: [
request.response,
...(request.suggestedQuestions?.length
? ['可以接着聊的问题:\n' + request.suggestedQuestions.map((text) => '- ' + text).join('\n')]
: []),
].join('\n\n'),
},
]),
current,
];
+22 -1
View File
@@ -26,6 +26,7 @@ import { compileTeacherContext } from './context';
import { prepareTeacherModel } from './model-runner';
import { consultationDefinition } from './consultation-role';
import { readTeacherSource } from './source-reader';
import { parseTeacherSuggestions } from './suggestions';
import { subscribeWorksSquareSession } from '../services/works-square-session';
export interface TeacherScope {
@@ -203,6 +204,13 @@ export class CodingTeacherService {
}
async send(scope: TeacherScope, id: string, input: TeacherSend): Promise<TeacherTopic> {
teacherTopicId(input.requestId);
const intent = input.intent === undefined ? 'question' : input.intent;
if (!['question', 'suggestions', 'guided-help'].includes(intent))
throw new TeacherError(422, 'teacher_intent_invalid', '提问方式无效,请重新打开老师后再试。');
if (intent !== 'question' && (
scope.projectId === 'preview' || scope.sourceId !== 'project' || (scope.role ?? 'teacher') !== 'teacher'
))
throw new TeacherError(422, 'teacher_intent_invalid', '这种提问方式只适用于项目里的老师。');
if (input.sourceConversationId !== undefined) teacherTopicId(input.sourceConversationId);
if (typeof input.text !== 'string' || !input.text.trim() || input.text.length > 6000)
throw new TeacherError(422, 'teacher_question_invalid', '请输入 1–6000 字的问题。');
@@ -228,6 +236,7 @@ export class CodingTeacherService {
if (existing) {
if (
existing.text !== input.text ||
(existing.intent ?? 'question') !== intent ||
JSON.stringify(existing.references) !== JSON.stringify(refs) ||
(existing.sourceConversationId ?? undefined) !== (input.sourceConversationId ?? undefined)
)
@@ -301,7 +310,8 @@ export class CodingTeacherService {
topic.requests,
input.text,
references,
model.inputLimit
model.inputLimit,
intent
);
// Reading context and resolving model credentials can yield while a source
// is being deleted. Project consultations must recheck the actual source.
@@ -309,6 +319,7 @@ export class CodingTeacherService {
throw new TeacherError(404, 'teacher_source_not_found', '来源会话已删除。');
const request = {
id: input.requestId,
intent,
...(input.sourceConversationId ? { sourceConversationId: input.sourceConversationId } : {}),
text: input.text,
references,
@@ -349,6 +360,12 @@ export class CodingTeacherService {
topic.revision++;
this.events.emit(key, structuredClone(topic));
});
this.assertAccount(account);
if (!controller.signal.aborted && intent === 'suggestions') {
const suggestions = parseTeacherSuggestions(current.response);
current.response = suggestions.intro;
current.suggestedQuestions = suggestions.questions;
}
current.status = controller.signal.aborted ? 'cancelled' : 'completed';
} catch (error) {
current.status = controller.signal.aborted ? 'cancelled' : 'failed';
@@ -358,6 +375,10 @@ export class CodingTeacherService {
? error.message
: '老师回复失败,已保留本次问题与收到的内容。';
} finally {
if (intent === 'suggestions' && current.status !== 'completed') {
current.response = '';
delete current.suggestedQuestions;
}
topic.updatedAt = new Date().toISOString();
topic.revision++;
try {
+33
View File
@@ -0,0 +1,33 @@
import { TeacherError } from './config-client';
export interface TeacherSuggestions {
intro: string;
questions: string[];
}
/** Validate model output before it can become interactive student questions. */
export function parseTeacherSuggestions(response: string): TeacherSuggestions {
const invalid = () => new TeacherError(
502,
'teacher_suggestions_invalid',
'老师这次没能整理好可以讨论的问题,请再试一次,或直接告诉老师你的想法。'
);
const text = response.trim();
const fenced = text.match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/i);
let parsed: unknown;
try {
parsed = JSON.parse(fenced ? fenced[1] : text);
} catch {
throw invalid();
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw invalid();
const { intro, questions } = parsed as Record<string, unknown>;
if (
typeof intro !== 'string' || !intro.trim() || intro.trim().length > 400 ||
!Array.isArray(questions) || questions.length < 2 || questions.length > 3 ||
questions.some((question) => typeof question !== 'string' || !question.trim() || question.trim().length > 120)
) throw invalid();
const uniqueQuestions = [...new Set(questions.map((question: string) => question.trim()))];
if (uniqueQuestions.length < 2) throw invalid();
return { intro: intro.trim(), questions: uniqueQuestions };
}
+4
View File
@@ -2,6 +2,7 @@ import type { ManagedReasoningChoice } from './managed-model-capabilities';
import type { PublicUsage } from './coding-conversation-contracts';
export type ConsultationRole = 'teacher' | 'friend';
export type TeacherRequestIntent = 'question' | 'suggestions' | 'guided-help';
export interface TeacherDefinition {
schema_version: 1;
@@ -52,6 +53,7 @@ export type TeacherRequestStatus =
| 'cancelled'
| 'interrupted';
export interface TeacherRequest {
intent?: TeacherRequestIntent;
sourceConversationId?: string;
id: string;
text: string;
@@ -63,6 +65,7 @@ export interface TeacherRequest {
omittedMessages: number;
status: TeacherRequestStatus;
response: string;
suggestedQuestions?: string[];
error?: string;
usage?: PublicUsage;
}
@@ -87,6 +90,7 @@ export interface TeacherTopicList {
lastSelectedTopicId: string | null;
}
export interface TeacherSend {
intent?: TeacherRequestIntent;
sourceConversationId?: string;
requestId: string;
text: string;
+60 -25
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowUp, ChevronDown, ChevronRight, FolderOpen, Maximize2, Plus, X } from 'lucide-react';
import { ArrowUp, ChevronDown, ChevronRight, Loader2, Maximize2, Plus, X } from 'lucide-react';
import { useAuthStore } from '@/stores/auth';
import teacherAvatar from '@/assets/consultations/teacher.png';
import friendAvatar from '@/assets/consultations/friend.png';
@@ -24,7 +24,6 @@ export function TeacherChatPanel({
draftRevision,
sampleContext,
role,
projectName,
expanded,
onExpand,
onReturnToWork,
@@ -54,13 +53,15 @@ export function TeacherChatPanel({
}, [draftKey]);
const label = role === 'friend' ? '朋友' : '老师';
const avatar = role === 'friend' ? friendAvatar : teacherAvatar;
const [contextOpen, setContextOpen] = useState(false);
const studentTeacher = role === 'teacher' && !draftRevision;
const [historyOpen, setHistoryOpen] = useState(false);
const [definition, setDefinition] = useState<TeacherDefinition | null>(null);
const [topics, setTopics] = useState<TeacherTopicList>({ items: [], lastSelectedTopicId: null });
const [topic, setTopic] = useState<TeacherTopic | null>(null);
const [text, setText] = useState(saved.text ?? '');
const [references, setReferences] = useState<TeacherReference[]>(quote ? [quote] : saved.references ?? []);
const draftRef = useRef({ text, references });
draftRef.current = { text, references };
const [error, setError] = useState('');
const [busy, setBusy] = useState(true);
const [enabled, setEnabled] = useState(false);
@@ -77,11 +78,16 @@ export function TeacherChatPanel({
if (pane) pane.scrollTop = pane.scrollHeight;
}, [topic?.id, topic?.revision]);
const consume = useCallback(
(next: TeacherTopic) =>
(next: TeacherTopic) => {
if (pending.current && next.requests.some((request) => request.id === pending.current?.requestId)) {
pending.current = null;
persistDraft(draftRef.current.text, draftRef.current.references);
}
setTopic((current) =>
current?.id === next.id && current.revision > next.revision ? current : next
),
[]
);
},
[persistDraft]
);
useEffect(() => {
if (quote) setReferences([quote]);
@@ -108,7 +114,7 @@ export function TeacherChatPanel({
if (listed.lastSelectedTopicId) {
const current = await teacherApi.read(base, listed.lastSelectedTopicId);
if (alive && (!draftRevision || current.draftRevision === draftRevision)) {
setTopic(current);
consume(current);
setDefinition(current.definition);
}
}
@@ -124,7 +130,7 @@ export function TeacherChatPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps
generation.current++;
};
}, [base, draftRevision, role, label]);
}, [base, draftRevision, role, label, consume]);
useEffect(() => {
if (!topic?.id) return;
const streamTopicId = topic.id;
@@ -200,8 +206,10 @@ export function TeacherChatPanel({
if (version === generation.current) setBusy(false);
}
};
const send = async () => {
if (!text.trim() || busy || !enabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
const send = async (action?: Pick<TeacherSend, 'text' | 'intent'>) => {
const question = action?.text ?? text;
const questionReferences = action ? [] : references;
if (!question.trim() || busy || !enabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
const version = generation.current;
setBusy(true);
setError('');
@@ -212,22 +220,33 @@ export function TeacherChatPanel({
if (version !== generation.current) return;
consume(current);
}
const previous = pending.current;
const previous = pending.current && !current.requests.some((request) => request.id === pending.current?.requestId)
? pending.current
: null;
const input =
previous &&
previous.text === text &&
JSON.stringify(previous.references) === JSON.stringify(references)
previous.text === question &&
(previous.intent ?? 'question') === (action?.intent ?? 'question') &&
JSON.stringify(previous.references) === JSON.stringify(questionReferences)
? previous
: { requestId: crypto.randomUUID(), text, references, ...(role && sourceId ? { sourceConversationId: sourceId } : {}) };
: {
requestId: crypto.randomUUID(), text: question, references: questionReferences,
...(action?.intent ? { intent: action.intent } : {}),
...(role && sourceId ? { sourceConversationId: sourceId } : {}),
};
pending.current = input;
persistDraft(text, references, input);
const next = await teacherApi.send(base, current.id, input);
if (version !== generation.current) return;
consume(next);
pending.current = null;
if (action) {
persistDraft(text, references);
} else {
setText('');
setReferences([]);
persistDraft('', []);
pending.current = null;
}
setTopics(await teacherApi.list(base));
} catch (e) {
if (version === generation.current)
@@ -239,6 +258,9 @@ export function TeacherChatPanel({
const running = topic?.requests.find(
(request) => request.status === 'preparing' || request.status === 'running'
);
const helpUnavailable = busy || !enabled || Boolean(running);
const askForSuggestions = () => void send({ text: '老师,帮我看看', intent: 'suggestions' });
const askForGuidance = () => void send({ text: '我也说不清,你带我看看', intent: 'guided-help' });
const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); };
const removeReferences = () => { setReferences([]); persistDraft(text, [], pending.current); };
const questions = definition?.suggested_questions.length ? definition.suggested_questions : role === 'friend'
@@ -254,11 +276,6 @@ export function TeacherChatPanel({
{onExpand && <button className="consult-icon" aria-label={expanded ? '恢复咨询栏宽度' : '展开咨询栏'} onClick={onExpand}><Maximize2 className="h-4 w-4" /></button>}
{onClose && <button className="consult-icon" aria-label={`关闭${label}`} onClick={onClose}><X className="h-4 w-4" /></button>}
</header>
{!draftRevision && <>
<button className="consultation-context flex items-center gap-2 px-5 py-3 text-left text-xs" aria-expanded={contextOpen} onClick={() => setContextOpen(!contextOpen)}>
<FolderOpen className="h-3.5 w-3.5" /><span className="min-w-0 flex-1 truncate">{projectName ?? '当前项目'}</span><span className="text-[10px] opacity-70">当前项目</span><ChevronDown className="h-3 w-3" /></button>
{contextOpen && <p className="px-5 py-3 text-xs leading-6 text-muted-foreground">{role === 'friend' ? '结合你的描述和操作对话聊感受;还没有实际看到或试玩的地方,会先和你确认。' : '围绕你的项目一起思考,可以参考当前操作对话。由你决定接下来怎么做。'}</p>}
</>}
{(topics.items.length > 0 || draftRevision) && <div className="px-5 pt-2">
<button className="text-[11px] text-muted-foreground" onClick={() => setHistoryOpen(!historyOpen)} aria-expanded={historyOpen}>以往讨论 <ChevronDown className="inline h-3 w-3" /></button>
{(historyOpen || !role) && <div className="mt-2 flex gap-2">
@@ -270,18 +287,35 @@ export function TeacherChatPanel({
<div ref={scrollRef} className="consultation-messages min-h-0 flex-1 overflow-y-auto px-5 py-6">
{!topic?.requests.length && <div className="flex min-h-full flex-col items-center justify-center pb-5 text-center">
<img className="mb-6 h-[68px] w-[68px] rounded-3xl [image-rendering:pixelated]" src={avatar} alt="" />
<h3 className="text-lg font-medium tracking-tight">{definition?.welcome_message || (role === 'friend' ? '一起看看你的作品' : '哪里需要一起想一想?')}</h3>
<p className="mb-6 mt-3 text-xs leading-6 text-muted-foreground">{role === 'friend' ? <>好玩的地方、困惑的瞬间,<br />我们一起发现。</> : <>想法、做法,或遇到的困难,<br />都可以慢慢聊。</>}</p>
<div className="flex w-full max-w-64 flex-col gap-2">{questions.map((question) => <button key={question} onClick={() => changeText(question)} className="consultation-suggestion flex items-center justify-between gap-2 rounded-lg border bg-white px-3 py-3 text-left text-xs">{question}<ChevronRight className="h-3 w-3 shrink-0 opacity-50" /></button>)}</div>
<h3 className="text-lg font-medium tracking-tight">{studentTeacher ? '有问题,随时来找我' : definition?.welcome_message || (role === 'friend' ? '一起看看你的作品' : '哪里需要一起想一想?')}</h3>
<p className="mb-6 mt-3 text-[13px] leading-6 text-muted-foreground">{studentTeacher
? <>有问题可以直接问我。<br />还没想好问什么,也没关系。</>
: role === 'friend' ? <>好玩的地方、困惑的瞬间,<br />我们一起发现。</> : <>想法、做法,或遇到的困难,<br />都可以慢慢聊。</>}</p>
{studentTeacher ? <div className="w-full max-w-64">
<button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-help-start flex min-h-11 w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-[13px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50">老师,帮我看看<ChevronRight className="h-3.5 w-3.5" /></button>
<p className="mt-3 text-xs leading-6 text-muted-foreground">我会结合你做到的地方,<br />找几个可以一起聊的问题。</p>
</div> : <div className="flex w-full max-w-64 flex-col gap-2">{questions.map((question) => <button key={question} onClick={() => changeText(question)} className="consultation-suggestion flex items-center justify-between gap-2 rounded-lg border bg-white px-3 py-3 text-left text-xs">{question}<ChevronRight className="h-3 w-3 shrink-0 opacity-50" /></button>)}</div>}
</div>}
{topic?.requests.map((request) => <div key={request.id} className="mb-7 space-y-4">
<div className="flex justify-end"><p className="consultation-user max-w-[92%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm px-4 py-3 text-[13px] leading-7">{request.text}</p></div>
{request.references.length > 0 && <details className="text-xs text-muted-foreground"><summary>本轮引用 {request.references.length} 项</summary>{request.references.map((ref, index) => <pre className="whitespace-pre-wrap" key={index}>{ref.text}</pre>)}</details>}
<div className="flex items-center gap-2 text-[11px] text-muted-foreground"><img src={avatar} alt="" className="h-6 w-6 rounded-lg [image-rendering:pixelated]" />{definition?.name ?? label}</div>
<div className="whitespace-pre-wrap break-words text-[13px] leading-7">{request.response || (['running', 'preparing'].includes(request.status) ? `${label}正在想…` : '')}</div>
{request.intent === 'suggestions' ? <>
{['running', 'preparing'].includes(request.status)
? <p role="status" className="flex items-center gap-2 text-[13px] leading-7 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />我看看你最近做到了哪里…</p>
: request.status === 'completed' && <>
<p className="whitespace-pre-wrap break-words text-[13px] leading-7">{request.response}</p>
<div className="flex flex-col gap-2" aria-label="可以和老师聊的问题">
{request.suggestedQuestions?.map((question) => <button key={question} type="button" disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-suggestion flex min-h-11 items-center justify-between gap-3 rounded-xl border bg-white px-3 py-3 text-left text-[13px] leading-6 transition-colors disabled:cursor-not-allowed disabled:opacity-50">{question}<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-60" /></button>)}
</div>
<button type="button" disabled={helpUnavailable} onClick={askForGuidance} className="consultation-guided-help min-h-10 text-left text-xs leading-6 underline decoration-current/30 underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50">我也说不清,你带我看看</button>
<p className="text-[11px] leading-5 text-muted-foreground">也可以直接在下面说说你的想法。</p>
</>}
{['failed', 'cancelled', 'interrupted'].includes(request.status) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-guided-help min-h-10 text-xs underline underline-offset-4 disabled:opacity-50">再请老师看看</button>}
</> : <div className="whitespace-pre-wrap break-words text-[13px] leading-7">{request.response || (['running', 'preparing'].includes(request.status) ? `${label}正在想…` : '')}</div>}
{request.omittedMessages > 0 && <p className="text-[10px] text-muted-foreground">本轮参考了较近的讨论,省略了 {request.omittedMessages} 条较早内容。</p>}
{request.error && <p className="text-xs text-destructive">{request.error}</p>}
{request.response && <div className="flex flex-wrap gap-4 text-[11px] text-muted-foreground">
{request.response && request.intent !== 'suggestions' && <div className="flex flex-wrap gap-4 text-[11px] text-muted-foreground">
{onReturnToWork && request.status === 'completed' && <button onClick={onReturnToWork} className="hover:text-foreground">我去试一试 <ChevronRight className="inline h-3 w-3" /></button>}
<button onClick={() => void navigator.clipboard.writeText(request.response).catch(() => setError('暂时无法复制,请选中文字复制。'))}>复制</button>
{!role && onBringBack && request.status === 'completed' && <button onClick={() => onBringBack(request.response)}>带回主会话草稿</button>}
@@ -292,6 +326,7 @@ export function TeacherChatPanel({
{topic?.unsaved && <div className="px-5 py-2 text-xs text-destructive">回复尚未保存,请复制或重试保存。<button className="ml-2 underline" onClick={() => void teacherApi.save(base, topic.id).then(consume).catch(() => setError('保存失败,请先复制回复。'))}>重试保存</button></div>}
{!enabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{label}暂未开放,历史仍可查看。</p>}
<form className="px-4 pb-4 pt-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
{studentTeacher && Boolean(topic?.requests.length) && <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-help-again mb-2 flex min-h-9 items-center gap-1.5 rounded-lg px-2 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50">老师,帮我看看<ChevronRight className="h-3 w-3" /></button>}
{references.length > 0 && <div className="mb-2 rounded-lg bg-white p-3 text-xs"><p className="line-clamp-3 whitespace-pre-wrap">{references.map((ref) => ref.text).join('\n')}</p><button type="button" className="mt-1 underline" onClick={removeReferences}>移除引用</button></div>}
<div className="consultation-composer rounded-2xl border bg-white p-3 focus-within:ring-2 focus-within:ring-black/5">
<textarea aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder={`想和${label}聊些什么?`} rows={2}
+5 -2
View File
@@ -17,7 +17,11 @@
.classroom-consultation-resizer { position: absolute; inset: 0 auto 0 -4px; width: 8px; z-index: 2; cursor: col-resize; }
.classroom-consultation-resizer:hover, .classroom-consultation-resizer:focus-visible { background: #82986733; }
.consultation-pane { background: #fafbf7; color: #434f3a; }
.consultation-context { background: #f2f5ec; border-top: 1px solid #edf0e6; border-bottom: 1px solid #e7ecdf; color: #79866c; }
.consultation-help-start { background: #e7eddc; color: #435836; }
.consultation-help-start:hover:not(:disabled) { background: #dce6cd; }
.consultation-help-again, .consultation-guided-help { color: #536747; }
.consultation-help-again:hover:not(:disabled) { background: #edf2e5; }
.consultation-pane button:focus-visible { outline: 2px solid #71845f; outline-offset: 3px; }
.consultation-suggestion { border-color: #e0e6d6; color: #718163; }
.consultation-suggestion:hover { border-color: #bac9aa; background: #f4f7ee; }
.consultation-user { background: #eaf0e1; }
@@ -26,7 +30,6 @@
.consult-icon { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 6px; color: #8c9781; }
.consult-icon:hover { background: #eaf0e1; color: #45563b; }
.consultation-friend { background: #fcfaf6; color: #756347; }
.consultation-friend .consultation-context { background: #f5f0e7; border-color: #eee7d9; color: #958162; }
.consultation-friend .consultation-user { background: #f0e9db; }
.consultation-friend .consultation-composer, .consultation-friend .consultation-suggestion { border-color: #e6ddca; }
.consultation-friend .consultation-suggestion { color: #8b7657; }
+22 -8
View File
@@ -534,7 +534,9 @@ async function installCodingFirstChatHost(
return respond(consultationTopics[consultationRole],201);
}
if (path.endsWith(`${topicBase}/${topicId}/messages`) && method === 'POST') {
consultationTopics[consultationRole]={...currentTopic,revision:2,requests:[{id:body!.requestId,text:body!.text,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:consultationRole==='friend'?'听你的描述,我有点好奇该从哪里开始玩。':'先理解状态如何随点击变化,再修改代码。'}]};
const suggestionRequest = body!.intent === 'suggestions';
const teacherResponse = suggestionRequest ? '我们可以从你最近试过的地方聊起。' : body!.intent === 'guided-help' ? '你最近做的哪一步,让你停下来想了一会儿?' : '先理解状态如何随点击变化,再修改代码。';
consultationTopics[consultationRole]={...currentTopic,revision:Number(currentTopic?.revision??0)+1,requests:[...(currentTopic?.requests as unknown[]??[]),{id:body!.requestId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,status:'completed',response:consultationRole==='friend'?'听你的描述,我有点好奇该从哪里开始玩。':teacherResponse,...(suggestionRequest?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
return respond(consultationTopics[consultationRole],202);
}
if (path.endsWith(`${topicBase}/${topicId}`)) return respond(currentTopic);
@@ -1736,17 +1738,24 @@ test('project consultations preserve student drafts and switch between work and
await expect(composer).toHaveValue('保留我的草稿');
await page.getByRole('button',{name:'老师',exact:true}).click();
const teacher=page.getByTestId('teacher-chat-panel');
await expect(teacher.getByText('一起理解代码')).toBeVisible();
await teacher.getByRole('button',{name:'解释当前代码',exact:true}).click();
await expect(teacher.getByRole('textbox',{name:'向老师提问'})).toHaveValue('解释当前代码');
await expect(teacher.getByText('有问题,随时来找我')).toBeVisible();
await expect(teacher.getByText('当前项目',{exact:true})).toHaveCount(0);
await expect(teacher.getByRole('button',{name:'解释当前代码',exact:true})).toHaveCount(0);
expect((await readState(electronApp)).captured.filter(item=>item.path.endsWith('/messages')&&item.method==='POST')).toHaveLength(0);
await teacher.getByRole('textbox',{name:'向老师提问'}).fill('帮我理解当前代码');
await teacher.getByRole('button',{name:'提问',exact:true}).click();
await teacher.getByRole('textbox',{name:'向老师提问'}).fill('还没说完的困惑');
await teacher.getByRole('button',{name:'老师,帮我看看',exact:true}).click();
await expect(teacher.getByText('我们可以从你最近试过的地方聊起。')).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向老师提问'})).toHaveValue('还没说完的困惑');
expect((await readState(electronApp)).captured.filter(item=>item.path.endsWith('/messages')&&item.method==='POST')).toHaveLength(1);
await teacher.getByRole('button',{name:'怎样观察别人玩游戏?',exact:true}).click();
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向老师提问'})).toHaveValue('还没说完的困惑');
await expect(teacher.getByRole('button',{name:'带回主会话草稿'})).toHaveCount(0);
await teacher.getByRole('button',{name:'我去试一试'}).click();
await expect(composer).toHaveValue('保留我的草稿');
await teacher.getByRole('textbox',{name:'向老师提问'}).fill('还没说完的困惑');
await teacher.getByRole('button',{name:'我也说不清,你带我看看',exact:true}).click();
await expect(teacher.getByText('你最近做的哪一步,让你停下来想了一会儿?')).toBeVisible();
await expect(teacher.getByRole('textbox',{name:'向老师提问'})).toHaveValue('还没说完的困惑');
await page.getByRole('button',{name:'朋友',exact:true}).click();
await expect(teacher).toHaveAttribute('data-role','friend');
await expect(teacher.getByText('一起看看你的作品')).toBeVisible();
@@ -1762,7 +1771,12 @@ test('project consultations preserve student drafts and switch between work and
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
const requests=(await readState(electronApp)).captured;
expect(requests.filter(item=>item.path.endsWith('/prompt')&&item.method==='POST')).toHaveLength(0);
expect(requests.filter(item=>item.path.endsWith('/messages')&&item.method==='POST')).toHaveLength(1);
const consultationRequests=requests.filter(item=>item.path.endsWith('/messages')&&item.method==='POST');
expect(consultationRequests).toHaveLength(3);
expect(consultationRequests[0].body).toMatchObject({intent:'suggestions',text:'老师,帮我看看'});
expect(consultationRequests[1].body).toMatchObject({text:'怎样观察别人玩游戏?'});
expect(consultationRequests[1].body?.intent).toBeUndefined();
expect(consultationRequests[2].body).toMatchObject({intent:'guided-help',text:'我也说不清,你带我看看'});
await page.screenshot({path:test.info().outputPath('project-teacher-side-chat.png')});
await teacher.getByRole('button',{name:'关闭老师'}).click();await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'老师',exact:true}).click();
+200 -6
View File
@@ -1,7 +1,7 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TeacherChatPanel } from '@/pages/Chat/TeacherChatPanel';
import type { TeacherDefinition, TeacherTopic } from '../../shared/coding-teacher';
import type { TeacherDefinition, TeacherRequest, TeacherSend, TeacherTopic } from '../../shared/coding-teacher';
const api = vi.hoisted(() => ({
config: vi.fn(),
preview: vi.fn(),
@@ -47,6 +47,19 @@ function topic(id: string): TeacherTopic {
}
const first = topic('first'),
second = topic('second');
function request(overrides: Partial<TeacherRequest> = {}): TeacherRequest {
return {
id: 'request-1', text: '老师,帮我看看', references: [], createdAt: 'now',
sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: 'now',
includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: '',
...overrides,
};
}
const suggestedQuestions = ['怎样知道别人看懂了规则?', '我该先试哪个想法?'];
function suggestionsRequest(overrides: Partial<TeacherRequest> = {}): TeacherRequest {
return request({ intent: 'suggestions', response: '我们可以从最近遇到的这两个地方聊起。', suggestedQuestions, ...overrides });
}
let streams: Map<string, EventTarget & { close: ReturnType<typeof vi.fn> }>;
beforeEach(() => {
vi.resetAllMocks();
@@ -144,10 +157,11 @@ describe('teacher side chat', () => {
expect(api.read).toHaveBeenCalled();
});
it.each([
['teacher', '老师', '我还没想好下一步做什么', '提问'],
['friend', '朋友', '想听听你对作品的第一印象', '发送给朋友'],
] as const)('only sends a %s suggestion after the student submits it', async (role, label, suggestion, sendLabel) => {
it('keeps friend suggestions as drafts until the student submits them', async () => {
const role = 'friend';
const label = '朋友';
const suggestion = '想听听你对作品的第一印象';
const sendLabel = '发送给朋友';
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.send.mockImplementation(async (_base, _id, input) => ({ ...first, requests: [{
id: input.requestId, text: input.text, references: [], createdAt: 'now',
@@ -174,6 +188,182 @@ describe('teacher side chat', () => {
expect(screen.getByLabelText(`向${label}提问`)).toHaveValue('');
});
it.each(['teacher', 'friend'] as const)('opens %s without an extra project row or a model request', async (role) => {
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.config.mockResolvedValue({ enabled: true, published_version: 1, revision: 1, definition: { ...definition, suggested_questions: ['固定的通用问题'] } });
render(<TeacherChatPanel projectId="p" projectName="星星收集游戏" sourceId="c" role={role} />);
await waitFor(() => expect(screen.getByRole('textbox')).toBeEnabled());
expect(screen.queryByText('星星收集游戏')).not.toBeInTheDocument();
expect(screen.queryByText('当前项目')).not.toBeInTheDocument();
expect(api.create).not.toHaveBeenCalled();
expect(api.send).not.toHaveBeenCalled();
if (role === 'teacher') {
expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled();
expect(screen.queryByRole('button', { name: '固定的通用问题' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '我还没想好下一步做什么' })).not.toBeInTheDocument();
} else {
expect(screen.getByRole('button', { name: '固定的通用问题' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '老师,帮我看看', exact: true })).not.toBeInTheDocument();
}
});
it('requests contextual suggestions explicitly, hides streaming JSON and preserves the freeform draft', async () => {
const streamingJson = '{"intro":"还没输出完","questions":["尚未确认的问题';
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [suggestionsRequest({ id: input.requestId, status: 'running', response: streamingJson, suggestedQuestions: undefined })],
}));
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
const help = screen.getByRole('button', { name: '老师,帮我看看', exact: true });
await waitFor(() => expect(help).toBeEnabled());
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我自己还想问的另一件事' } });
fireEvent.click(help);
await screen.findByText('我看看你最近做到了哪里…');
expect(screen.queryByText(streamingJson)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '尚未确认的问题' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledWith('p/teacher', 'first', expect.objectContaining({
intent: 'suggestions', text: '老师,帮我看看', sourceConversationId: 'c',
}));
expect(screen.getByLabelText('向老师提问')).toHaveValue('我自己还想问的另一件事');
expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeDisabled();
await waitFor(() => expect(streams.has('first')).toBe(true));
const sent = api.send.mock.calls[0][2] as TeacherSend;
act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({
...first, revision: 3, requests: [suggestionsRequest({ id: sent.requestId })],
}) })));
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
expect(screen.queryByText('我看看你最近做到了哪里…')).not.toBeInTheDocument();
for (const question of suggestedQuestions) expect(screen.getByRole('button', { name: question })).toBeEnabled();
expect(screen.getByRole('button', { name: '我也说不清,你带我看看', exact: true })).toBeEnabled();
expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled();
expect(api.send).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('向老师提问')).toHaveValue('我自己还想问的另一件事');
});
it.each([
[suggestedQuestions[0], undefined],
['我也说不清,你带我看看', 'guided-help'],
] as const)('sends the selected help action "%s" without replacing the student draft', async (text, intent) => {
const previous = suggestionsRequest();
api.read.mockResolvedValue({ ...first, requests: [previous] });
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [previous, request({ id: input.requestId, text: input.text, intent: input.intent, response: '我们先从你刚才做的那一步看起。' })],
}));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" quote={{ kind: 'code', text: '学生正在引用的一段代码' }} />);
await waitFor(() => expect(screen.getByRole('button', { name: text, exact: true })).toBeEnabled());
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '还在整理的自由提问' } });
expect(api.send).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: text, exact: true }));
await screen.findByText('我们先从你刚才做的那一步看起。');
expect(api.send).toHaveBeenCalledTimes(1);
const sent = api.send.mock.calls[0][2] as TeacherSend;
expect(sent).toMatchObject({ text, sourceConversationId: 'c' });
expect(sent.references).toEqual([]);
expect(sent.intent).toBe(intent);
expect(screen.getByLabelText('向老师提问')).toHaveValue('还在整理的自由提问');
expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument();
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
expect(screen.getByLabelText('向老师提问')).toHaveValue('还在整理的自由提问');
expect(screen.getByText('学生正在引用的一段代码')).toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
});
it('retries an uncertain suggestions request after reopening without losing the student draft', async () => {
api.send.mockRejectedValue(new Error('网络暂不可用'));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await waitFor(() => expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled());
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我还没写完的问题' } });
fireEvent.click(screen.getByRole('button', { name: '老师,帮我看看', exact: true }));
await screen.findByText('网络暂不可用');
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
expect(originalRequest.intent).toBe('suggestions');
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await waitFor(() => expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled());
expect(screen.getByLabelText('向老师提问')).toHaveValue('我还没写完的问题');
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我又补了一点自己的想法' } });
fireEvent.click(screen.getByRole('button', { name: '老师,帮我看看', exact: true }));
await screen.findByText('网络暂不可用');
expect(api.send.mock.calls[1][2]).toEqual(originalRequest);
expect(screen.getByLabelText('向老师提问')).toHaveValue('我又补了一点自己的想法');
});
it('shows a failed suggestion request without leaking raw JSON and starts a fresh request on another try', async () => {
const failed = suggestionsRequest({ status: 'failed', response: '{"intro":"未完成', suggestedQuestions: undefined, error: '这次没有看清楚,请再试一次。' });
api.read.mockResolvedValue({ ...first, requests: [failed] });
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [failed, suggestionsRequest({ id: input.requestId })],
}));
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await screen.findByText('这次没有看清楚,请再试一次。');
expect(screen.queryByText('{"intro":"未完成')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: suggestedQuestions[0] })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '再请老师看看', exact: true }));
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
expect(api.send.mock.calls[0][2]).toMatchObject({ intent: 'suggestions' });
expect(api.send.mock.calls[0][2].requestId).not.toBe(failed.id);
});
it.each(['reopen', 'stream'] as const)('reconciles an accepted request learned through %s before retrying a failed reply', async (via) => {
let accepted: TeacherRequest;
api.send.mockRejectedValueOnce(new Error('响应中途断开')).mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 3, requests: [accepted, suggestionsRequest({ id: input.requestId })],
}));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
await waitFor(() => expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled());
await waitFor(() => expect(streams.has('first')).toBe(true));
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '我的自由提问继续留着' } });
fireEvent.click(screen.getByRole('button', { name: '老师,帮我看看', exact: true }));
await screen.findByText('响应中途断开');
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
accepted = suggestionsRequest({ id: originalRequest.requestId, status: 'failed', response: '', suggestedQuestions: undefined, error: '老师已接到,但这次回复失败了' });
const recovered = { ...first, revision: 2, requests: [accepted] };
if (via === 'reopen') {
view.unmount();
api.read.mockResolvedValue(recovered);
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
} else {
act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(recovered) })));
}
await screen.findByText('老师已接到,但这次回复失败了');
await waitFor(() => expect(screen.getByRole('button', { name: '再请老师看看', exact: true })).toBeEnabled());
expect(screen.getByLabelText('向老师提问')).toHaveValue('我的自由提问继续留着');
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '再请老师看看', exact: true }));
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
expect(api.send).toHaveBeenCalledTimes(2);
expect(api.send.mock.calls[1][2].requestId).not.toBe(originalRequest.requestId);
expect(screen.getByLabelText('向老师提问')).toHaveValue('我的自由提问继续留着');
});
it('still submits a student-written question with its quote and clears only that submitted draft', async () => {
const quote = { kind: 'code' as const, text: 'setScore(score + 1)' };
api.send.mockImplementation(async (_base, _id, input: TeacherSend) => ({
...first, revision: 2, requests: [request({
id: input.requestId, text: input.text, references: input.references ?? [], response: '你觉得每点一次,分数应该怎么变化?',
})],
}));
const view = render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" quote={quote} />);
await waitFor(() => expect(screen.getByLabelText('向老师提问')).toBeEnabled());
fireEvent.change(screen.getByLabelText('向老师提问'), { target: { value: '为什么分数没有增加?' } });
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
await screen.findByText('你觉得每点一次,分数应该怎么变化?');
expect(api.send.mock.calls[0][2]).toMatchObject({ text: '为什么分数没有增加?', references: [quote], sourceConversationId: 'c' });
expect(api.send.mock.calls[0][2].intent).toBeUndefined();
expect(screen.getByLabelText('向老师提问')).toHaveValue('');
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '老师,帮我看看', exact: true })).toBeEnabled();
view.unmount();
render(<TeacherChatPanel projectId="p" sourceId="c" role="teacher" />);
expect(screen.getByLabelText('向老师提问')).toHaveValue('');
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
});
it('restores a closed draft and keeps teacher, friend and project drafts separate', async () => {
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
const closed = vi.fn();
@@ -241,7 +431,7 @@ describe('teacher side chat', () => {
});
it('keeps operations preview available without a consultation role', async () => {
api.preview.mockResolvedValue({ payload: definition, draft_revision: 7 });
api.preview.mockResolvedValue({ payload: { ...definition, suggested_questions: ['运营配置的预览问题'] }, draft_revision: 7 });
api.list.mockResolvedValue({ items: [], lastSelectedTopicId: null });
api.create.mockResolvedValue({ ...first, draftRevision: 7 });
render(<TeacherChatPanel projectId="preview" sourceId="sample" draftRevision={7} sampleContext="学生的练习代码" />);
@@ -249,6 +439,10 @@ describe('teacher side chat', () => {
expect(screen.getByText('运营草稿试聊')).toBeInTheDocument();
expect(api.preview).toHaveBeenCalledWith(7);
expect(api.config).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '运营配置的预览问题' }));
expect(screen.getByLabelText('向老师提问')).toHaveValue('运营配置的预览问题');
expect(api.send).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: '老师,帮我看看', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '老师新话题' }));
await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码'));
});
+223 -3
View File
@@ -5,10 +5,11 @@ import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodingTeacherService, type TeacherScope } from '../../electron/coding-teacher/service';
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
import { compileTeacherContext, sourceContext } from '../../electron/coding-teacher/context';
import { compileTeacherContext, estimateTeacherTokens, sourceContext } from '../../electron/coding-teacher/context';
import { streamTeacherReply } from '../../electron/coding-teacher/model-runner';
import { TeacherError } from '../../electron/coding-teacher/config-client';
import { consultationDefinition } from '../../electron/coding-teacher/consultation-role';
import { parseTeacherSuggestions } from '../../electron/coding-teacher/suggestions';
import {
createCodingProjectStore,
createMemoryCodingProjectStorage,
@@ -19,7 +20,7 @@ import {
createCodingProjectConfigV2,
} from '../../electron/coding-projects/project-config';
import { InMemoryConversationRuntime } from '../../electron/coding-runtime/in-memory-conversation-runtime';
import type { ConsultationRole, TeacherDefinition, TeacherSourceContext } from '../../shared/coding-teacher';
import type { ConsultationRole, TeacherDefinition, TeacherRequestIntent, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
import type { ConversationSnapshot } from '../../shared/coding-conversation-contracts';
import { parseNianCodeDeepLinkUrl } from '../../electron/main/app-deep-link';
@@ -78,8 +79,9 @@ async function fixture() {
version = 1,
accountCurrent = true;
let finish: () => void = () => undefined;
let reply = '计数器保存一个数字。';
const run = vi.fn(async (_messages, signal: AbortSignal, onText: (text: string) => void) => {
onText('计数器保存一个数字。');
onText(reply);
await new Promise<void>((resolve, reject) => {
finish = resolve;
signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
@@ -123,6 +125,7 @@ async function fixture() {
readSource,
prepareModel,
finish: () => finish(),
replyWith: (text: string) => { reply = text; },
disable: () => {
enabled = false;
},
@@ -458,6 +461,223 @@ describe('project teacher and friend consultations', () => {
});
});
describe('teacher contextual discussion entry points', () => {
const requestId = '22222222-2222-4222-8222-222222222222';
const nextRequestId = '33333333-3333-4333-8333-333333333333';
const intro = '我们可以从你刚才想做的计数器聊起。';
const questions = ['我希望谁来使用这个计数器?', '我怎么知道数字有没有按照想法变化?'];
const response = JSON.stringify({ intro, questions });
const newScope = (f: Awaited<ReturnType<typeof fixture>>): TeacherScope => ({
projectId: f.scope.projectId, sourceId: 'project', role: 'teacher',
});
const finishRequest = async (f: Awaited<ReturnType<typeof fixture>>, scope: TeacherScope, id: string) => {
let complete!: (topic: TeacherTopic) => void;
const finished = new Promise<TeacherTopic>((resolve) => { complete = resolve; });
const unsubscribe = await f.service.subscribe(scope, id, (topic) => {
if (['completed', 'failed', 'cancelled'].includes(topic.requests.at(-1)?.status ?? '')) complete(topic);
});
f.finish();
const topic = await finished;
unsubscribe();
return topic;
};
it('validates a completed suggestion response, persists it, and includes it in the next question history', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.replyWith('```json\n' + response + '\n```');
const accepted = await f.service.send(scope, topic.id, {
requestId, intent: 'suggestions', text: '老师,帮我看看', sourceConversationId: f.scope.sourceId,
});
expect(accepted.requests[0]).toMatchObject({ intent: 'suggestions', status: 'running' });
expect(accepted.requests[0].suggestedQuestions).toBeUndefined();
expect(JSON.stringify(f.run.mock.calls[0][0])).toContain('创建计数器');
expect(JSON.stringify(f.run.mock.calls[0][0])).toContain('只返回 JSON 对象');
const completed = await finishRequest(f, scope, topic.id);
expect(completed.requests[0]).toMatchObject({
intent: 'suggestions', text: '老师,帮我看看', status: 'completed', response: intro, suggestedQuestions: questions,
usage: { inputTokens: 20, outputTokens: 10 },
});
const disk = JSON.parse(await readFile(path.join(
f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json'
), 'utf8'));
expect(disk.requests[0].suggestedQuestions).toEqual(questions);
expect(disk.requests[0].response).toBe(intro);
f.replyWith('你想让谁来使用?');
await f.service.send(scope, topic.id, { requestId: nextRequestId, text: questions[0] });
const nextMessages = f.run.mock.calls[1][0];
const history = nextMessages.filter((message: { role: string }) => message.role === 'assistant');
expect(history[0].content).toContain(intro);
for (const question of questions) expect(history[0].content).toContain(question);
expect(nextMessages.at(-1).content).not.toContain('只返回 JSON 对象');
expect((await f.service.read(scope, topic.id)).requests[1].intent).toBe('question');
expect(completed.definition.system_prompt).toBe(definition.system_prompt);
});
it.each(['not JSON', JSON.stringify({ intro, questions: ['只有一个问题?'] })])('fails malformed suggestions without displaying raw output or losing usage: %s', async (invalid) => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.replyWith(invalid);
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看' });
const completed = await finishRequest(f, scope, topic.id);
expect(completed.requests[0]).toMatchObject({ status: 'failed', response: '', usage: { inputTokens: 20, outputTokens: 10 } });
expect(completed.requests[0].suggestedQuestions).toBeUndefined();
expect(completed.requests[0].error).toContain('请再试一次');
const disk = JSON.parse(await readFile(path.join(
f.created.project.path, '.makelore/teacher-conversations', topic.accountId, 'project', topic.id + '.json'
), 'utf8'));
expect(disk.requests[0].response).toBe('');
expect(disk.requests[0].suggestedQuestions).toBeUndefined();
});
it('does not treat a cancelled response as valid suggestions even when its JSON is complete', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.replyWith(response);
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看' });
await f.service.cancel(scope, topic.id, requestId);
await vi.waitFor(async () => {
const request = (await f.service.read(scope, topic.id)).requests[0];
expect(request).toMatchObject({ status: 'cancelled', response: '' });
expect(request.suggestedQuestions).toBeUndefined();
});
});
it('does not parse suggestions after a failed stream or account change', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.run.mockImplementationOnce(async (_messages, _signal, onText) => {
onText(response);
throw new TeacherError(502, 'teacher_stream_interrupted', '回复中断,请再试一次。');
});
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看' });
await vi.waitFor(async () => {
const request = (await f.service.read(scope, topic.id)).requests[0];
expect(request).toMatchObject({ status: 'failed', response: '' });
expect(request.suggestedQuestions).toBeUndefined();
});
const next = await f.service.create(scope);
f.replyWith(response);
await f.service.send(scope, next.id, { requestId: nextRequestId, intent: 'suggestions', text: '老师,帮我看看' });
f.switchAccount();
f.finish();
await vi.waitFor(async () => {
const request = (await f.service.read(scope, next.id)).requests[0];
expect(request).toMatchObject({ status: 'failed', response: '' });
expect(request.suggestedQuestions).toBeUndefined();
});
});
it('retains source-deletion cancellation for suggestions', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.replyWith(response);
await f.service.send(scope, topic.id, { requestId, intent: 'suggestions', text: '老师,帮我看看', sourceConversationId: f.scope.sourceId });
await f.service.removeSource(f.scope.projectId, f.scope.sourceId);
const saved = await f.service.read(scope, topic.id);
expect(saved.requests[0]).toMatchObject({ status: 'cancelled', response: '' });
expect(saved.requests[0].suggestedQuestions).toBeUndefined();
});
it('adds a one-question guided opening for this round without changing the teacher system instructions', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
f.replyWith('先想一个人:你希望谁来用你的计数器?');
await f.service.send(scope, topic.id, { requestId, intent: 'guided-help', text: '我也说不清,你带我看看' });
const completed = await finishRequest(f, scope, topic.id);
expect(completed.requests[0]).toMatchObject({
intent: 'guided-help', status: 'completed', response: '先想一个人:你希望谁来用你的计数器?',
});
expect(completed.requests[0].suggestedQuestions).toBeUndefined();
const messages = f.run.mock.calls[0][0];
expect(messages.at(-1).content).toContain('只发起一个具体、容易回答的交流起点');
expect(messages[0]).toEqual(compileTeacherContext(definition, context, [], '问题', []).messages[0]);
});
it('deduplicates normalized question intent and rejects reuse with another intent', async () => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
const input = { requestId, text: '老师,帮我看看' };
await f.service.send(scope, topic.id, input);
await f.service.send(scope, topic.id, { ...input, intent: 'question' });
expect(f.run).toHaveBeenCalledOnce();
await expect(f.service.send(scope, topic.id, { ...input, intent: 'suggestions' }))
.rejects.toMatchObject({ code: 'teacher_request_conflict' });
const second = await f.service.create(scope);
const suggestions = { requestId: nextRequestId, intent: 'suggestions' as const, text: '老师,帮我看看' };
await Promise.all([f.service.send(scope, second.id, suggestions), f.service.send(scope, second.id, suggestions)]);
expect(f.run).toHaveBeenCalledTimes(2);
});
it.each(['suggestions', 'guided-help'] as const)('rejects %s outside the project teacher before preparing a model', async (intent) => {
const f = await fixture();
const friendScope = { ...newScope(f), role: 'friend' as const };
const previewScope = { projectId: 'preview', sourceId: 'preview' };
const friend = await f.service.create(friendScope);
const legacy = await f.service.create(f.scope);
const preview = await f.service.create(previewScope, 2, '示例项目');
for (const [scope, topic] of [[friendScope, friend], [f.scope, legacy], [previewScope, preview]] as const) {
await expect(f.service.send(scope, topic.id, { requestId, text: '老师,帮我看看', intent }))
.rejects.toMatchObject({ code: 'teacher_intent_invalid' });
expect((await f.service.read(scope, topic.id)).requests).toEqual([]);
}
expect(f.prepareModel).not.toHaveBeenCalled();
expect(f.readSource).not.toHaveBeenCalled();
expect(f.run).not.toHaveBeenCalled();
});
it.each(['unknown', '', null, 1, {}])('rejects malformed request intent %j before any model preparation', async (intent) => {
const f = await fixture();
const scope = newScope(f);
const topic = await f.service.create(scope);
await expect(f.service.send(scope, topic.id, { requestId, text: '老师,帮我看看', intent: intent as TeacherRequestIntent }))
.rejects.toMatchObject({ code: 'teacher_intent_invalid' });
expect(f.prepareModel).not.toHaveBeenCalled();
expect(f.run).not.toHaveBeenCalled();
});
it('budgets the round-specific instructions and honestly starts from ideas with no source', () => {
const empty = { ...context, messages: [] };
const ordinary = compileTeacherContext(definition, empty, [], '老师,帮我看看', []);
const suggestions = compileTeacherContext(definition, empty, [], '老师,帮我看看', [], 8000, 'suggestions');
expect(suggestions.messages[0]).toEqual(ordinary.messages[0]);
expect(suggestions.messages.at(-1)?.content).toContain('没有可用上下文时');
expect(suggestions.messages.at(-1)?.content).toContain('不要编造');
expect(() => compileTeacherContext(definition, empty, [], '老师,帮我看看', [], estimateTeacherTokens(ordinary.messages), 'suggestions'))
.toThrow('超过上下文预算');
});
it('trims and deduplicates valid suggestions, accepting plain JSON and standard JSON code fences', () => {
const raw = JSON.stringify({ intro: ' ' + intro + ' ', questions: [' ' + questions[0], questions[1], questions[0] + ' '] });
expect(parseTeacherSuggestions(raw)).toEqual({ intro, questions });
expect(parseTeacherSuggestions('```json\n' + raw + '\n```')).toEqual({ intro, questions });
expect(parseTeacherSuggestions('```\n' + raw + '\n```')).toEqual({ intro, questions });
});
it.each([
null,
[],
{ intro: '', questions },
{ intro: ' '.repeat(3), questions },
{ intro: '字'.repeat(401), questions },
{ intro, questions: [questions[0]] },
{ intro, questions: [...questions, '第三个?', '第四个?'] },
{ intro, questions: [questions[0], questions[0]] },
{ intro, questions: [questions[0], ''] },
{ intro, questions: [questions[0], '字'.repeat(121)] },
{ intro, questions: [questions[0], 1] },
])('rejects suggestion responses that cannot become two or three short questions: %j', (value) => {
expect(() => parseTeacherSuggestions(JSON.stringify(value))).toThrow('请再试一次');
});
});
describe('teacher context and wire contract', () => {
it('takes only complete user/assistant text and preserves the read cursor', () => {
const snapshot = {