diff --git a/.project-docs/30-worklog/tasks/20260922-classroom-workspace-7b9e4c21.md b/.project-docs/30-worklog/tasks/20260922-classroom-workspace-7b9e4c21.md index bfcf213d..288de00d 100644 --- a/.project-docs/30-worklog/tasks/20260922-classroom-workspace-7b9e4c21.md +++ b/.project-docs/30-worklog/tasks/20260922-classroom-workspace-7b9e4c21.md @@ -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. diff --git a/README.md b/README.md index 6dc9698e..b857e662 100644 --- a/README.md +++ b/README.md @@ -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` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。 diff --git a/electron/coding-teacher/context.ts b/electron/coding-teacher/context.ts index 5d420e8c..65cc1cce 100644 --- a/electron/coding-teacher/context.ts +++ b/electron/coding-teacher/context.ts @@ -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, ]; diff --git a/electron/coding-teacher/service.ts b/electron/coding-teacher/service.ts index bfb2de4d..e7b36676 100644 --- a/electron/coding-teacher/service.ts +++ b/electron/coding-teacher/service.ts @@ -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 { 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 { diff --git a/electron/coding-teacher/suggestions.ts b/electron/coding-teacher/suggestions.ts new file mode 100644 index 00000000..32f33cbb --- /dev/null +++ b/electron/coding-teacher/suggestions.ts @@ -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; + 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 }; +} diff --git a/shared/coding-teacher.ts b/shared/coding-teacher.ts index 91baf45e..925cbe4e 100644 --- a/shared/coding-teacher.ts +++ b/shared/coding-teacher.ts @@ -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; diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx index aeb2b2fc..0e852fc4 100644 --- a/src/pages/Chat/TeacherChatPanel.tsx +++ b/src/pages/Chat/TeacherChatPanel.tsx @@ -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(null); const [topics, setTopics] = useState({ items: [], lastSelectedTopicId: null }); const [topic, setTopic] = useState(null); const [text, setText] = useState(saved.text ?? ''); const [references, setReferences] = useState(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) => { + 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); - setText(''); - setReferences([]); - persistDraft('', []); pending.current = null; + if (action) { + persistDraft(text, references); + } else { + setText(''); + setReferences([]); + persistDraft('', []); + } 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 && } {onClose && } - {!draftRevision && <> - - {contextOpen &&

{role === 'friend' ? '结合你的描述和操作对话聊感受;还没有实际看到或试玩的地方,会先和你确认。' : '围绕你的项目一起思考,可以参考当前操作对话。由你决定接下来怎么做。'}

} - } {(topics.items.length > 0 || draftRevision) &&
{(historyOpen || !role) &&
@@ -270,18 +287,35 @@ export function TeacherChatPanel({
{!topic?.requests.length &&
-

{definition?.welcome_message || (role === 'friend' ? '一起看看你的作品' : '哪里需要一起想一想?')}

-

{role === 'friend' ? <>好玩的地方、困惑的瞬间,
我们一起发现。 : <>想法、做法,或遇到的困难,
都可以慢慢聊。}

-
{questions.map((question) => )}
+

{studentTeacher ? '有问题,随时来找我' : definition?.welcome_message || (role === 'friend' ? '一起看看你的作品' : '哪里需要一起想一想?')}

+

{studentTeacher + ? <>有问题可以直接问我。
还没想好问什么,也没关系。 + : role === 'friend' ? <>好玩的地方、困惑的瞬间,
我们一起发现。 : <>想法、做法,或遇到的困难,
都可以慢慢聊。}

+ {studentTeacher ?
+ +

我会结合你做到的地方,
找几个可以一起聊的问题。

+
:
{questions.map((question) => )}
}
} {topic?.requests.map((request) =>

{request.text}

{request.references.length > 0 &&
本轮引用 {request.references.length} 项{request.references.map((ref, index) =>
{ref.text}
)}
}
{definition?.name ?? label}
-
{request.response || (['running', 'preparing'].includes(request.status) ? `${label}正在想…` : '')}
+ {request.intent === 'suggestions' ? <> + {['running', 'preparing'].includes(request.status) + ?

我看看你最近做到了哪里…

+ : request.status === 'completed' && <> +

{request.response}

+
+ {request.suggestedQuestions?.map((question) => )} +
+ +

也可以直接在下面说说你的想法。

+ } + {['failed', 'cancelled', 'interrupted'].includes(request.status) && } + :
{request.response || (['running', 'preparing'].includes(request.status) ? `${label}正在想…` : '')}
} {request.omittedMessages > 0 &&

本轮参考了较近的讨论,省略了 {request.omittedMessages} 条较早内容。

} {request.error &&

{request.error}

} - {request.response &&
+ {request.response && request.intent !== 'suggestions' &&
{onReturnToWork && request.status === 'completed' && } {!role && onBringBack && request.status === 'completed' && } @@ -292,6 +326,7 @@ export function TeacherChatPanel({ {topic?.unsaved &&
回复尚未保存,请复制或重试保存。
} {!enabled && !error && !busy &&

{label}暂未开放,历史仍可查看。

}
{ e.preventDefault(); void send(); }}> + {studentTeacher && Boolean(topic?.requests.length) && } {references.length > 0 &&

{references.map((ref) => ref.text).join('\n')}

}