Remove teacher conversation discussion components
This commit is contained in:
1 parent
592920da06
commit
317def7912
9 files changed
+150
-371
No files matched your search
@@ -0,0 +1,60 @@
|
|||||||
|
# Task: Remove teacher agent conversation component rendering
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260928-remove-teacher-cards-ef1cf59c
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260928-remove-teacher-cards-ef1cf59c-remove-teacher-cards
|
||||||
|
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260928-remove-teacher-cards-ef1cf59c
|
||||||
|
- Base commit: 592920da060276fa222ce15383bc22d2dde2eb49
|
||||||
|
- Owner: codex
|
||||||
|
- Status: Ready for Integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Remove the screenshot's teacher consultation discussion components from MakeLore: ideas, structure, flow and comparison panels, their invitations, focus controls, actions and styles.
|
||||||
|
- Stop requesting component presentation for new ordinary questions and guided help. Preserve ordinary replies, quick suggestions, rich text, tool activity, project chat identity and historical data.
|
||||||
|
- Renderer, its focused unit/browser/Electron regressions and README only; no Main/backend protocol or storage migration.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- User explicitly requested removal of component rendering and prohibited subagents without consent. No subagents were created.
|
||||||
|
- Concurrent Task Gate passed: bundled check/start/status established this feature task, branch, absolute worktree and base with matching Git-common ownership. Primary main has unrelated untracked records and remained unchanged.
|
||||||
|
- Project Context Loaded: read-before-planning, own task, memory-index, project-positioning, current-state, decision-index, system-overview, business-rules, success-criteria and ADR-2026-09-22-coding-teacher; AGENTS.md and README supplement generic positioning placeholders. Current product is project-scoped ongoing agent chat; Main owns execution, history and read tools.
|
||||||
|
- Read all 132 peer task records' available Scope, Intent And Constraints and Promotion Candidates. Historical placeholder scopes remain unknown; related consultation/rendering work is integrated. Independent proactive observer work remains separate and does not require the removed component UI. No concrete semantic dependency blocks this task.
|
||||||
|
- Planning Gate Passed before edits. The explicit user request supersedes the old component-rendering behavior; canonical memory remains unchanged in feature mode.
|
||||||
|
- Original workspace was Works Square; matching visible labels were found only in MakeLore TeacherDiscussionPanel and TeacherChatPanel. Companion server task records that routing finding without product edits.
|
||||||
|
- An uncertain pre-upgrade request retries its original ID and full payload, including old presentation fields. New requests omit presentation/discussion. This preserves existing replay semantics and prevents duplicate model execution.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Deleted TeacherDiscussionPanel.tsx and teacher-discussion.css, plus the panel mount, focus selection, state transitions and component API calls from TeacherChatPanel.
|
||||||
|
- Ordinary new questions and guided help use the existing non-component path. Old active/offered discussion state no longer hides suggested follow-up questions.
|
||||||
|
- Input stays fixed using shrink-0 after removal of its former CSS rule. Existing replies, failure originals, tool activity and all Main-owned history remain intact.
|
||||||
|
- Updated README and existing tests/fixtures to describe and verify the final behavior.
|
||||||
|
- No merge, push, packaged installation, deployment or paid model call performed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- pnpm 10.33.4 matches packageManager. Reused the primary checkout's existing dependencies through a local node_modules junction; no lockfile/dependency change.
|
||||||
|
- 47 tests passed: coding-teacher-ui.test.tsx and teacher-conversation-panel.test.tsx. Detects hidden old cards reappearing, new component request metadata, lost uncertain-request identity, broken cancellation/drafts and project conversation regressions.
|
||||||
|
- pnpm run typecheck passed; scoped ESLint passed for all changed TS/TSX files.
|
||||||
|
- pnpm run build:vite passed for Renderer/Main/Preload/utility. Existing bundle size, mixed-import and old Browserslist warnings remain.
|
||||||
|
- 12 Edge browser tests passed: 319px/508px ordinary and all four historical component kinds, plus Markdown/table/code/image/math layouts. Captured comparison screenshot was inspected: no component panel and composer remains visible. Vite reported KaTeX fonts outside its allow list because dependencies are reused across the worktree; mathematical DOM rendering passed, but this fixture run is not custom-font verification.
|
||||||
|
- 1 Electron scenario passed: project consultations preserve student drafts and switch between work/chat; ordinary/guided/suggestion messages have no new presentation/discussion metadata.
|
||||||
|
- Tests use deterministic API responses, not live provider calls. Task-aware documentation drift check passed.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- Integrate the client branch when requested; installed applications need an updated client build.
|
||||||
|
- Independent proactive-observer integration must retain this component-free consultation display.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- Target: coding-teacher ADR, system-overview and teacher behavior in current-state/business-rules.
|
||||||
|
- Proposal: replace the pinned discussion component UI contract with ordinary conversation; new questions and guided help omit discussion-v1, while existing structured histories and uncertain request recovery retain their original data.
|
||||||
|
- Evidence: explicit user screenshot request, Renderer deletion and passing unit/browser/Electron checks.
|
||||||
|
- Future impact: future consultation UI or observer work must not restore those components incidentally.
|
||||||
|
- Semantic conflict: intentionally supersedes the previously accepted visible component workflow; user has authorized this change. Canonical reconciliation belongs to a later Integration Gate, with no further product clarification required.
|
||||||
|
|
||||||
|
|
||||||
@@ -175,14 +175,13 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
|||||||
- 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成简短的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。
|
- 新的单会话入口停止旧版前端定时跟进派发;项目主动观察由独立功能衔接,不能重新创建可见话题。未下发目录的旧版入口仍保留原跟进合同:前端每 5 分钟请求一次智能体跟进;窗口隐藏、来源归档、主对话正在执行、智能体正在回复或学生正在智能体栏写草稿时延后。Main 再检查在线启停、来源归属、项目级冷却和已完成文本指纹,未变上下文按下述十五分钟冷却处理。跟进沿用运营模型、已发布 Skills 与当前智能体话题,生成简短的具体建议或引导;以主动智能体发言持久化,不伪造学生消息。模型调用沿用智能体计费规则。
|
||||||
- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。
|
- 进入项目时,智能体头像旁先显示运营发布的欢迎语气泡,不调用模型、不声称已检查项目;每个账号/项目主动收起后不重复弹出。真实的未读主动建议优先替换欢迎语。主动发言在头像旁直接显示气泡,不抢焦点、不盖住作品;作品区按气泡与原生预览的实际交叠范围避让,已有工具栏高度计入计算,气泡位于独立智能体栏时不额外留白,窗口/分栏尺寸变化和收起气泡时实时归还空间;“和智能体聊聊”接回同一段咨询,“等会儿聊”收起并保留历史。已有未处理气泡时不堆叠新邀请;咨询已展开时也显示气泡,直到学生主动点击查看或收起,期间的其他回复不会吞掉未读邀请。五分钟检查计时在刷新和项目切换后保留;智能体咨询里的新讨论也算进展,即使操作对话没有变化也会继续跟进;上下文完全未变时放缓到十五分钟一次,不永久停止。事件连接丢失时,每十五秒只读同步未完成回复,收到结果后停止轮询,不重复调用模型。失败如实提示,不以本地文案替代模型回复。已接受请求恢复使用原身份,退出账号和来源删除仍沿用 Main 中止边界。
|
||||||
- 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。
|
- 智能体同时支持自由提问与输入框底部的提示词按钮“帮我看看”:学生点击后才结合当前操作对话与咨询历史生成 2–3 个可聊的问题。点击生成的问题会主动开始讨论;“我也说不清,你带我看看”让智能体从一个具体、容易回答的交流起点带着聊。这些快捷求助保留已有输入草稿,网络结果不确定时复用请求身份,已确认终态的请求不重复执行;解析失败可重新求助,不用固定问题伪装模型结果。服务端下发的推荐问题只填入草稿,由用户确认发送。界面不提供独立示范页面、“记一下”、共识或自动待办,智能体的回复下不再展示“我去试一试”和“复制”按钮,学生通过已有的操作对话/作品入口继续创作。
|
||||||
- 智能体讨论采用“上方固定整理内容、下方独立滚动对话、底部原有输入框”的布局。普通回答可带直接发送的引导问题;想法板、结构图、流程/条件图和逐项对照由同一次模型回复提供结构化数据。先邀请学生“用这个一起想”,进入后程序锁定信息结构,智能体随讨论更新同一份内容;解释问题可仅回复文字。节点点击只选择讨论焦点,对照里的“聊聊这一点”直接发问,都保留输入草稿。
|
- 智能体咨询栏保留普通消息与原有输入框,不再渲染想法板、结构图、流程/条件图、逐项对照或其邀请、讨论焦点和操作按钮。新提问与引导求助不再请求 `discussion-v1` 组件输出;“帮我看看”及生成的追问仍可使用,保留输入草稿。
|
||||||
- 想法板区分已留下、智能体建议和暂放内容,可由学生采纳、暂放、选择先想哪项。“把想法理一理”明确转换为智能体归纳的结构图,并可“回去补充想法”。“只聊天”暂停整理,“先这些”暂时结束并保留未确定内容,“接着改”恢复原工具;这些操作只影响智能体讨论,不创建项目分支、不执行作品修改。当前讨论卡片按聊天中的项目分别保存,历史轮次保留卡片快照。
|
- 历史讨论数据及快照保留在 Main 的原存储中,旧回复正文、工具活动和解析失败时保存的原文仍可查看。升级前结果不确定的请求重试保持原请求身份与内容,避免重复执行。
|
||||||
- Main 校验结构、引用和内容长度,拒绝未经学生选择的类型变更与旧版本更新。结构化回复在完整校验后一次应用;生成中显示简短处理状态,停止、失败或无效组件保留上一份内容。普通 Markdown 链接、JSON 对象/数组和代码示例保持正文,仅讨论协议的顶层字段或专用围栏进入组件解析。解析失败仍保留完整的可读 reply,并将本次原始回答保存为默认折叠、字面显示的“查看收到的原始内容”,不把它再次加入模型上下文;既有丢失原文的历史不能恢复。
|
|
||||||
- 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。
|
- 咨询正文支持 Markdown 标题、列表、表格、代码围栏、HTTP(S) 链接/图片和数学公式,长代码与表格在栏内横向滚动,不执行 HTML。云端正文维持 string 合同,未知对象不会猜测转成回答。工具活动只从云端主线程的类型化工具事件及 Main 本地读取过程获得,按本次问题/工具调用身份合并名称和状态,跨云端暂停、续接仍只计一次;暂停读取不算失败,状态以实际读取结果或问题终态为准,单独折叠显示;参数、结果原文和内部错误不混入回复,思考内容不作为正文。客户端仅保存自己实际收到的工具状态,不能补回旧历史或断线期间已经过期的事件。
|
||||||
- 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。
|
- 云端咨询在 local_context 声明 read_protocol=2;需先部署配套 Yuxi API 和 worker,再升级客户端。云端持久累计读取字节及批次,每轮告知模型剩余额度;预算耗尽后消费最后一批结果,并以 tool_choice=none 要求根据现有证据形成答案和说明缺口。Main 限制实际返回量并拒绝第十三批读取,区分读取达到上限、上下文失效及格式无效。服务端对未声明协议的已安装旧客户端保留原工具参数与六批边界。
|
||||||
- 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。
|
- 智能体人设、职责、提示词和 Skills 由发布配置决定。Main 不追加固定教学基线、不生成“小麦”、不清空所选智能体的 Skills,只添加真实工具能力、上下文边界和本轮界面协议。智能体名称叫“老师”“朋友”或“代码顾问”不改变调用路径或权限。
|
||||||
- 每轮格式由 Main 的对应意图协议决定;`discussion.ts` 为支持组件的请求注入唯一 `{reply, quickReplies, tool}` 协议。运营教学补充不另写字段协议或要求始终纯文字。工具内讨论保留类型、稳定 ID、未修改内容和采纳状态;暂停/未进入时 `tool:null`,没有实质变化时也可保留原内容。结构图、流程和对照目前没有独立的采纳/来源字段,待定、建议与预测只能在展示文字中明确,不能据此推导已确认共识。
|
- 每轮格式由 Main 的对应意图协议决定;当前咨询界面的新问题使用普通回答,“帮我看看”使用既有问题建议协议。旧请求的讨论协议解析与数据读取仍保留,客户端不再提供组件交互入口。
|
||||||
- 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询历史和适用的当前组件,并支持上述只读原文恢复;没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。“先这些”仍仅结束本轮整理,保留各条目状态,不确认全部候选、不自动制作。提示词不宣称这些系统能力已接通,也不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。
|
- 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用和咨询历史,并支持上述只读原文恢复;没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。提示词不宣称这些系统能力已接通,也不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。
|
||||||
- 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或当前整理本身超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。
|
- 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或当前整理本身超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。
|
||||||
- `tests/fixtures/teacher-guidance-examples.json` 提供宠物、种植、射击、蛋糕的人工多轮输入输出样例,由真实解析器、上下文编译器与讨论状态机校验;样例不加载到运行时,也不代表真实模型教学效果已验收。云端定义由 Yuxi 维护并由运营显式同步下发;本地代码修改不会自动改写云端配置或学生已接受问题的固定发布版本。
|
- `tests/fixtures/teacher-guidance-examples.json` 提供宠物、种植、射击、蛋糕的人工多轮输入输出样例,由真实解析器、上下文编译器与讨论状态机校验;样例不加载到运行时,也不代表真实模型教学效果已验收。云端定义由 Yuxi 维护并由运营显式同步下发;本地代码修改不会自动改写云端配置或学生已接受问题的固定发布版本。
|
||||||
- 下发智能体的图标随发布版本保存,顶栏、咨询面板、欢迎区和消息使用定义中的 `icon_url`;未设置或图片加载失败时使用内置头像。运营在 Yuxi 更换图标后需同步下发配置,联系人使用当前下发的名称和图标;每轮执行仍记录实际发布版本。相对图片地址由服务端使用 Yuxi 公网入口补全。
|
- 下发智能体的图标随发布版本保存,顶栏、咨询面板、欢迎区和消息使用定义中的 `icon_url`;未设置或图片加载失败时使用内置头像。运营在 Yuxi 更换图标后需同步下发配置,联系人使用当前下发的名称和图标;每轮执行仍记录实际发布版本。相对图片地址由服务端使用 Yuxi 公网入口补全。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
import { ArrowUp, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { teacherApi, teacherTopicsPath, teacherConversationsPath, legacyTopicBase, type TeacherCatalogState } from '@/lib/coding-teacher';
|
import { teacherApi, teacherTopicsPath, teacherConversationsPath, legacyTopicBase, type TeacherCatalogState } from '@/lib/coding-teacher';
|
||||||
@@ -10,8 +10,6 @@ import type {
|
|||||||
TeacherTopic,
|
TeacherTopic,
|
||||||
TeacherTopicList,
|
TeacherTopicList,
|
||||||
} from '../../../shared/coding-teacher';
|
} from '../../../shared/coding-teacher';
|
||||||
import { TeacherDiscussionPanel, DiscussionFocus } from './TeacherDiscussionPanel';
|
|
||||||
import type { TeacherDiscussionAction } from '../../../shared/coding-teacher';
|
|
||||||
import { TeacherAvatar } from './TeacherAvatar';
|
import { TeacherAvatar } from './TeacherAvatar';
|
||||||
import { TeacherReply } from './TeacherReply';
|
import { TeacherReply } from './TeacherReply';
|
||||||
|
|
||||||
@@ -87,7 +85,7 @@ function ProjectTeacherChatPanel({
|
|||||||
try { localStorage.setItem(draftKey, JSON.stringify({ text, references, pending, ...referenceScopeRef.current })); } catch { /* Draft remains in the open panel. */ }
|
try { localStorage.setItem(draftKey, JSON.stringify({ text, references, pending, ...referenceScopeRef.current })); } catch { /* Draft remains in the open panel. */ }
|
||||||
}, [draftKey]);
|
}, [draftKey]);
|
||||||
const label = '智能体';
|
const label = '智能体';
|
||||||
const discussionEnabled = !draftRevision;
|
const helpEnabled = !draftRevision;
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [before, setBefore] = useState<string | null>(cachedView?.before ?? null);
|
const [before, setBefore] = useState<string | null>(cachedView?.before ?? null);
|
||||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
@@ -95,7 +93,6 @@ function ProjectTeacherChatPanel({
|
|||||||
const followBottom = useRef(cachedView?.follow ?? true);
|
const followBottom = useRef(cachedView?.follow ?? true);
|
||||||
const restoreTop = useRef(cachedView?.top);
|
const restoreTop = useRef(cachedView?.top);
|
||||||
const scrollPosition = useRef(cachedView?.top ?? 0);
|
const scrollPosition = useRef(cachedView?.top ?? 0);
|
||||||
const [focus, setFocus] = useState<{ topicId: string; id: string; label: string } | null>(null);
|
|
||||||
const [localDefinition, setDefinition] = useState<TeacherDefinition | null>(null);
|
const [localDefinition, setDefinition] = useState<TeacherDefinition | null>(null);
|
||||||
const catalogStateRef = useRef(catalogState);
|
const catalogStateRef = useRef(catalogState);
|
||||||
catalogStateRef.current = catalogState;
|
catalogStateRef.current = catalogState;
|
||||||
@@ -107,10 +104,7 @@ function ProjectTeacherChatPanel({
|
|||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const focusedOnce = useRef(false);
|
const focusedOnce = useRef(false);
|
||||||
const [topics, setTopics] = useState<TeacherTopicList>({ items: [], lastSelectedTopicId: null });
|
const [topics, setTopics] = useState<TeacherTopicList>({ items: [], lastSelectedTopicId: null });
|
||||||
const [storedTopic, setTopic] = useState<TeacherTopic | null>(cachedView?.topic ?? null);
|
const [topic, setTopic] = useState<TeacherTopic | null>(cachedView?.topic ?? null);
|
||||||
const topic = useMemo(() => storedTopic && continuous ? { ...storedTopic,
|
|
||||||
discussion: storedTopic.conversation?.discussions[projectId] ?? storedTopic.discussion }
|
|
||||||
: storedTopic, [storedTopic, continuous, projectId]);
|
|
||||||
const legacyFriend = topic?.role === 'friend';
|
const legacyFriend = topic?.role === 'friend';
|
||||||
const legacyView = Boolean(continuous && topic && !topic.conversation);
|
const legacyView = Boolean(continuous && topic && !topic.conversation);
|
||||||
const viewingLegacy = useRef(false);
|
const viewingLegacy = useRef(false);
|
||||||
@@ -158,12 +152,6 @@ function ProjectTeacherChatPanel({
|
|||||||
if (continuous && draftKey && !viewingLegacy.current) conversationViews.set(draftKey, { topic: topicRef.current,
|
if (continuous && draftKey && !viewingLegacy.current) conversationViews.set(draftKey, { topic: topicRef.current,
|
||||||
before: beforeRef.current, top: scrollPosition.current, follow: followBottom.current });
|
before: beforeRef.current, top: scrollPosition.current, follow: followBottom.current });
|
||||||
}, [continuous, draftKey]);
|
}, [continuous, draftKey]);
|
||||||
const content = topic?.discussion?.content;
|
|
||||||
const activeFocus = focus && focus.topicId === topic?.id && content && topic?.discussion?.status === 'active' && (
|
|
||||||
content.kind === 'ideas' ? content.items.some(item => item.id === focus.id)
|
|
||||||
: content.kind === 'comparison' ? content.rows.some(row => content.columns.some(column => `${row.id}:${column.id}` === focus.id))
|
|
||||||
: content.nodes.some(node => node.id === focus.id)
|
|
||||||
) ? focus : null;
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const pane = scrollRef.current;
|
const pane = scrollRef.current;
|
||||||
if (!pane) return;
|
if (!pane) return;
|
||||||
@@ -396,7 +384,7 @@ function ProjectTeacherChatPanel({
|
|||||||
if (version === generation.current) setBusy(false);
|
if (version === generation.current) setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const send = async (action?: Pick<TeacherSend, 'text' | 'intent' | 'discussion'>) => {
|
const send = async (action?: Pick<TeacherSend, 'text' | 'intent'>) => {
|
||||||
const question = action?.text ?? text;
|
const question = action?.text ?? text;
|
||||||
const questionReferences = action ? [] : references;
|
const questionReferences = action ? [] : references;
|
||||||
if (!question.trim() || busy || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
|
if (!question.trim() || busy || !topicEnabled || topicRef.current?.requests.some((request) => ['preparing', 'running'].includes(request.status))) return;
|
||||||
@@ -422,27 +410,21 @@ function ProjectTeacherChatPanel({
|
|||||||
consume(current);
|
consume(current);
|
||||||
setDefinition(current.definition);
|
setDefinition(current.definition);
|
||||||
}
|
}
|
||||||
const presentation = discussionEnabled && (!action?.intent || action.intent === 'question' || action.intent === 'guided-help') ? 'discussion-v1' as const : undefined;
|
|
||||||
const discussion = presentation && current?.discussion?.status === 'active'
|
|
||||||
? action?.discussion ?? { toolId: current.discussion.id, revision: current.discussion.revision,
|
|
||||||
...(activeFocus?.topicId === current.id ? { focusId: activeFocus.id } : {}) } : undefined;
|
|
||||||
const previous = pending.current && !current?.requests.some((request) => request.id === pending.current?.requestId)
|
const previous = pending.current && !current?.requests.some((request) => request.id === pending.current?.requestId)
|
||||||
? pending.current
|
? pending.current
|
||||||
: null;
|
: null;
|
||||||
|
// Retry an uncertain saved request verbatim, including pre-upgrade presentation fields.
|
||||||
const input =
|
const input =
|
||||||
previous &&
|
previous &&
|
||||||
previous.text === question &&
|
previous.text === question &&
|
||||||
(!continuous || previous.projectId === projectId) &&
|
(!continuous || previous.projectId === projectId) &&
|
||||||
previous.sourceConversationId === (sourceId || undefined) &&
|
previous.sourceConversationId === (sourceId || undefined) &&
|
||||||
previous.presentation === presentation &&
|
|
||||||
JSON.stringify(previous.discussion) === JSON.stringify(discussion) &&
|
|
||||||
(previous.intent ?? 'question') === (action?.intent ?? 'question') &&
|
(previous.intent ?? 'question') === (action?.intent ?? 'question') &&
|
||||||
JSON.stringify(previous.references) === JSON.stringify(questionReferences)
|
JSON.stringify(previous.references) === JSON.stringify(questionReferences)
|
||||||
? previous
|
? previous
|
||||||
: {
|
: {
|
||||||
requestId: crypto.randomUUID(), text: question, references: questionReferences,
|
requestId: crypto.randomUUID(), text: question, references: questionReferences,
|
||||||
...(continuous ? { projectId } : {}),
|
...(continuous ? { projectId } : {}),
|
||||||
...(presentation ? { presentation } : {}), ...(discussion ? { discussion } : {}),
|
|
||||||
...(action?.intent ? { intent: action.intent } : {}),
|
...(action?.intent ? { intent: action.intent } : {}),
|
||||||
...(sourceId ? { sourceConversationId: sourceId } : {}),
|
...(sourceId ? { sourceConversationId: sourceId } : {}),
|
||||||
};
|
};
|
||||||
@@ -472,34 +454,6 @@ function ProjectTeacherChatPanel({
|
|||||||
(request) => request.status === 'preparing' || request.status === 'running'
|
(request) => request.status === 'preparing' || request.status === 'running'
|
||||||
);
|
);
|
||||||
const helpUnavailable = busy || !topicEnabled || Boolean(running);
|
const helpUnavailable = busy || !topicEnabled || Boolean(running);
|
||||||
const updateDiscussion = async (action: TeacherDiscussionAction['action'], itemId?: string) => {
|
|
||||||
const current = topicRef.current;
|
|
||||||
if (!current?.discussion || helpUnavailable) return;
|
|
||||||
const version = generation.current;
|
|
||||||
setBusy(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const next = await teacherApi.updateDiscussion(topicBase, endpointId, {
|
|
||||||
...(continuous ? { projectId } : {}),
|
|
||||||
toolId: current.discussion.id, revision: current.discussion.revision, action, ...(itemId ? { itemId } : {}),
|
|
||||||
});
|
|
||||||
if (version === generation.current) { consume(next); setFocus(null); }
|
|
||||||
} catch (e) {
|
|
||||||
if (version === generation.current) setError(e instanceof Error ? e.message : '内容还没更新,请再试一次。');
|
|
||||||
} finally { if (version === generation.current) setBusy(false); }
|
|
||||||
};
|
|
||||||
const focusItem = (id: string, label: string) => {
|
|
||||||
if (!topic) return;
|
|
||||||
setFocus({ topicId: topic.id, id, label });
|
|
||||||
inputRef.current?.focus();
|
|
||||||
};
|
|
||||||
const structureIdeas = () => {
|
|
||||||
if (!topic?.discussion) return;
|
|
||||||
setFocus(null);
|
|
||||||
void send({ text: '把我留下的想法理一理,看看它们怎么联系。', discussion: {
|
|
||||||
toolId: topic.discussion.id, revision: topic.discussion.revision, transition: 'structure',
|
|
||||||
} });
|
|
||||||
};
|
|
||||||
const askForSuggestions = () => void send({ text: '帮我看看', intent: 'suggestions' });
|
const askForSuggestions = () => void send({ text: '帮我看看', intent: 'suggestions' });
|
||||||
const askForGuidance = () => void send({ text: '我也说不清,你带我看看', intent: 'guided-help' });
|
const askForGuidance = () => void send({ text: '我也说不清,你带我看看', intent: 'guided-help' });
|
||||||
const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); };
|
const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); };
|
||||||
@@ -529,13 +483,6 @@ function ProjectTeacherChatPanel({
|
|||||||
className="shrink-0 rounded-lg px-2 text-xs text-muted-foreground hover:bg-white hover:text-foreground disabled:opacity-40">新话题</button>
|
className="shrink-0 rounded-lg px-2 text-xs text-muted-foreground hover:bg-white hover:text-foreground disabled:opacity-40">新话题</button>
|
||||||
</div>}
|
</div>}
|
||||||
</div>}
|
</div>}
|
||||||
{discussionEnabled && topic?.discussion && <TeacherDiscussionPanel key={topic.id} discussion={topic.discussion}
|
|
||||||
disabled={helpUnavailable} focusId={activeFocus?.id}
|
|
||||||
onAction={(action, itemId) => void updateDiscussion(action, itemId)} onFocus={focusItem} onStructure={structureIdeas}
|
|
||||||
onDiscuss={(id, question) => {
|
|
||||||
focusItem(id, question);
|
|
||||||
void send({ text: question, discussion: { toolId: topic.discussion!.id, revision: topic.discussion!.revision, focusId: id } });
|
|
||||||
}} />}
|
|
||||||
<div ref={scrollRef} className="consultation-messages min-h-0 flex-1 overflow-y-auto px-5 py-6"
|
<div ref={scrollRef} className="consultation-messages min-h-0 flex-1 overflow-y-auto px-5 py-6"
|
||||||
onScroll={() => { const pane = scrollRef.current; if (!pane) return;
|
onScroll={() => { const pane = scrollRef.current; if (!pane) return;
|
||||||
scrollPosition.current = pane.scrollTop;
|
scrollPosition.current = pane.scrollTop;
|
||||||
@@ -576,8 +523,8 @@ function ProjectTeacherChatPanel({
|
|||||||
<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>
|
<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>
|
||||||
</>}
|
</>}
|
||||||
{['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>}
|
{['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>}
|
||||||
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{request.discussionContext ? '正在梳理你的新想法…' : `${label}正在想…`}</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
|
</> : request.response ? <TeacherReply text={request.response} /> : <div className="text-[13px] leading-7">{['running', 'preparing'].includes(request.status) ? <span role="status" className="inline-flex items-center gap-2 text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />{`${label}正在想…`}</span> : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}</div>}
|
||||||
{discussionEnabled && request.intent !== 'suggestions' && request.status === 'completed' && topic.discussion?.status !== 'active' && topic.discussion?.status !== 'offered' && Boolean(request.suggestedQuestions?.length) && <div className="consultation-follow-ups" aria-label="接着聊">
|
{helpEnabled && request.intent !== 'suggestions' && request.status === 'completed' && Boolean(request.suggestedQuestions?.length) && <div className="consultation-follow-ups" aria-label="接着聊">
|
||||||
{request.suggestedQuestions?.map(question => <button type="button" key={question} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
|
{request.suggestedQuestions?.map(question => <button type="button" key={question} disabled={helpUnavailable} onClick={() => void send({ text: question })} className="consultation-follow-up"><span className="min-w-0 flex-1">{question}</span><ChevronRight className="h-4 w-4 shrink-0 opacity-70" aria-hidden="true" /></button>)}
|
||||||
</div>}
|
</div>}
|
||||||
{request.discussionError && <p className="text-xs leading-6 text-muted-foreground">{request.discussionError}</p>}
|
{request.discussionError && <p className="text-xs leading-6 text-muted-foreground">{request.discussionError}</p>}
|
||||||
@@ -597,7 +544,7 @@ function ProjectTeacherChatPanel({
|
|||||||
{error && <p role="alert" className="px-5 py-2 text-xs leading-5 text-destructive">{error}</p>}
|
{error && <p role="alert" className="px-5 py-2 text-xs leading-5 text-destructive">{error}</p>}
|
||||||
{topic?.unsaved && <div className="px-5 py-2 text-xs text-destructive">回复尚未保存,请复制或重试保存。<button className="ml-2 underline" onClick={() => void teacherApi.save(topicBase, endpointId).then(consume).catch(() => setError('保存失败,请先复制回复。'))}>重试保存</button></div>}
|
{topic?.unsaved && <div className="px-5 py-2 text-xs text-destructive">回复尚未保存,请复制或重试保存。<button className="ml-2 underline" onClick={() => void teacherApi.save(topicBase, endpointId).then(consume).catch(() => setError('保存失败,请先复制回复。'))}>重试保存</button></div>}
|
||||||
{!topicEnabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{legacyFriend ? '这是旧版内置朋友的话题,仅供查看。请选择智能体新建话题。' : `${label}暂未开放,历史仍可查看。`}</p>}
|
{!topicEnabled && !error && !busy && <p className="px-5 pb-2 text-xs text-muted-foreground">{legacyFriend ? '这是旧版内置朋友的话题,仅供查看。请选择智能体新建话题。' : `${label}暂未开放,历史仍可查看。`}</p>}
|
||||||
<form className="px-4 pb-4 pt-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
|
<form className="shrink-0 px-4 pb-4 pt-2" onSubmit={(e) => { e.preventDefault(); void send(); }}>
|
||||||
{Boolean(unassignedDraft.text?.trim() || unassignedDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
|
{Boolean(unassignedDraft.text?.trim() || unassignedDraft.references?.length) && <details className="mb-2 text-xs text-muted-foreground">
|
||||||
<summary>查看之前的项目草稿</summary>
|
<summary>查看之前的项目草稿</summary>
|
||||||
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">
|
<div className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg border p-2">
|
||||||
@@ -614,12 +561,11 @@ function ProjectTeacherChatPanel({
|
|||||||
</details>}
|
</details>}
|
||||||
{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>}
|
{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">
|
<div className="consultation-composer rounded-2xl border bg-white p-3 focus-within:ring-2 focus-within:ring-black/5">
|
||||||
{activeFocus && <DiscussionFocus label={activeFocus.label} onClear={() => setFocus(null)} />}
|
|
||||||
<textarea ref={inputRef} aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder="和老师聊聊" rows={2}
|
<textarea ref={inputRef} aria-label={`向${label}提问`} value={text} disabled={busy} maxLength={6000} onChange={(e) => changeText(e.target.value)} placeholder="和老师聊聊" rows={2}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void send(); } }}
|
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); void send(); } }}
|
||||||
className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
|
className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
|
||||||
<div className="mt-2 flex items-center justify-between gap-2" data-testid="consultation-composer-actions">
|
<div className="mt-2 flex items-center justify-between gap-2" data-testid="consultation-composer-actions">
|
||||||
{discussionEnabled ? <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-help-prompt flex min-h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50">帮我看看<ChevronRight className="h-3 w-3" aria-hidden="true" /></button>
|
{helpEnabled ? <button type="button" disabled={helpUnavailable} onClick={askForSuggestions} className="consultation-help-prompt flex min-h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50">帮我看看<ChevronRight className="h-3 w-3" aria-hidden="true" /></button>
|
||||||
: <span />}
|
: <span />}
|
||||||
{running ? <button type="button" className="rounded-lg border px-3 py-2 text-xs" onClick={() => void teacherApi.cancel(topicBase, endpointId, running.id).then(consume).catch(() => setError('暂时无法停止,请稍后重试。'))}>停止回复</button>
|
{running ? <button type="button" className="rounded-lg border px-3 py-2 text-xs" onClick={() => void teacherApi.cancel(topicBase, endpointId, running.id).then(consume).catch(() => setError('暂时无法停止,请稍后重试。'))}>停止回复</button>
|
||||||
: <button type="submit" aria-label="提问" title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim()} className="consultation-send flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
|
: <button type="submit" aria-label="提问" title={busy ? '正在发送…' : '发送'} disabled={busy || !topicEnabled || !text.trim()} className="consultation-send flex h-8 w-8 items-center justify-center rounded-full text-white disabled:opacity-30"><ArrowUp className="h-4 w-4" /></button>}
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { ArrowDown, ArrowRight, Check, ChevronDown, ChevronUp, GitBranch, Layers3, MessageCircle, Puzzle, Sparkles, X } from 'lucide-react';
|
|
||||||
import type { TeacherDiscussionAction } from '../../../shared/coding-teacher';
|
|
||||||
import type { TeacherDiscussion, TeacherStructureNode } from '../../../shared/teacher-discussion';
|
|
||||||
import './teacher-discussion.css';
|
|
||||||
|
|
||||||
type Action = TeacherDiscussionAction['action'];
|
|
||||||
export function TeacherDiscussionPanel({ discussion, disabled, focusId, onAction, onFocus, onDiscuss, onStructure }: {
|
|
||||||
discussion: TeacherDiscussion;
|
|
||||||
disabled: boolean;
|
|
||||||
focusId?: string;
|
|
||||||
onAction(action: Action, itemId?: string): void;
|
|
||||||
onFocus(id: string, label: string): void;
|
|
||||||
onDiscuss(id: string, text: string): void;
|
|
||||||
onStructure(): void;
|
|
||||||
}) {
|
|
||||||
const [folded, setFolded] = useState(false);
|
|
||||||
const content = discussion.content;
|
|
||||||
const active = discussion.status === 'active';
|
|
||||||
const offered = discussion.status === 'offered';
|
|
||||||
const collapsed = folded || (!active && !offered);
|
|
||||||
const Icon = content.kind === 'ideas' ? Puzzle : content.kind === 'structure' ? Layers3 : content.kind === 'flow' ? GitBranch : Layers3;
|
|
||||||
const select = (id: string, label: string) => { if (active && !disabled) onFocus(id, label); };
|
|
||||||
const tree = (parentId?: string): React.ReactNode => content.kind === 'structure' && content.nodes.filter(node => node.parentId === parentId).map((node: TeacherStructureNode) => (
|
|
||||||
<li key={node.id}>
|
|
||||||
<button type="button" disabled={!active || disabled} aria-pressed={focusId === node.id} className="discussion-node" onClick={() => select(node.id, node.label)}>
|
|
||||||
{node.relation && <span className="discussion-relation">{node.relation}</span>}<span>{node.label}</span>
|
|
||||||
</button>
|
|
||||||
{content.nodes.some(child => child.parentId === node.id) && <ul>{tree(node.id)}</ul>}
|
|
||||||
</li>
|
|
||||||
));
|
|
||||||
return <section className="teacher-discussion" data-testid="teacher-discussion" data-kind={content.kind} data-collapsed={collapsed} aria-label="一起整理的内容">
|
|
||||||
<header className="discussion-heading">
|
|
||||||
<span className="discussion-emblem"><Icon size={17} aria-hidden="true" /></span>
|
|
||||||
<div><span className="discussion-eyebrow">{offered ? '要不要一起理一理?' : active ? '我们正在一起想' : discussion.status === 'finished' ? '先留在这里' : '想法还在,慢慢聊'}</span><h3 title={content.title}>{content.title}</h3></div>
|
|
||||||
{(active || offered) && <button type="button" className="discussion-icon" aria-label={collapsed ? '展开整理内容' : '收起整理内容'} aria-expanded={!collapsed} onClick={() => setFolded(!folded)}>{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}</button>}
|
|
||||||
{!active && !offered && <button type="button" className="discussion-resume" disabled={disabled} onClick={() => { setFolded(false); onAction('resume'); }}>接着改</button>}
|
|
||||||
</header>
|
|
||||||
{!collapsed && <>
|
|
||||||
<div className="discussion-content" tabIndex={0} aria-label="整理内容,可滚动查看">
|
|
||||||
{content.kind === 'ideas' && <div className="discussion-ideas">
|
|
||||||
{(['kept', 'suggested', 'aside'] as const).map(state => {
|
|
||||||
const items = content.items.filter(item => item.state === state);
|
|
||||||
return items.length > 0 && <div key={state} className={`discussion-idea-group discussion-${state}`}>
|
|
||||||
<p className="discussion-group-label">{state === 'kept' ? '我想留下的' : state === 'suggested' ? '老师想到的,你来决定' : '暂时放一边'}</p>
|
|
||||||
{items.map(item => <div key={item.id} className="discussion-idea" data-selected={focusId === item.id}>
|
|
||||||
{item.parentId && <span className="discussion-relation">关于「{content.items.find(parent => parent.id === item.parentId)?.text}」</span>}
|
|
||||||
<button type="button" className="discussion-idea-text" disabled={!active || disabled} onClick={() => select(item.id, item.text)} aria-pressed={focusId === item.id}>
|
|
||||||
<span className="discussion-piece"><Puzzle size={14} /></span><span>{item.text}</span>
|
|
||||||
</button>
|
|
||||||
{active && <div className="discussion-item-actions">
|
|
||||||
{state !== 'kept' ? <button disabled={disabled} onClick={() => onAction('keep-idea', item.id)}><Check size={12} />留下</button> : <button aria-pressed={content.firstItemId === item.id} disabled={disabled} onClick={() => onAction('first-idea', item.id)}>{content.firstItemId === item.id ? <><Check size={12} />先想这个</> : '从这里开始'}</button>}
|
|
||||||
{state !== 'aside' && <button disabled={disabled} onClick={() => onAction('aside-idea', item.id)}>先放一边</button>}
|
|
||||||
</div>}
|
|
||||||
</div>)}
|
|
||||||
</div>;
|
|
||||||
})}
|
|
||||||
</div>}
|
|
||||||
{content.kind === 'structure' && <ul className="discussion-tree">{tree()}</ul>}
|
|
||||||
{content.kind === 'flow' && <div className="discussion-flow">
|
|
||||||
{content.nodes.map((node, index) => <div key={node.id} className="discussion-flow-step">
|
|
||||||
<button type="button" className="discussion-node" data-node-kind={node.kind} aria-pressed={focusId === node.id} disabled={!active || disabled} onClick={() => select(node.id, node.label)}>
|
|
||||||
<span className="discussion-step-number">{index + 1}</span><span><small>{node.kind === 'event' ? '当这件事发生' : node.kind === 'condition' ? '看看是否满足' : '接下来'}</small>{node.label}</span>
|
|
||||||
</button>
|
|
||||||
{content.edges.filter(edge => edge.from === node.id).map(edge => <button type="button" key={edge.id} className="discussion-edge" disabled={!active || disabled} onClick={() => select(edge.to, content.nodes.find(next => next.id === edge.to)?.label ?? '')}>
|
|
||||||
<ArrowDown size={12} /><span>{edge.label && <b>{edge.label} → </b>}{content.nodes.find(next => next.id === edge.to)?.label}</span><span className="discussion-destination">{content.nodes.findIndex(next => next.id === edge.to) + 1}</span>
|
|
||||||
</button>)}
|
|
||||||
</div>)}
|
|
||||||
</div>}
|
|
||||||
{content.kind === 'comparison' && <div className="discussion-comparison" role="table" aria-label={content.title}>
|
|
||||||
{content.rows.map(row => <div className="discussion-comparison-row" key={row.id} role="rowgroup">
|
|
||||||
<h4>{row.label}</h4><div className="discussion-comparison-cells" style={{ gridTemplateColumns: `repeat(${content.columns.length === 3 ? 3 : 2}, minmax(0, 1fr))` }} role="row">
|
|
||||||
{content.columns.map(column => <div key={column.id} role="cell" className="discussion-comparison-cell" data-selected={focusId === `${row.id}:${column.id}`}>
|
|
||||||
<b>{column.label}</b><p>{row.cells.find(cell => cell.columnId === column.id)?.text}</p>
|
|
||||||
{active && <button type="button" disabled={disabled} onClick={() => onDiscuss(`${row.id}:${column.id}`, `聊聊「${row.label}」时,「${column.label}」这一点:${row.cells.find(cell => cell.columnId === column.id)?.text ?? ''}`)}><MessageCircle size={12} />聊聊这一点</button>}
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
</div>}
|
|
||||||
</div>
|
|
||||||
<footer className="discussion-footer">
|
|
||||||
{offered ? <><button type="button" className="discussion-primary" disabled={disabled} onClick={() => onAction('enter')}><Sparkles size={13} />用这个一起想</button><button type="button" disabled={disabled} onClick={() => onAction('pause')}>先不用</button></> : <>
|
|
||||||
{content.kind === 'ideas' && <button type="button" className="discussion-next" disabled={disabled || !content.items.some(item => item.state === 'kept')} onClick={onStructure}>把想法理一理<ArrowRight size={13} /></button>}
|
|
||||||
{content.kind === 'structure' && discussion.previousIdeas && <button type="button" className="discussion-next" disabled={disabled} onClick={() => onAction('back-ideas')}>回去补充想法</button>}
|
|
||||||
<div className="discussion-footer-actions"><button type="button" disabled={disabled} onClick={() => onAction('pause')}>只聊天</button><button type="button" className="discussion-primary" disabled={disabled} onClick={() => onAction('finish')}><Check size={13} />先这些</button></div>
|
|
||||||
</>}
|
|
||||||
</footer>
|
|
||||||
</>}
|
|
||||||
</section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DiscussionFocus({ label, onClear }: { label: string; onClear(): void }) {
|
|
||||||
return <div className="discussion-focus"><MessageCircle size={13} /><span>接着聊:{label}</span><button type="button" aria-label="取消讨论焦点" onClick={onClear}><X size={13} /></button></div>;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
.teacher-discussion { display:flex; flex-direction:column; flex-shrink:0; min-height:0; max-height:46%; margin:12px 12px 0; border:1px solid #d6e2cf; border-radius:16px; background:#fff; overflow:hidden; box-shadow:0 3px 0 #dce7d780; font-size:12px; color:#3b4934; }
|
|
||||||
.teacher-discussion[data-kind="structure"] { border-color:#d6d7f1; }
|
|
||||||
.teacher-discussion[data-kind="flow"] { border-color:#c8e3ee; }
|
|
||||||
.teacher-discussion[data-kind="comparison"] { border-color:#efdcad; }
|
|
||||||
.teacher-discussion button { cursor:pointer; transition:background 150ms, border-color 150ms; }
|
|
||||||
.teacher-discussion button:disabled { cursor:default; opacity:.65; }
|
|
||||||
.teacher-discussion button:focus-visible { outline:2px solid #607c45; outline-offset:-2px; }
|
|
||||||
.discussion-heading { display:flex; align-items:center; gap:8px; flex-shrink:0; padding:11px 12px; background:#f1f6e9; }
|
|
||||||
[data-kind="structure"] .discussion-heading { background:#f0eefa; }
|
|
||||||
[data-kind="flow"] .discussion-heading { background:#eaf5fb; }
|
|
||||||
[data-kind="comparison"] .discussion-heading { background:#fff5d9; }
|
|
||||||
.discussion-heading>div { flex:1; min-width:0; }
|
|
||||||
.discussion-heading h3 { margin:3px 0 0; font-weight:650; font-size:13px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; overflow-wrap:anywhere; line-height:1.5; }
|
|
||||||
.discussion-eyebrow { color:#667158; font-size:10px; }
|
|
||||||
.discussion-emblem { display:grid; place-items:center; flex-shrink:0; width:30px; height:30px; border:1px solid #d8e2ce; border-radius:9px; background:#fff; }
|
|
||||||
.discussion-icon { display:grid; place-items:center; width:28px; height:32px; flex-shrink:0; }
|
|
||||||
.discussion-content { overflow-y:auto; overflow-x:hidden; min-height:0; padding:12px; overscroll-behavior:contain; scrollbar-width:thin; }
|
|
||||||
.discussion-group-label { font-size:10px; font-weight:600; color:#69715f; margin:0 0 7px; }
|
|
||||||
.discussion-idea-group+.discussion-idea-group { margin-top:14px; }
|
|
||||||
.discussion-idea { border:1px solid #dae5cd; border-radius:10px; background:#f4f8e9; padding:8px; margin-top:6px; }
|
|
||||||
.discussion-suggested .discussion-idea { background:#fff6dd; border-color:#eedbaa; border-style:dashed; }
|
|
||||||
.discussion-aside .discussion-idea { background:#f4f3f1; border-color:#e2e0d9; color:#6d7166; }
|
|
||||||
.discussion-idea[data-selected="true"], .discussion-node[aria-pressed="true"], .discussion-comparison-cell[data-selected="true"] { outline:2px solid #768954; outline-offset:-2px; }
|
|
||||||
.discussion-idea-text { display:flex; align-items:flex-start; gap:7px; text-align:left; width:100%; line-height:1.65; overflow-wrap:anywhere; }
|
|
||||||
.discussion-piece { padding-top:3px; flex-shrink:0; color:#849464; }
|
|
||||||
.discussion-item-actions { display:flex; justify-content:flex-end; gap:13px; flex-wrap:wrap; margin-top:7px; }
|
|
||||||
.discussion-item-actions button { display:flex; align-items:center; gap:3px; min-height:28px; font-size:10px; color:#596546; }
|
|
||||||
.discussion-item-actions button:hover:not(:disabled) { text-decoration:underline; }
|
|
||||||
.discussion-item-actions button[aria-pressed="true"] { font-weight:700; }
|
|
||||||
.discussion-relation { display:block; font-size:10px; color:#777a6b; margin:0 0 4px; overflow-wrap:anywhere; }
|
|
||||||
.discussion-tree, .discussion-tree ul { list-style:none; padding:0; margin:0; }
|
|
||||||
.discussion-tree ul { margin-left:10px; padding-left:10px; border-left:2px solid #dbd7ed; }
|
|
||||||
.discussion-tree li { margin-top:7px; }
|
|
||||||
.discussion-node { width:100%; border:1px solid #e1dcf2; border-radius:9px; background:#f5f2ff; padding:9px 10px; text-align:left; line-height:1.6; overflow-wrap:anywhere; }
|
|
||||||
.discussion-node:hover:not(:disabled) { background:#ece8fa; }
|
|
||||||
.discussion-flow-step+.discussion-flow-step { margin-top:10px; }
|
|
||||||
.discussion-flow .discussion-node { display:flex; gap:8px; background:#eff8fb; border-color:#cbe3eb; }
|
|
||||||
.discussion-flow .discussion-node[data-node-kind="condition"] { background:#fff7df; border-color:#efdcaa; }
|
|
||||||
.discussion-flow .discussion-node[data-node-kind="outcome"] { background:#edf5e7; border-color:#d4e3c7; }
|
|
||||||
.discussion-node small { display:block; font-size:9px; opacity:.8; }
|
|
||||||
.discussion-step-number,.discussion-destination { display:grid; place-items:center; flex-shrink:0; width:20px; height:20px; border-radius:6px; background:#fff; border:1px solid #d6dfd0; font-size:10px; }
|
|
||||||
.discussion-edge { display:flex; align-items:center; gap:5px; width:100%; padding:7px 4px 0 12px; text-align:left; font-size:10px; line-height:1.5; color:#627160; }
|
|
||||||
.discussion-edge>span:first-of-type { flex:1; min-width:0; overflow-wrap:anywhere; }
|
|
||||||
.discussion-edge svg { flex-shrink:0; }
|
|
||||||
.discussion-edge b { font-weight:600; }
|
|
||||||
.discussion-comparison-row+.discussion-comparison-row { margin-top:12px; }
|
|
||||||
.discussion-comparison-row h4 { font-weight:600; font-size:11px; margin:0 0 7px; }
|
|
||||||
.discussion-comparison-cells { display:grid; gap:6px; }
|
|
||||||
.discussion-comparison-cell { min-width:0; display:flex; flex-direction:column; padding:9px 8px; background:#f4f2fc; border:1px solid #e1dcf1; border-radius:10px; overflow-wrap:anywhere; }
|
|
||||||
.discussion-comparison-cell:nth-child(even) { background:#eef6e8; border-color:#dae6cc; }
|
|
||||||
.discussion-comparison-cell>b { font-size:10px; }
|
|
||||||
.discussion-comparison-cell p { flex:1; line-height:1.7; font-size:11px; margin:7px 0 8px; white-space:pre-wrap; }
|
|
||||||
.discussion-comparison-cell button { display:flex; align-items:center; flex-wrap:wrap; gap:4px; font-size:10px; min-height:30px; color:#506144; text-align:left; }
|
|
||||||
.discussion-footer { flex-shrink:0; padding:8px 11px; border-top:1px solid #eef0e8; display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap; }
|
|
||||||
.discussion-footer button,.discussion-resume { display:inline-flex; align-items:center; justify-content:center; gap:4px; min-height:32px; padding:5px 7px; font-size:11px; border-radius:8px; }
|
|
||||||
.discussion-footer-actions { display:flex; align-items:center; gap:7px; justify-content:flex-end; width:100%; }
|
|
||||||
.discussion-footer .discussion-primary { background:#526e3c; color:#fff; padding:6px 12px; }
|
|
||||||
.discussion-footer .discussion-primary:hover:not(:disabled) { background:#405a2b; }
|
|
||||||
.discussion-next { color:#5a6650; }
|
|
||||||
.discussion-next:hover:not(:disabled), .discussion-resume:hover:not(:disabled) { background:#eaf0df; }
|
|
||||||
.discussion-resume { flex-shrink:0; background:white; border:1px solid #dce3d1; }
|
|
||||||
.discussion-focus { display:flex; align-items:center; gap:6px; padding:7px 9px; margin-bottom:6px; border:1px solid #dedfcd; background:#f4f5e8; border-radius:9px; font-size:11px; }
|
|
||||||
.discussion-focus>span { min-width:0; flex:1; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
||||||
.discussion-focus button { display:grid; place-items:center; width:26px; height:26px; cursor:pointer; }
|
|
||||||
.consultation-pane>form { flex-shrink:0; }
|
|
||||||
.teacher-discussion~.consultation-messages { padding-top:16px; padding-bottom:8px; }
|
|
||||||
@media (max-height:650px) { .teacher-discussion { max-height:40%; } .discussion-heading { padding:7px 10px; } }
|
|
||||||
@media (prefers-reduced-motion:reduce) { .teacher-discussion button { transition:none; } }
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Deterministic responses for layout/interaction tests only; never imported by the app.
|
// Deterministic responses for layout/interaction tests only; never imported by the app.
|
||||||
import type { TeacherDefinition, TeacherTopic, TeacherSend, TeacherDiscussionAction } from '../../../shared/coding-teacher';
|
import type { TeacherDefinition, TeacherTopic, TeacherSend } from '../../../shared/coding-teacher';
|
||||||
import type { TeacherDiscussionContent } from '../../../shared/teacher-discussion';
|
import type { TeacherDiscussionContent } from '../../../shared/teacher-discussion';
|
||||||
const definition: TeacherDefinition = { schema_version: 1, teacher_id: 'coding-teacher', name: '麦洛智能体', description: '', avatar_id: 'avatar-01', welcome_message: '', suggested_questions: [], system_prompt: '测试', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } };
|
const definition: TeacherDefinition = { schema_version: 1, teacher_id: 'coding-teacher', name: '麦洛智能体', description: '', avatar_id: 'avatar-01', welcome_message: '', suggested_questions: [], system_prompt: '测试', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } };
|
||||||
const samples: Record<string, TeacherDiscussionContent> = {
|
const samples: Record<string, TeacherDiscussionContent> = {
|
||||||
@@ -28,42 +28,14 @@ export const teacherApi = {
|
|||||||
read: async () => structuredClone(current),
|
read: async () => structuredClone(current),
|
||||||
create: async () => structuredClone(current),
|
create: async () => structuredClone(current),
|
||||||
events: async () => stream,
|
events: async () => stream,
|
||||||
updateDiscussion: async (_base: string, _id: string, input: TeacherDiscussionAction) => {
|
|
||||||
const tool = current.discussion!;
|
|
||||||
if (input.action === 'pause' || input.action === 'finish') tool.status = input.action === 'pause' ? 'paused' : 'finished';
|
|
||||||
else tool.status = 'active';
|
|
||||||
if (input.action === 'back-ideas' && tool.previousIdeas) {
|
|
||||||
if (tool.content.kind === 'structure') tool.previousStructure = structuredClone(tool.content);
|
|
||||||
tool.content = structuredClone(tool.previousIdeas);
|
|
||||||
}
|
|
||||||
if (tool.content.kind === 'ideas') {
|
|
||||||
const item = tool.content.items.find(item => item.id === input.itemId);
|
|
||||||
if (item && input.action === 'keep-idea') item.state = 'kept';
|
|
||||||
if (item && input.action === 'aside-idea') item.state = 'aside';
|
|
||||||
if (item && input.action === 'first-idea') tool.content.firstItemId = item.id;
|
|
||||||
}
|
|
||||||
tool.revision++;
|
|
||||||
snapshot();
|
|
||||||
return structuredClone(current);
|
|
||||||
},
|
|
||||||
send: async (_base: string, _id: string, input: TeacherSend) => {
|
send: async (_base: string, _id: string, input: TeacherSend) => {
|
||||||
|
if (input.presentation || input.discussion) throw new Error('New chat requests must not ask for components');
|
||||||
current.requests.push({ id: input.requestId, text: input.text, references: [], createdAt: 'now', status: 'running', sourceCursor: { workerGeneration: 0, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, response: '', discussionContext: input.discussion });
|
current.requests.push({ id: input.requestId, text: input.text, references: [], createdAt: 'now', status: 'running', sourceCursor: { workerGeneration: 0, seq: 1 }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, response: '', discussionContext: input.discussion });
|
||||||
snapshot();
|
snapshot();
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
const request = current.requests.at(-1)!;
|
const request = current.requests.at(-1)!;
|
||||||
request.status = 'completed';
|
request.status = 'completed';
|
||||||
request.response = '好,已经把你的补充放在上面了。我们可以接着想。';
|
request.response = '好,我们可以接着聊。';
|
||||||
const tool = current.discussion;
|
|
||||||
if (tool?.status === 'active' && input.discussion) {
|
|
||||||
if (input.discussion.transition === 'structure' && tool.content.kind === 'ideas') {
|
|
||||||
tool.previousIdeas = structuredClone(tool.content);
|
|
||||||
tool.content = structuredClone(samples.structure);
|
|
||||||
} else if (tool.content.kind === 'ideas') tool.content.items.push({ id: `new-${tool.revision}`, text: input.text, state: 'kept' });
|
|
||||||
else if (tool.content.kind === 'structure') tool.content.nodes.push({ id: `new-${tool.revision}`, label: input.text, parentId: 'world' });
|
|
||||||
else if (tool.content.kind === 'flow') tool.content.nodes.push({ id: `new-${tool.revision}`, label: input.text, kind: 'outcome' });
|
|
||||||
else tool.content.rows.push({ id: `new-${tool.revision}`, label: '再想想这一点', cells: tool.content.columns.map(col => ({ columnId: col.id, text: '先观察实际玩的时候有什么不同。' })) });
|
|
||||||
tool.revision++;
|
|
||||||
}
|
|
||||||
snapshot();
|
snapshot();
|
||||||
}, 350);
|
}, 350);
|
||||||
return structuredClone(current);
|
return structuredClone(current);
|
||||||
@@ -73,3 +45,5 @@ export const teacherApi = {
|
|||||||
export const teacherTopicsPath = () => '/fixture';
|
export const teacherTopicsPath = () => '/fixture';
|
||||||
|
|
||||||
export const legacyTopicBase = (base: string) => base;
|
export const legacyTopicBase = (base: string) => base;
|
||||||
|
|
||||||
|
export const teacherConversationsPath = () => '/fixture/conversations';
|
||||||
@@ -1879,6 +1879,12 @@ test('project consultations preserve student drafts and switch between work and
|
|||||||
await expect(composer).toHaveValue('保留我的草稿');
|
await expect(composer).toHaveValue('保留我的草稿');
|
||||||
await teacher.getByRole('button',{name:'我也说不清,你带我看看',exact:true}).click();
|
await teacher.getByRole('button',{name:'我也说不清,你带我看看',exact:true}).click();
|
||||||
await expect(teacher.getByText('你最近做的哪一步,让你停下来想了一会儿?')).toBeVisible();
|
await expect(teacher.getByText('你最近做的哪一步,让你停下来想了一会儿?')).toBeVisible();
|
||||||
|
await expect(teacher.getByTestId('teacher-discussion')).toHaveCount(0);
|
||||||
|
const consultationSends = (await readState(electronApp)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST');
|
||||||
|
for (const sent of consultationSends) {
|
||||||
|
expect(sent.body).not.toHaveProperty('presentation');
|
||||||
|
expect(sent.body).not.toHaveProperty('discussion');
|
||||||
|
}
|
||||||
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
|
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
|
||||||
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
|
await page.getByRole('tab',{name:'操作对话',exact:true}).click();
|
||||||
await expect(composer).toBeVisible();
|
await expect(composer).toBeVisible();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ test.beforeAll(async () => {
|
|||||||
server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-teacher-discussion', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0 }, plugins: [{
|
server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-teacher-discussion', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0 }, plugins: [{
|
||||||
name: 'isolated-discussion-test', enforce: 'pre',
|
name: 'isolated-discussion-test', enforce: 'pre',
|
||||||
load(id) {
|
load(id) {
|
||||||
if (id.endsWith('/src/lib/coding-teacher.ts')) return "export { teacherApi, teacherTopicsPath, legacyTopicBase } from '/tests/e2e/fixtures/teacher-discussion-api.ts';";
|
if (id.endsWith('/src/lib/coding-teacher.ts')) return "export { teacherApi, teacherTopicsPath, teacherConversationsPath, legacyTopicBase } from '/tests/e2e/fixtures/teacher-discussion-api.ts';";
|
||||||
if (id.endsWith('/src/stores/auth.ts')) return 'export const useAuthStore = selector => selector({user:{userId:"fixture"}});';
|
if (id.endsWith('/src/stores/auth.ts')) return 'export const useAuthStore = selector => selector({user:{userId:"fixture"}});';
|
||||||
},
|
},
|
||||||
configureServer(vite) { vite.middlewares.use((req, res, next) => {
|
configureServer(vite) { vite.middlewares.use((req, res, next) => {
|
||||||
@@ -49,7 +49,7 @@ for (const width of [319, 508]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'structure', 'flow', 'comparison']) {
|
for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'structure', 'flow', 'comparison']) {
|
||||||
test(`${kind} pins the current tool and composer at ${width}px`, async ({ page }) => {
|
test(`${kind} history uses the message pane without discussion cards at ${width}px`, async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1180, height: 800 });
|
await page.setViewportSize({ width: 1180, height: 800 });
|
||||||
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
|
const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
|
||||||
await page.addInitScript(() => localStorage.setItem(
|
await page.addInitScript(() => localStorage.setItem(
|
||||||
@@ -60,43 +60,23 @@ for (const width of [319, 508]) for (const kind of ['ordinary', 'ideas', 'struct
|
|||||||
await expect(input).toBeEnabled();
|
await expect(input).toBeEnabled();
|
||||||
await expect(page.locator('form')).not.toHaveText(/^0/);
|
await expect(page.locator('form')).not.toHaveText(/^0/);
|
||||||
await expect(page.getByText('查看旧版朋友草稿')).toHaveCount(0);
|
await expect(page.getByText('查看旧版朋友草稿')).toHaveCount(0);
|
||||||
const tool = page.getByTestId('teacher-discussion');
|
await expect(page.getByTestId('teacher-discussion')).toHaveCount(0);
|
||||||
if (kind === 'ordinary') {
|
|
||||||
await expect(tool).toHaveCount(0);
|
|
||||||
await input.fill('保留我的草稿');
|
|
||||||
await page.getByRole('button', { name: '有什么好玩的想法?' }).click();
|
|
||||||
await expect(page.getByText('好,已经把你的补充放在上面了。我们可以接着想。')).toBeVisible();
|
|
||||||
await expect(input).toHaveValue('保留我的草稿');
|
|
||||||
} else {
|
|
||||||
await expect(tool).toHaveAttribute('data-kind', kind);
|
|
||||||
const before = await tool.boundingBox();
|
|
||||||
const composer = await input.boundingBox();
|
const composer = await input.boundingBox();
|
||||||
await page.locator('.consultation-messages').hover();
|
await page.locator('.consultation-messages').hover();
|
||||||
await page.mouse.wheel(0, -1500);
|
await page.mouse.wheel(0, -1500);
|
||||||
await expect.poll(async () => (await tool.boundingBox())?.y).toBe(before!.y);
|
|
||||||
expect((await input.boundingBox())?.y).toBe(composer!.y);
|
expect((await input.boundingBox())?.y).toBe(composer!.y);
|
||||||
expect(await tool.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
|
||||||
expect(composer!.y + composer!.height).toBeLessThan(800);
|
expect(composer!.y + composer!.height).toBeLessThan(800);
|
||||||
|
if (kind === 'ordinary') {
|
||||||
|
await input.fill('保留我的草稿');
|
||||||
|
await page.getByRole('button', { name: '有什么好玩的想法?' }).click();
|
||||||
|
await expect(input).toHaveValue('保留我的草稿');
|
||||||
|
} else {
|
||||||
await input.fill('它还喜欢和我踢球');
|
await input.fill('它还喜欢和我踢球');
|
||||||
await page.getByRole('button', { name: '提问', exact: true }).click();
|
await page.getByRole('button', { name: '提问', exact: true }).click();
|
||||||
await expect(page.getByRole('status')).toContainText('正在梳理');
|
|
||||||
await expect(page.getByText('好,已经把你的补充放在上面了。我们可以接着想。')).toBeVisible();
|
|
||||||
await expect(tool).toHaveCount(1);
|
|
||||||
await expect(tool).toHaveAttribute('data-kind', kind);
|
|
||||||
if (kind === 'ideas') {
|
|
||||||
await page.getByRole('button', { name: '把想法理一理' }).click();
|
|
||||||
await expect(tool).toHaveAttribute('data-kind', 'structure');
|
|
||||||
await page.getByRole('button', { name: '回去补充想法' }).click();
|
|
||||||
await expect(tool).toHaveAttribute('data-kind', 'ideas');
|
|
||||||
await expect(tool.getByText('它还喜欢和我踢球', { exact: true })).toHaveCount(1);
|
|
||||||
}
|
|
||||||
await input.fill('先留下下一句');
|
|
||||||
await page.getByRole('button', { name: '先这些', exact: true }).click();
|
|
||||||
await expect(tool).toHaveAttribute('data-collapsed', 'true');
|
|
||||||
await expect(input).toHaveValue('先留下下一句');
|
|
||||||
await page.getByRole('button', { name: '接着改', exact: true }).click();
|
|
||||||
await expect(tool).toHaveAttribute('data-collapsed', 'false');
|
|
||||||
}
|
}
|
||||||
|
await expect(page.getByText('好,我们可以接着聊。')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('teacher-discussion')).toHaveCount(0);
|
||||||
|
await expect.poll(() => page.locator('.consultation-messages').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||||
await page.screenshot({ path: test.info().outputPath(`${kind}-${width}.png`) });
|
await page.screenshot({ path: test.info().outputPath(`${kind}-${width}.png`) });
|
||||||
expect(errors).toEqual([]);
|
expect(errors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -568,75 +568,66 @@ describe('teacher side chat', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('pinned discussion workspace', () => {
|
describe('conversation without discussion components', () => {
|
||||||
const ideas = { id: 'tool', revision: 1, status: 'active' as const, content: {
|
const contents = [
|
||||||
kind: 'ideas' as const, title: '一起想我的宠物游戏', items: [{ id: 'dog', text: '养只小狗', state: 'kept' as const }],
|
{ kind: 'ideas' as const, title: '旧想法板', items: [{ id: 'dog', text: '养只小狗', state: 'kept' as const }] },
|
||||||
} };
|
{ kind: 'structure' as const, title: '旧结构图', nodes: [{ id: 'dog', label: '照顾小狗' }] },
|
||||||
async function openDiscussion(status: 'active' | 'offered' = 'active') {
|
{ kind: 'flow' as const, title: '旧流程图', nodes: [{ id: 'home', kind: 'event' as const, label: '回家' }], edges: [] },
|
||||||
const current = { ...structuredClone(first), discussion: { ...structuredClone(ideas), status } };
|
{ kind: 'comparison' as const, title: '旧对照表', columns: [{ id: 'a', label: '主动回应' }, { id: 'b', label: '点击回应' }], rows: [] },
|
||||||
|
];
|
||||||
|
const discussion = { id: 'tool', revision: 1, status: 'active' as const, content: contents[0] };
|
||||||
|
|
||||||
|
it.each(contents)('keeps $kind history readable without rendering a component or sending its context', async (content) => {
|
||||||
|
const current = { ...structuredClone(first), discussion: { ...discussion, content },
|
||||||
|
requests: [request({ response: '已有的普通回答', suggestedQuestions: ['继续聊聊?'] })] };
|
||||||
|
api.read.mockResolvedValue(current);
|
||||||
|
api.send.mockResolvedValue({ ...current, revision: 2 });
|
||||||
|
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||||
|
await screen.findByText('已有的普通回答');
|
||||||
|
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(content.title)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '继续聊聊?' })).toBeVisible();
|
||||||
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '普通问题' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||||
|
await waitFor(() => expect(api.send).toHaveBeenCalled());
|
||||||
|
const input = api.send.mock.calls[0][2];
|
||||||
|
expect(input.text).toBe('普通问题');
|
||||||
|
expect(input).not.toHaveProperty('presentation');
|
||||||
|
expect(input).not.toHaveProperty('discussion');
|
||||||
|
expect(api.updateDiscussion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restore offered components from live snapshots and still stops a reply without losing the draft', async () => {
|
||||||
|
const current = { ...structuredClone(first), discussion: { ...discussion, status: 'offered' as const } };
|
||||||
api.read.mockResolvedValue(current);
|
api.read.mockResolvedValue(current);
|
||||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||||
await screen.findByText('一起想我的宠物游戏');
|
await ready();
|
||||||
await waitFor(() => expect(streams.has('first')).toBe(true));
|
const updating = { ...current, revision: 2, requests: [request({ status: 'running', discussionContext: { toolId: 'tool', revision: 1 } })] };
|
||||||
return current;
|
act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(updating) })));
|
||||||
}
|
expect(screen.getByRole('status')).toHaveTextContent('智能体正在想');
|
||||||
it('quotes a focused item into the existing composer without sending, then sends the locked version', async () => {
|
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
|
||||||
const current = await openDiscussion();
|
expect(screen.queryByRole('button', { name: '用这个一起想' })).not.toBeInTheDocument();
|
||||||
api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 2, requests: [request({ text: input.text, response: '先聊聊小狗。' })] }));
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '养只小狗', exact: true }));
|
|
||||||
expect(api.send).not.toHaveBeenCalled();
|
|
||||||
expect(screen.getByText('接着聊:养只小狗')).toBeVisible();
|
|
||||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我希望是柯基' } });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
|
||||||
await waitFor(() => expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({ presentation: 'discussion-v1', discussion: { toolId: 'tool', revision: 1, focusId: 'dog' }, text: '我希望是柯基' })));
|
|
||||||
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it('finishes provisionally, keeps the draft, chats without changing the tool, and resumes explicitly', async () => {
|
|
||||||
const current = await openDiscussion();
|
|
||||||
api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 2, discussion: { ...ideas, status: 'finished', revision: 2 } });
|
|
||||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '还在想的下一句' } });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '先这些', exact: true }));
|
|
||||||
await screen.findByText('先留在这里');
|
|
||||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('还在想的下一句');
|
|
||||||
expect(screen.getByTestId('teacher-discussion')).toHaveAttribute('data-collapsed', 'true');
|
|
||||||
api.send.mockImplementation(async (_base, _id, input) => ({ ...current, revision: 3, discussion: { ...ideas, status: 'finished', revision: 2 }, requests: [request({ text: input.text, response: '我们慢慢聊。' })] }));
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
|
||||||
await screen.findByText('我们慢慢聊。');
|
|
||||||
expect(api.send.mock.calls[0][2].discussion).toBeUndefined();
|
|
||||||
api.updateDiscussion.mockResolvedValueOnce({ ...current, revision: 4, discussion: { ...ideas, revision: 3 } });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '接着改', exact: true }));
|
|
||||||
await screen.findByRole('button', { name: '养只小狗', exact: true });
|
|
||||||
expect(api.updateDiscussion.mock.calls[1][2]).toEqual({ toolId: 'tool', revision: 2, action: 'resume' });
|
|
||||||
});
|
|
||||||
it('offers a tool before activating, and preserves a draft during explicit structure conversion', async () => {
|
|
||||||
const current = await openDiscussion('offered');
|
|
||||||
api.updateDiscussion.mockResolvedValue({ ...current, revision: 2, discussion: { ...ideas, revision: 2 } });
|
|
||||||
expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeDisabled();
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '用这个一起想', exact: true }));
|
|
||||||
await screen.findByRole('button', { name: '把想法理一理', exact: true });
|
|
||||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '不要丢掉的草稿' } });
|
|
||||||
api.send.mockResolvedValue({ ...current, revision: 3, discussion: { ...ideas, revision: 2 }, requests: [] });
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '把想法理一理', exact: true }));
|
|
||||||
await waitFor(() => expect(api.send).toHaveBeenCalled());
|
|
||||||
expect(api.send.mock.calls[0][2].discussion).toEqual({ toolId: 'tool', revision: 2, transition: 'structure' });
|
|
||||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('不要丢掉的草稿');
|
|
||||||
});
|
|
||||||
it('uses the same pinned component for SSE updates and leaves it untouched while thinking or stopping', async () => {
|
|
||||||
const current = await openDiscussion();
|
|
||||||
const stream = streams.get('first')!;
|
|
||||||
const updating = { ...current, revision: 2, requests: [request({ status: 'running', response: '', discussionContext: { toolId: 'tool', revision: 1 } })] };
|
|
||||||
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(updating) })));
|
|
||||||
expect(screen.getByRole('status')).toHaveTextContent('正在梳理');
|
|
||||||
expect(screen.getByRole('button', { name: '养只小狗', exact: true })).toBeVisible();
|
|
||||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '下一轮草稿' } });
|
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '下一轮草稿' } });
|
||||||
api.cancel.mockResolvedValue({ ...updating, revision: 3, requests: [request({ status: 'cancelled' })] });
|
api.cancel.mockResolvedValue({ ...updating, revision: 3, requests: [request({ status: 'cancelled' })] });
|
||||||
fireEvent.click(screen.getByRole('button', { name: '停止回复' }));
|
fireEvent.click(screen.getByRole('button', { name: '停止回复' }));
|
||||||
await screen.findByText('这次先停在这里。想继续时可以再说说。');
|
await screen.findByText('这次先停在这里。想继续时可以再说说。');
|
||||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('下一轮草稿');
|
expect(screen.getByLabelText('向智能体提问')).toHaveValue('下一轮草稿');
|
||||||
act(() => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...current, revision: 4, discussion: { ...ideas, revision: 2, content: { ...ideas.content, title: '一起想小狗和花园' } } }) })));
|
|
||||||
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
|
|
||||||
expect(screen.getByText('一起想小狗和花园')).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retries a saved pre-upgrade request verbatim instead of creating a second model call', async () => {
|
||||||
|
const pending: TeacherSend = { requestId: 'old-uncertain-request', text: '结果还没确认的问题', references: [],
|
||||||
|
sourceConversationId: 'c', presentation: 'discussion-v1', discussion: { toolId: 'tool', revision: 1 } };
|
||||||
|
localStorage.setItem('makelore-consultation-draft:signed-out:p:teacher', JSON.stringify({ text: pending.text, references: [], pending }));
|
||||||
|
api.read.mockResolvedValue({ ...structuredClone(first), discussion });
|
||||||
|
api.send.mockResolvedValue({ ...structuredClone(first), revision: 2, requests: [request({ id: pending.requestId, response: '收到原来的回答' })] });
|
||||||
|
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||||
|
await ready();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||||
|
await screen.findByText('收到原来的回答');
|
||||||
|
expect(api.send).toHaveBeenCalledExactlyOnceWith('p/agent-topics', 'first', pending);
|
||||||
|
expect(screen.queryByTestId('teacher-discussion')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('sends a normal suggested question directly without changing the composer draft', async () => {
|
it('sends a normal suggested question directly without changing the composer draft', async () => {
|
||||||
const current = { ...first, requests: [request({ response: '先听听你的想法。', suggestedQuestions: ['怎样知道小狗喜欢我?'] })] };
|
const current = { ...first, requests: [request({ response: '先听听你的想法。', suggestedQuestions: ['怎样知道小狗喜欢我?'] })] };
|
||||||
api.read.mockResolvedValue(current);
|
api.read.mockResolvedValue(current);
|
||||||
@@ -653,23 +644,6 @@ describe('pinned discussion workspace', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
it('comparison focuses and directly sends the selected difference while preserving the existing draft', async () => {
|
|
||||||
const current = { ...first, discussion: { id: 'compare', revision: 3, status: 'active' as const, content: {
|
|
||||||
kind: 'comparison' as const, title: '它什么时候回应我?', columns: [{ id: 'a', label: '点击才回应' }, { id: 'b', label: '主动回应' }], rows: [{ id: 'home', label: '回到家时', cells: [{ columnId: 'a', text: '等我点击' }, { columnId: 'b', text: '跑来欢迎我' }] }],
|
|
||||||
} } };
|
|
||||||
api.read.mockResolvedValue(current);
|
|
||||||
api.send.mockResolvedValue({ ...current, revision: 2 });
|
|
||||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
|
||||||
await screen.findByText('回到家时');
|
|
||||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '自己还没说完的话' } });
|
|
||||||
fireEvent.click(screen.getAllByRole('button', { name: '聊聊这一点' })[1]);
|
|
||||||
await waitFor(() => expect(api.send).toHaveBeenCalled());
|
|
||||||
expect(api.send.mock.calls[0][2]).toMatchObject({ text: '聊聊「回到家时」时,「主动回应」这一点:跑来欢迎我', discussion: { toolId: 'compare', revision: 3, focusId: 'home:b' } });
|
|
||||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('自己还没说完的话');
|
|
||||||
expect(screen.getAllByTestId('teacher-discussion')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
describe('merged cloud teacher classroom', () => {
|
describe('merged cloud teacher classroom', () => {
|
||||||
it('keeps an old unassigned project draft readable without copying it to the selected agent', async () => {
|
it('keeps an old unassigned project draft readable without copying it to the selected agent', async () => {
|
||||||
const published = { ...definition, config_id: 'one' };
|
const published = { ...definition, config_id: 'one' };
|
||||||
@@ -710,7 +684,9 @@ describe('merged cloud teacher classroom', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
fireEvent.click(screen.getByRole('button', { name: '提问', exact: true }));
|
||||||
await screen.findByText('先看看玩家会做什么。');
|
await screen.findByText('先看看玩家会做什么。');
|
||||||
expect(api.create).not.toHaveBeenCalled();
|
expect(api.create).not.toHaveBeenCalled();
|
||||||
expect(api.sendConversation).toHaveBeenCalledWith('p', 'algorithm', expect.objectContaining({ projectId: 'p', sourceConversationId: 'c', presentation: 'discussion-v1' }));
|
expect(api.sendConversation).toHaveBeenCalledWith('p', 'algorithm', expect.objectContaining({ projectId: 'p', sourceConversationId: 'c' }));
|
||||||
|
expect(api.sendConversation.mock.calls[0][2]).not.toHaveProperty('presentation');
|
||||||
|
expect(api.sendConversation.mock.calls[0][2]).not.toHaveProperty('discussion');
|
||||||
expect(screen.getByRole('complementary', { name: '算法智能体咨询' })).toBeVisible();
|
expect(screen.getByRole('complementary', { name: '算法智能体咨询' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user