diff --git a/.project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md b/.project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md
new file mode 100644
index 00000000..5b7bbf65
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20260929-community-welcome-c724e5a1.md
@@ -0,0 +1,61 @@
+# Task: Add community inspiration to the creative welcome screen and update trial
+
+## Identity
+
+- Task ID: 20260929-community-welcome-c724e5a1
+- Mode: Feature
+- Branch: codex/20260929-community-welcome-c724e5a1-community-welcome
+- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-community-welcome-c724e5a1
+- Base commit: 4199cadd1e067ce9741d6496196f4d13fed61d12
+- Owner: codex
+- Status: Ready for Integration
+
+## Scope
+
+- Implement a creative empty-state and explicit external Works Square links in the existing temporary application. Latest user correction: do not fetch or surface works from the square, and do not embed the square.
+
+## Intent And Constraints
+
+- User authorizes the previous community design. No subagents, desktop focus changes, cloud publication, paid model calls, formal-app installation or remote push. Preserve project selection, drafts, history and teacher three-round introduction behavior. Public community browsing is explicitly authorized; retired upload/publish and learning flows stay retired.
+
+## Outcome
+
+- Warm welcome heading, existing composer and a compact inspiration invitation share the empty-chat layout. Both the invitation and persistent sidebar button open only https://square.nianxx.cn/#explore in the system browser on explicit click.
+- No public project feed, preview cards, community store or iframe remains in this feature. Existing unrelated Main routes are unchanged. The composer remains mounted across the first send; teacher introductions keep their prior three-successful-operation-round behavior and receive responsive clearance.
+- Preserved the concurrent live help-label overlay (task 20260929-teacher-help-label-83a2f67c): the two buttons and suggestions text remain 帮我整体看🧠. Ported only its three source edits and synchronized existing expectations; peer checkout untouched.
+- Temporary Renderer now loads this checkout's index-SmPpxLeP.js. Existing Main PID 62668 remains running; formal installation, cloud configuration, user history and remote branches unchanged. Kept this worktree for the live trial.
+
+## Verification
+
+- Pinned corepack pnpm: typecheck passed; lint:check has 0 errors and 8 pre-existing warnings; production Renderer/Main/Preload/utility build passed.
+- Final full suite: 2,654 passed, 3 skipped across 265 files including the separate pressure run. Final focused link/composer suite: 33 passed. Help-label preservation suite also passed.
+- Five headless Chrome tests passed: 375/768/1280/1470px layouts, teacher clearance, reduced motion, no square network requests or iframe, click-only external URL, draft/composer preservation and first-send dismissal. Visually inspected desktop and narrow screenshots. Native Electron fixture updated and all 20 cases parse via --list; no headed Electron test or real external-browser launch was performed.
+- Live verification: current asset, sidebar link present, no work cards or iframe, no alert, runtime worker ready/idle, authentication identity/route unchanged, all 13 saved drafts byte-equal. Updates used background loadFile with cache-busting; no app/window activation called. Focus monitor during refresh showed no trial activation.
+- Temporary bundle was missing Info.plist/helper metadata and other resources. Matched local Electron 43.4.0 main/framework SHA-256 hashes, restored 238 missing bundle files and retained the trial bundle identity. Existing conversation hydration recovered; no model prompt was sent. This repaired only temporary bundle files, not repository runtime behavior.
+- Evidence and pre-update manifests/state are in trial community-welcome-20260929/. Project-docs ownership drift and git diff checks passed.
+
+## Follow-ups
+
+- Keep the worktree while the trial references it. Merge/push only when requested.
+
+## Promotion Candidates
+
+- None recorded.
+
+
+## Project Context Loaded
+
+- Feature task `20260929-community-welcome-c724e5a1`; branch/worktree in Identity and base `4199cadd1e067ce9741d6496196f4d13fed61d12` verified by official start/status. App worktree tool returned Not a git repository for the parent chat cwd, so the official skill script created this isolated checkout. Previous trial source remains read-only.
+- Read entry/memory/positioning, own task, current integrated snapshot, decisions, architecture, domain, success criteria, evidence/reflection/commitment indexes and peer task scopes. Existing approved classroom behavior at 4199cad supersedes stale canonical component/welcome wording. Feature ownership prohibits canonical edits.
+- Goal: student-owned creation with a welcoming input and real peer-work inspiration. Main remains the remote data/auth boundary. User approval of public community browsing explicitly covers this new entry despite the earlier general gallery restriction; do not restore publishing, uploads, learning or asset galleries.
+- Peers: prior teacher cleanup/UI task owns the live trial source, formal installation/cloud-agent verification is separately active, and language-skill scope is unfinished. No changes to those checkouts or cloud. New isolated frontend feature uses committed previous UI work as base. No semantic dependency conflict.
+- Evidence: public `/api/projects?limit=100` returns 21 works including runnable apps and videos; existing Main `/api/works/projects` already provides a public safe projection. Root and play-page HEAD responses have no X-Frame-Options/frame-ancestors restriction. Prefer existing public Host API and an isolated, sandboxed community overlay without unmounting the current project.
+- Gate: Passed.
+
+## Final Plan And Scope Correction
+
+1. Keep the approved welcome/composer layout and teacher clearance.
+2. Follow the user's latest correction: only external square links. Remove the proposed feed, recommendation cards, store and in-app viewer completely. Keep project state and drafts mounted and never send a prompt on link clicks.
+3. Verify layout, click behavior, no community requests, drafts, repository checks and the actual temporary Renderer; preserve the concurrent label update and temporary profile.
+
+All three steps completed. Earlier public-feed investigation was feasibility work; no feed fetching or embedded browsing is present in the delivered feature.
diff --git a/README.md b/README.md
index fd4eacfe..8f3d1922 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用
Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列,统一采用横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页:
- `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
- 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。
+ 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话显示童趣欢迎语、输入框和「去作品广场逛逛」入口;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。
- `Makelore Agents|AI 智能体`:配置个人云智能体并试聊,发布后正式对话、接入个人微信或创建应用 API Key。支持流式回复、审批、排队请求、附件与产物、自动任务和活动历史;重新进入时恢复最近智能体与对话,未确认操作可按原输入重试。聊天可提出日程建议,由创建者核对能力、时间和费用后启用。知识库上传、替换及导入本人会话附件后自动解析并建立索引;文档卡片区分排队、解析、索引、可用和失败,持续刷新已加载文档,失败可原地重试;零文本内容不会显示为可用,新文档可用后才移除被替换的旧文档。支持删除知识内容;个人 MCP 凭据、Skills、子智能体可自助管理。支持智能体和会话归档、发布版本比较及恢复为草稿。除知识库 embedding 向量化外,模型调用(包括知识处理触发的对话模型调用)仍按原规则由创建者个人词元点数支付,可按智能体设置每次任务和每日上限,费用按时间、来源、应用分页统计;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限;应用调用者保有自己的内容空间。Main 管理云会话和本机文件,Renderer 通过 Host API 操作。配套服务接入见 [Yuxi MakeLore 说明](https://xerrors.github.io/Yuxi/advanced/makelore-agents.html)。 编辑采用全高桌面分栏,支持拖动调整与展开、独立滚动、小窗口配置/试用切换。首次创建按选用途、说要求、试一试引导,提供科学问答、故事、英语和笔记整理起点;回答方式可多选,特别要求直接编辑,仍以草稿的 system_prompt 保存。新助手预选云端目录的第一个可用模型并明确显示,可自行更换;已有助手不自动换模型。编辑区以“我的要求”和“高级设置”两个 Tab 切换,默认显示基础要求;高级设置包含能力、知识与限制,返回时保留上次分类,切换保留未保存的输入及当前试用。已开始使用的助手在顶部显示“修改已保存,尚未应用”或“已应用最新修改”,并提供显眼的“应用最新修改”按钮;保存只更新草稿,应用沿用同一发布操作,失败重试保留原操作身份。草稿明确保存(Ctrl/⌘+S),保存与切换页面保留当前试用及未发送文字;“保存并重新试用”使用最新保存的要求,保留上次试用供对比,并可复用上次问题,不自动发送。示例问题仅填入输入框;有运行、排队、审批或未确认操作时先处理当前试用。工具确认先展示实际操作说明与参数,完整技术详情可展开。确认“开始使用”会创建仅自己可用的发布版本并进入独立对话,微信渠道与应用接入仍单独配置。费用上限在限制中独立保存。Enter 发送、Shift+Enter 换行,F6 切换面板焦点、Esc 恢复分栏。
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
diff --git a/src/components/community/CommunityInvitation.tsx b/src/components/community/CommunityInvitation.tsx
new file mode 100644
index 00000000..4faf365a
--- /dev/null
+++ b/src/components/community/CommunityInvitation.tsx
@@ -0,0 +1,8 @@
+import { CommunityLink } from './CommunityLink';
+
+export function CommunityInvitation() {
+ return
+ 还没想到?去找找灵感吧 作品广场里,有小伙伴们用麦洛做出的奇思妙想。
+
+ ;
+}
diff --git a/src/components/community/CommunityLink.tsx b/src/components/community/CommunityLink.tsx
new file mode 100644
index 00000000..55c34eca
--- /dev/null
+++ b/src/components/community/CommunityLink.tsx
@@ -0,0 +1,24 @@
+import { useState } from 'react';
+import { ArrowUpRight, Compass } from 'lucide-react';
+import { openCommunity } from '@/lib/community';
+import './community.css';
+
+export function CommunityLink({ variant = 'inline', collapsed = false }: {
+ variant?: 'inline' | 'sidebar'; collapsed?: boolean;
+}) {
+ const [opening, setOpening] = useState(false);
+ const [error, setError] = useState(false);
+ return
+
{
+ setOpening(true); setError(false);
+ void openCommunity().catch(() => setError(true)).finally(() => setOpening(false));
+ }}>
+
+ {!collapsed && <>{variant === 'sidebar' ? '作品广场' : '去作品广场逛逛'} >}
+
+ {error &&
没能打开作品广场,请再点一次试试。 }
+
;
+}
diff --git a/src/components/community/community.css b/src/components/community/community.css
new file mode 100644
index 00000000..0562e3df
--- /dev/null
+++ b/src/components/community/community.css
@@ -0,0 +1,36 @@
+.coding-chat-body { display: flex; flex: 1; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
+.coding-chat-body[hidden] { display: none; }
+.coding-chat-body[data-welcome="true"] { overflow-y: auto; padding: clamp(24px, 4vh, 48px) 24px 28px; background: radial-gradient(ellipse at 45% 18%, #fff8e8 0%, #fffdf8 28%, #fff 67%); }
+.coding-chat-body[data-welcome="true"] .classroom-chat-view { flex: 0 0 auto; }
+.coding-chat-body[data-welcome="true"] .classroom-composer-view { width: 100%; max-width: 740px; margin: 0 auto; }
+.coding-chat-body[data-welcome="true"] [data-testid="coding-message-composer"] { width: 100%; padding: 0 0 28px; }
+.coding-chat-body[data-welcome="true"] .chat-composer-surface { background: #fff; border-color: #d7dfda; border-radius: 20px; box-shadow: 0 5px 0 #eaf0e8, 0 12px 32px #69774b08; }
+.coding-start-heading { position: relative; width: 100%; max-width: 740px; margin: 0 auto; padding: 0 12px 24px; text-align: center; }
+.coding-start-mark { display: inline-grid; position: relative; place-items: center; width: 57px; height: 59px; border-radius: 18px; border: 1px solid #f2d6a7; background: #fff0d7; box-shadow: 0 4px 0 #eeddbd; transform: rotate(-7deg); animation: community-hello 650ms ease-out both; }
+.coding-start-mark img { width: 35px; height: 35px; }
+.coding-start-mark > svg { position: absolute; width: 17px; height: 17px; right: -24px; top: -2px; color: #c78b36; }
+.coding-start-mark > svg:last-child { width: 12px; height: 12px; left: -23px; top: 39px; color: #83a496; }
+.coding-start-eyebrow { margin: 18px 0 7px; color: #7a7769; font-size: 12px; letter-spacing: .08em; }
+.coding-start-heading h1 { color: #304968; font-size: clamp(24px, 2.5vw, 34px); line-height: 1.45; font-weight: 650; letter-spacing: -.04em; }
+.coding-start-heading h1 span { position: relative; white-space: nowrap; background: linear-gradient(transparent 73%, #ffe6a6 73%, #ffe6a6 94%, transparent 94%); }
+.coding-start-caption { margin-top: 10px; color: #718096; font-size: 13px; line-height: 1.8; }
+.community-invitation { display: flex; align-items: center; justify-content: space-between; gap: 18px; width: 100%; max-width: 740px; margin: 5px auto 0; padding: 21px 23px; border: 1px dashed #e4d9bc; border-radius: 18px; background: #fffaf0; color: #304968; }
+.community-invitation h2 { margin-bottom: 5px; font-size: 15px; font-weight: 600; }
+.community-invitation p { color: #7c806e; font-size: 12px; line-height: 1.7; }
+.community-link { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; padding: 8px 13px; border: 1px solid #dce5d0; border-radius: 12px; background: #f0f5e8; color: #526b46; font-size: 13px; cursor: pointer; white-space: nowrap; }
+.community-link:hover:not(:disabled) { background: #e5eed9; border-color: #b7c6a4; }
+.community-link:disabled { opacity: .6; cursor: wait; }
+.community-link:focus-visible { outline: 2px solid #547ab0; outline-offset: 3px; }
+.community-link svg { width: 17px; height: 17px; flex-shrink: 0; }
+.community-link .community-link-arrow { width: 14px; height: 14px; }
+.community-link[data-variant="sidebar"] { justify-content: flex-start; width: 100%; margin-top: 12px; border-color: transparent; background: transparent; text-align: left; }
+.community-link[data-variant="sidebar"]:hover { background: #e8f0e7; }
+.community-link[data-collapsed="true"] { justify-content: center; padding-inline: 0; }
+.community-link-error { display: block; margin-top: 6px; color: #965a45; font-size: 12px; line-height: 1.6; }
+@keyframes community-hello { 0% { transform: rotate(-15deg) translateY(5px); opacity: 0; } 70% { transform: rotate(3deg); opacity: 1; } 100% { transform: rotate(-7deg); } }
+@media (max-width: 600px) {
+ .coding-chat-body[data-welcome="true"] { padding: 24px 16px; }
+ .coding-start-caption { max-width: 230px; margin: 9px auto 0; }
+ .community-invitation { flex-direction: column; align-items: flex-start; gap: 13px; padding: 19px; }
+}
+@media (prefers-reduced-motion: reduce) { .coding-start-mark { animation: none; } }
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 1a59ef0a..730bb70d 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -53,6 +53,7 @@ import { useProviderStore } from '@/stores/providers';
import type { CodingProjectSummary } from '@/types/coding-project';
import { useProjectConfigStore } from '@/stores/project-config';
import { toast } from 'sonner';
+import { CommunityLink } from '@/components/community/CommunityLink';
type OpenDialogResult = {
canceled: boolean;
@@ -435,6 +436,7 @@ export function Sidebar({
{!sidebarCollapsed && '新建项目'}
+
{
+ await invokeIpc('shell:openExternal', COMMUNITY_URL);
+}
diff --git a/src/pages/Chat/CodingChatBody.tsx b/src/pages/Chat/CodingChatBody.tsx
new file mode 100644
index 00000000..a54f9588
--- /dev/null
+++ b/src/pages/Chat/CodingChatBody.tsx
@@ -0,0 +1,29 @@
+import { useLayoutEffect, useRef, type ReactNode } from 'react';
+
+/** Keep the same composer mounted while the empty state becomes a conversation. */
+export function CodingChatBody({ welcome, hidden, bubble, children }: {
+ welcome: boolean; hidden: boolean; bubble?: HTMLElement | null; children: ReactNode;
+}) {
+ const ref = useRef(null);
+ useLayoutEffect(() => {
+ const body = ref.current;
+ if (!body || !welcome || hidden || !bubble) return;
+ const update = () => {
+ const area = body.getBoundingClientRect();
+ const message = bubble.getBoundingClientRect();
+ const overlaps = message.height > 0 && message.right > area.left && message.left < area.right && message.bottom > area.top;
+ // Wide workspaces put the welcome beside the teachers. Compact ones flow below them.
+ body.style.paddingRight = overlaps && area.width >= 1050 ? `${Math.ceil(area.right - message.left + 24)}px` : '';
+ body.style.paddingTop = overlaps && area.width < 1050 ? `${Math.ceil(message.bottom - area.top + 24)}px` : '';
+ };
+ update();
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
+ observer?.observe(body); observer?.observe(bubble);
+ window.addEventListener('resize', update);
+ return () => {
+ observer?.disconnect(); window.removeEventListener('resize', update);
+ body.style.paddingRight = ''; body.style.paddingTop = '';
+ };
+ }, [welcome, hidden, bubble]);
+ return {children}
;
+}
diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx
index 7fe9dd00..eda736d0 100644
--- a/src/pages/Chat/CodingChatPanel.tsx
+++ b/src/pages/Chat/CodingChatPanel.tsx
@@ -47,6 +47,9 @@ import { CodingConversationHeader } from './CodingConversationHeader';
import { CodingConversationTimeline } from './CodingConversationTimeline';
import { CodingInteractionPanel } from './CodingInteractionPanel';
import { CodingWelcomeHero } from './CodingWelcomeHero';
+import { CodingStartHeading } from './CodingStartHeading';
+import { CodingChatBody } from './CodingChatBody';
+import { CommunityInvitation } from '@/components/community/CommunityInvitation';
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
import { useAuthStore } from '@/stores/auth';
import { TeacherChatPanel } from './TeacherChatPanel';
@@ -676,6 +679,8 @@ export function CodingChatPanel({
});
}, [loginRecovery, activeProject, targetConversationId, entryLoadState, workspaceLoadState, handleRecovery, draftKey, onLoginRecoveryConsumed]);
const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]);
+ const showWelcome = editable && !selectedConversation?.archivedAt && !running && runStatus === 'idle'
+ && (!targetConversationId || (entryLoadState === 'live' && snapshot?.nodes.length === 0));
if (!activeProject && workspaceLoadState !== 'loading') {
return (
@@ -807,6 +812,7 @@ export function CodingChatPanel({
}
}} />
+
{(workspaceError || conversationMetadataError || connectionError) && (
@@ -829,7 +835,7 @@ export function CodingChatPanel({
)}
- {targetConversationId
+ {showWelcome ? : targetConversationId
? (
}
+ {showWelcome && }
+
{consultationOpen && activeProject && (
diff --git a/src/pages/Chat/CodingStartHeading.tsx b/src/pages/Chat/CodingStartHeading.tsx
new file mode 100644
index 00000000..f40c5e70
--- /dev/null
+++ b/src/pages/Chat/CodingStartHeading.tsx
@@ -0,0 +1,11 @@
+import { Sparkles, Star } from 'lucide-react';
+import mark from '@/assets/consultations/mark.svg';
+
+export function CodingStartHeading() {
+ return
+
+
小点子,也能变成大惊喜
+
今天,你想创造什么?
+
一个游戏、一段故事,或是一个天马行空的想法。
+
;
+}
diff --git a/src/pages/Chat/TeacherChatPanel.tsx b/src/pages/Chat/TeacherChatPanel.tsx
index 6203fbf7..fb5b20a1 100644
--- a/src/pages/Chat/TeacherChatPanel.tsx
+++ b/src/pages/Chat/TeacherChatPanel.tsx
@@ -471,7 +471,7 @@ export function TeacherChatPanel({
(request) => request.status === 'preparing' || request.status === 'running'
);
const helpUnavailable = busy || !topicEnabled || Boolean(running);
- const askForSuggestions = () => void send({ text: '不知道问什么,你先帮我看看👀', intent: 'suggestions' });
+ const askForSuggestions = () => void send({ text: '帮我整体看🧠', intent: 'suggestions' });
const changeText = (next: string) => { setText(next); persistDraft(next, references, pending.current); };
const removeReferences = () => { setReferences([]); persistDraft(text, [], pending.current); };
const questions = definition?.suggested_questions ?? [];
@@ -541,7 +541,7 @@ export function TeacherChatPanel({
{request.suggestedQuestions?.map((question, index) => void send({ text: question })} className="consultation-follow-up">{question} )}
>}
- {['failed', 'cancelled', 'interrupted'].includes(request.status) && 不知道问什么,你先帮我看看👀 }
+ {['failed', 'cancelled', 'interrupted'].includes(request.status) && 帮我整体看🧠 }
> : request.response ? : {['running', 'preparing'].includes(request.status) ? {label}正在想… : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
}
{request.intent !== 'suggestions' && request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) &&
{request.suggestedQuestions?.map((question, index) =>
void send({ text: question })} className="consultation-follow-up">{question} )}
@@ -585,7 +585,7 @@ export function TeacherChatPanel({
className="min-h-[60px] w-full resize-none border-0 bg-transparent text-[13px] leading-6 outline-none placeholder:text-muted-foreground/65" />
- {!draftRevision && !text.trim() && 不知道问什么,你先帮我看看👀 }
+ {!draftRevision && !text.trim() && 帮我整体看🧠 }
{running ?
void teacherApi.cancel(topicBase, endpointId, running.id).then(consume).catch(() => setError('暂时无法停止,请稍后重试。'))}>停止回复
:
}
diff --git a/tests/e2e/community-welcome.spec.ts b/tests/e2e/community-welcome.spec.ts
new file mode 100644
index 00000000..fe4d0ec7
--- /dev/null
+++ b/tests/e2e/community-welcome.spec.ts
@@ -0,0 +1,90 @@
+import { test, expect } from '@playwright/test';
+import { createServer, type ViteDevServer } from 'vite';
+import { resolve } from 'node:path';
+
+let server: ViteDevServer;
+let baseUrl: string;
+test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
+test.beforeAll(async () => {
+ server = await createServer({
+ configFile: false, cacheDir: 'node_modules/.vite-community-layout',
+ resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' },
+ server: { host: '127.0.0.1', port: 0 },
+ plugins: [{
+ name: 'isolated-community', enforce: 'pre',
+ load(id) {
+ if (id.endsWith('/src/lib/host-events.ts')) return 'export function subscribeHostEvent() { return () => {}; }';
+ if (id.endsWith('/src/lib/host-api.ts')) return `
+ export async function createHostEventSource() { return { close() {}, addEventListener() {} }; }
+ export async function ensureHostApiToken() { return 'fixture'; }
+ export function getHostApiBase() { return ''; }
+ export function reportDesktopActivity() {}
+ export function setDesktopBackgroundLease() {}
+ export async function hostApiFetch() { return {}; }
+ `;
+ },
+ configureServer(vite) {
+ vite.middlewares.use((req, res, next) => {
+ if (req.url !== '/' && !req.url?.startsWith('/?')) return next();
+ res.setHeader('Content-Type', 'text/html');
+ res.end('
');
+ });
+ },
+ }],
+ });
+ await server.listen();
+ baseUrl = server.resolvedUrls!.local[0];
+});
+test.afterAll(async () => { await server?.close(); });
+test.beforeEach(async ({ page }) => {
+ page.on('pageerror', error => { throw error; });
+ await page.addInitScript(() => {
+ const scope = window as typeof window & { __communityCalls: unknown[][] };
+ scope.__communityCalls = [];
+ Object.defineProperty(window, 'electron', { value: { ipcRenderer: {
+ invoke: async (...args: unknown[]) => { scope.__communityCalls.push(args); return undefined; },
+ } } });
+ });
+});
+
+for (const width of [375, 768, 1280, 1470]) {
+ test(`welcome, composer and community link clear teacher introductions at ${width}px`, async ({ page }) => {
+ await page.setViewportSize({ width, height: 850 });
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.goto(baseUrl + '?teachers');
+ const heading = page.getByTestId('coding-start-heading');
+ const teachers = page.getByTestId('teacher-welcomes');
+ await expect(teachers.locator('.teacher-welcome-card')).toHaveCount(2);
+ await expect.poll(async () => {
+ const a = await heading.boundingBox(), b = await teachers.boundingBox();
+ return !!a && !!b && (a.x + a.width <= b.x || a.y >= b.y + b.height);
+ }).toBe(true);
+ await expect(page.locator('.coding-start-mark')).toHaveCSS('animation-name', 'none');
+ await page.getByTestId('community-invitation').scrollIntoViewIfNeeded();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
+ await expect(page.getByRole('button', { name: '去作品广场逛逛' })).toBeVisible();
+ await teachers.getByRole('button', { name: '先自己试试' }).click();
+ await heading.scrollIntoViewIfNeeded();
+ await page.screenshot({ path: test.info().outputPath(`community-welcome-${width}.png`) });
+ });
+}
+
+test('the community link opens only on click and leaves the draft mounted', async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 850 });
+ const communityRequests: string[] = [];
+ page.on('request', request => { if (request.url().includes('square.nianxx.cn')) communityRequests.push(request.url()); });
+ await page.goto(baseUrl);
+ const composer = page.getByRole('textbox');
+ await composer.fill('我想做一个自己的小镇');
+ await composer.evaluate(node => node.setAttribute('data-mounted', 'same'));
+ await page.getByRole('button', { name: '去作品广场逛逛' }).click();
+ expect(await page.evaluate(() => (window as typeof window & { __communityCalls: unknown[][] }).__communityCalls))
+ .toContainEqual(['shell:openExternal', 'https://square.nianxx.cn/#explore']);
+ expect(communityRequests).toEqual([]);
+ await expect(page.locator('iframe')).toHaveCount(0);
+ await expect(composer).toHaveValue('我想做一个自己的小镇');
+ await expect(composer).toHaveAttribute('data-mounted', 'same');
+ await page.getByRole('button', { name: '发送', exact: true }).click();
+ await expect(page.getByTestId('community-invitation')).toHaveCount(0);
+ await expect(composer).toHaveAttribute('data-mounted', 'same');
+});
diff --git a/tests/e2e/fixtures/community-welcome.tsx b/tests/e2e/fixtures/community-welcome.tsx
new file mode 100644
index 00000000..5f18539e
--- /dev/null
+++ b/tests/e2e/fixtures/community-welcome.tsx
@@ -0,0 +1,51 @@
+import { useState, type ComponentProps } from 'react';
+import { createRoot } from 'react-dom/client';
+import { CodingChatBody } from '../../../src/pages/Chat/CodingChatBody';
+import { CodingStartHeading } from '../../../src/pages/Chat/CodingStartHeading';
+import { CodingComposer } from '../../../src/pages/Chat/CodingComposer';
+import { TeacherCompanion } from '../../../src/pages/Chat/TeacherCompanion';
+import { CommunityInvitation } from '../../../src/components/community/CommunityInvitation';
+import '../../../src/styles/globals.css';
+import '../../../src/pages/Chat/classroom-workspace.css';
+
+export function Layout() {
+ const [bubble, setBubble] = useState
(null);
+ const [draft, setDraft] = useState('');
+ const [sent, setSent] = useState(false);
+ const [dismissed, setDismissed] = useState([]);
+ const agents = ['架构工程师-陈宗琦', '产品工程师-Alice'].map((name, index) => ({
+ teacher_id: String(index), version: 1, is_default: index === 0,
+ definition: { config_id: String(index), name, avatar_id: 'avatar-06',
+ description: index === 0 ? '有什么编程问题想一起讨论?' : '陪你把天马行空的点子,变成能玩、能用、眼睛一亮的小作品。' },
+ }));
+ const companion = {
+ agents, selectedAgent: agents[0], definition: agents[0].definition, enabled: true,
+ unreadAgentIds: [], invitation: null, welcome: null, loading: false, error: null, checking: false,
+ refreshing: false, refreshStatus: 'idle', refresh: async () => {}, reloadCatalog: async () => {}, dismiss: () => {},
+ welcomes: new URLSearchParams(location.search).has('teachers') ? agents.flatMap((agent, index) => dismissed.includes(agent.teacher_id)
+ ? [] : [{ agentId: agent.teacher_id, definition: agent.definition, text: agent.definition.description, tone: index }]) : [],
+ dismissWelcome: (id: string) => setDismissed(values => [...values, id]),
+ dismissWelcomes: () => setDismissed(['0', '1']),
+ } as ComponentProps['companion'];
+ return
+
+ 测试项目 › 新对话
+ {}} onToggle={() => {}} bubbleRef={setBubble} />
+
+
+ 操作对话 作品
+
+
+
+ {}} onSubmit={() => { setSent(true); setDraft(''); }} onRecover={() => {}}
+ onAddFiles={() => {}} onRemoveAttachment={() => {}} />
+
+ {!sent && }
+
+
+
;
+}
+createRoot(document.getElementById('root')!).render( );
diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts
index d2a48c79..fed1943a 100644
--- a/tests/e2e/pi-coding-first-chat.spec.ts
+++ b/tests/e2e/pi-coding-first-chat.spec.ts
@@ -1121,10 +1121,25 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
buffer: largePng,
});
await composer.fill('Build the first PI scene');
+ await electronApp.evaluate(({ shell }) => {
+ const scope = globalThis as typeof globalThis & { __communityOpened?: string[] };
+ scope.__communityOpened = [];
+ shell.openExternal = async url => { scope.__communityOpened!.push(url); };
+ });
+ await page.getByRole('button', { name: '作品广场', exact: true }).click();
+ await page.getByRole('button', { name: '去作品广场逛逛' }).click();
+ await expect.poll(() => electronApp.evaluate(() => (
+ (globalThis as typeof globalThis & { __communityOpened?: string[] }).__communityOpened
+ ))).toEqual(['https://square.nianxx.cn/#explore', 'https://square.nianxx.cn/#explore']);
+ await expect(composer).toHaveValue('Build the first PI scene');
+ await expect(page.getByText('large.png', { exact: true })).toBeVisible();
+ await expect(page.getByRole('dialog')).toHaveCount(0);
+ expect((await readState(electronApp)).captured.filter(item => item.path.endsWith('/prompt') && item.method === 'POST')).toHaveLength(0);
await expect(page.getByRole('button', { name: '发送' })).toBeEnabled();
await page.getByTestId('coding-message-composer').evaluate(
(form: HTMLFormElement) => form.requestSubmit(),
);
+ await expect(page.getByTestId('community-invitation')).toHaveCount(0);
await expect.poll(async () => {
const state = await readState(electronApp);
@@ -1869,7 +1884,7 @@ test('project consultations preserve student drafts and switch between work and
await expect(page.getByRole('button', { name: '与朋友聊天' })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#coding-consultation-dock')).toHaveCSS('width', '508px');
await expect(teacher.locator('form .consultation-hint')).toBeVisible();
- await expect(teacher.locator('form .consultation-hint')).toHaveText('不知道问什么,你先帮我看看👀');
+ await expect(teacher.locator('form .consultation-hint')).toHaveText('帮我整体看🧠');
await expect(teacher.getByText('把你的困惑说出来就好')).toHaveCount(0);
await expect(teacher.getByText('当前项目',{exact:true})).toHaveCount(0);
await expect(teacher.getByRole('button',{name:'解释当前代码',exact:true})).toBeVisible();
@@ -1924,7 +1939,7 @@ test('project consultations preserve student drafts and switch between work and
expect(request.body).not.toHaveProperty('discussion');
}
await expect(teacher).toHaveAccessibleName('朋友咨询');
- expect(consultationRequests[0].body).toMatchObject({intent:'suggestions',text:'不知道问什么,你先帮我看看👀'});
+ expect(consultationRequests[0].body).toMatchObject({intent:'suggestions',text:'帮我整体看🧠'});
expect(consultationRequests[1].body).toMatchObject({text:'怎样观察别人玩游戏?'});
expect(consultationRequests[1].body?.intent).toBeUndefined();
await page.screenshot({path:test.info().outputPath('project-teacher-side-chat.png')});
diff --git a/tests/e2e/teacher-reply-layout.spec.ts b/tests/e2e/teacher-reply-layout.spec.ts
index 636775fd..e5680b21 100644
--- a/tests/e2e/teacher-reply-layout.spec.ts
+++ b/tests/e2e/teacher-reply-layout.spec.ts
@@ -29,7 +29,7 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation',
const panel = page.getByTestId('teacher-chat-panel');
const retry = panel.locator('.consultation-retry');
await expect(retry).toBeEnabled();
- await expect(retry).toHaveText('不知道问什么,你先帮我看看👀');
+ await expect(retry).toHaveText('帮我整体看🧠');
expect(await retry.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const [panelBounds, retryBounds] = await Promise.all([panel.boundingBox(), retry.boundingBox()]);
expect(retryBounds!.x).toBeGreaterThanOrEqual(panelBounds!.x);
@@ -41,7 +41,7 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation',
await expect(page.getByText('我们可以先从一个小地方开始。')).toBeVisible();
await expect(input).toHaveValue('我还有一点自己的想法');
expect(await page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs)).toEqual([
- { requestId: expect.any(String), text: '不知道问什么,你先帮我看看👀', intent: 'suggestions', references: [], projectId: 'fixture', sourceConversationId: 'source' },
+ { requestId: expect.any(String), text: '帮我整体看🧠', intent: 'suggestions', references: [], projectId: 'fixture', sourceConversationId: 'source' },
]);
}
});
@@ -89,7 +89,7 @@ for (const width of [319, 508]) {
const send = page.getByRole('button', { name: '提问', exact: true });
const sent = () => page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs);
await expect(hint).toBeEnabled();
- await expect(hint).toHaveText('不知道问什么,你先帮我看看👀');
+ await expect(hint).toHaveText('帮我整体看🧠');
expect(await hint.evaluate(el => el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight)).toBe(true);
await expect(hint).toHaveCSS('background-color', 'rgb(255, 242, 201)');
const initialSend = (await send.boundingBox())!;
@@ -110,9 +110,9 @@ for (const width of [319, 508]) {
expect(await sent()).toEqual([]);
await hint.click();
await expect(page.getByText('我们可以先从一个小地方开始。')).toBeVisible();
- expect(await sent()).toEqual([{ requestId: expect.any(String), text: '不知道问什么,你先帮我看看👀', intent: 'suggestions', references: [], projectId: 'fixture', sourceConversationId: 'source' }]);
+ expect(await sent()).toEqual([{ requestId: expect.any(String), text: '帮我整体看🧠', intent: 'suggestions', references: [], projectId: 'fixture', sourceConversationId: 'source' }]);
await expect(page.getByRole('button', { name: '我也说不清,你带我看看', exact: true })).toHaveCount(0);
- await expect(page.locator('.consultation-messages').getByRole('button', { name: '不知道问什么,你先帮我看看👀', exact: true })).toHaveCount(0);
+ await expect(page.locator('.consultation-messages').getByRole('button', { name: '帮我整体看🧠', exact: true })).toHaveCount(0);
const card = page.getByRole('button', { name: '可以从哪个小地方开始?' });
await expect(card).toBeVisible();
await input.fill('留着我自己的草稿');
diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx
index 974d8b7b..b88f3e27 100644
--- a/tests/unit/coding-chat-panel.test.tsx
+++ b/tests/unit/coding-chat-panel.test.tsx
@@ -340,7 +340,7 @@ describe('CodingChatPanel first Conversation', () => {
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
fireEvent.keyDown(menu, { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '归档(任务继续运行)' }));
- await screen.findByTestId('coding-conversation-pending');
+ await screen.findByRole('heading', { name: '今天,你想创造什么?' });
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.abort).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '查看归档对话' }));
@@ -434,13 +434,15 @@ describe('CodingChatPanel first Conversation', () => {
expect(onOpenProjectSettings).not.toHaveBeenCalled();
});
- it('keeps an empty project editable without creating a conversation until first send', async () => {
+ it('keeps an empty project and its draft mounted across community visits until first send', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([]);
projectApi.create.mockResolvedValue(conversation);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
+ const api = await import('@/lib/api-client');
+ const external = vi.spyOn(api, 'invokeIpc').mockResolvedValue(undefined);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
render( );
@@ -448,8 +450,17 @@ describe('CodingChatPanel first Conversation', () => {
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.snapshot).not.toHaveBeenCalled();
fireEvent.change(textbox, {target:{value:'First prompt'}});
+ fireEvent.click(screen.getByRole('button', { name: '去作品广场逛逛' }));
+ await waitFor(() => expect(external).toHaveBeenCalledWith('shell:openExternal', 'https://square.nianxx.cn/#explore'));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(screen.getByRole('textbox')).toBe(textbox);
+ expect(textbox).toHaveValue('First prompt');
+ expect(projectApi.create).not.toHaveBeenCalled();
+ expect(conversationApi.submit).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button',{name:'发送'}));
await waitFor(()=>expect(projectApi.create).toHaveBeenCalledOnce());
+ await waitFor(() => expect(screen.queryByTestId('community-invitation')).not.toBeInTheDocument());
+ expect(screen.getByRole('textbox')).toBe(textbox);
});
it('rehydrates a preselected stale Conversation when the programming view mounts', async () => {
diff --git a/tests/unit/coding-teacher-ui.test.tsx b/tests/unit/coding-teacher-ui.test.tsx
index ff2dc375..608e0a6c 100644
--- a/tests/unit/coding-teacher-ui.test.tsx
+++ b/tests/unit/coding-teacher-ui.test.tsx
@@ -496,13 +496,13 @@ describe('teacher side chat', () => {
}));
render( );
const input = screen.getByLabelText('向智能体提问');
- const help = () => screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' });
+ const help = () => screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' });
await waitFor(() => expect(help()).toBeEnabled());
expect(screen.getByTestId('consultation-composer-actions')).toContainElement(help());
- expect(help()).toHaveAccessibleName('不知道问什么,你先帮我看看👀');
+ expect(help()).toHaveAccessibleName('帮我整体看🧠');
expect(api.send).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: '我先自己想想' } });
- expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
+ expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
expect(api.send).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: ' ' } });
expect(help()).toBeEnabled();
@@ -517,11 +517,11 @@ describe('teacher side chat', () => {
expect(screen.queryByRole('button', { name: '尚未确认的问题' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
expect(api.send).toHaveBeenCalledWith('p/agent-topics', 'first', expect.objectContaining({
- intent: 'suggestions', text: '不知道问什么,你先帮我看看👀', sourceConversationId: 'c',
+ intent: 'suggestions', text: '帮我整体看🧠', sourceConversationId: 'c',
}));
expect(help()).toBeDisabled();
fireEvent.change(input, { target: { value: '我自己还想问的另一件事' } });
- expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
+ expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
await waitFor(() => expect(streams.has('first')).toBe(true));
const sent = api.send.mock.calls[0][2] as TeacherSend;
act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({
@@ -532,7 +532,7 @@ describe('teacher side chat', () => {
expect(screen.queryByText('我看看你最近做到了哪里…')).not.toBeInTheDocument();
for (const question of suggestedQuestions) expect(screen.getByRole('button', { name: question })).toBeEnabled();
expect(screen.queryByRole('button', { name: '我也说不清,你带我看看', exact: true })).not.toBeInTheDocument();
- expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
+ expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
expect(input).toHaveValue('我自己还想问的另一件事');
});
@@ -567,8 +567,8 @@ describe('teacher side chat', () => {
it('keeps an uncertain suggestions request identity through reopening and draft edits', async () => {
api.send.mockRejectedValue(new Error('网络暂不可用'));
const view = render( );
- await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled());
- fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' }));
+ await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled());
+ fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' }));
await screen.findByText('网络暂不可用');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我还没写完的问题' } });
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
@@ -578,10 +578,10 @@ describe('teacher side chat', () => {
render( );
await waitFor(() => expect(screen.getByLabelText('向智能体提问')).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我还没写完的问题');
- expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
+ expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
expect(api.send).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '' } });
- fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' }));
+ fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' }));
await screen.findByText('网络暂不可用');
expect(api.send.mock.calls[1][2]).toEqual(originalRequest);
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
@@ -592,16 +592,16 @@ describe('teacher side chat', () => {
localStorage.setItem('makelore-consultation-draft:signed-out:p:teacher', JSON.stringify({ text: '', references: [], pending: original }));
api.send.mockRejectedValue(new Error('网络暂不可用'));
const view = render( );
- await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled());
+ await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled());
expect(api.send).not.toHaveBeenCalled();
- fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' }));
+ fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' }));
await screen.findByText('网络暂不可用');
expect(JSON.parse(JSON.stringify(api.send.mock.calls[0][2]))).toEqual(original);
view.unmount();
render( );
- await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled());
+ await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled());
expect(api.send).toHaveBeenCalledTimes(1);
- fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' }));
+ fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' }));
await screen.findByText('网络暂不可用');
expect(JSON.parse(JSON.stringify(api.send.mock.calls[1][2]))).toEqual(original);
});
@@ -626,13 +626,13 @@ describe('teacher side chat', () => {
api.read.mockResolvedValue({ ...first, requests: [suggestionsRequest({ id: original.requestId, text: original.text, status })] });
api.send.mockResolvedValue({ ...first, revision: 2 });
render( );
- await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled());
+ await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled());
expect(api.send).not.toHaveBeenCalled();
- fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' }));
+ fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' }));
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(1));
const sent = api.send.mock.calls[0][2] as TeacherSend;
expect(sent.requestId).not.toBe(original.requestId);
- expect(sent).toMatchObject({ text: '不知道问什么,你先帮我看看👀', intent: 'suggestions', references: [], sourceConversationId: 'c' });
+ expect(sent).toMatchObject({ text: '帮我整体看🧠', intent: 'suggestions', references: [], sourceConversationId: 'c' });
});
it.each(['failed', 'cancelled', 'interrupted'] as const)('retries a %s suggestion request without leaking raw JSON or reusing its accepted request id', async status => {
@@ -645,7 +645,7 @@ describe('teacher side chat', () => {
await screen.findByText('这次没有看清楚,请再试一次。');
expect(screen.queryByText('{"intro":"未完成')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: suggestedQuestions[0] })).not.toBeInTheDocument();
- fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: '.consultation-retry' }));
+ fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: '.consultation-retry' }));
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
expect(api.send.mock.calls[0][2]).toMatchObject({ intent: 'suggestions' });
expect(api.send.mock.calls[0][2].requestId).not.toBe(failed.id);
@@ -657,9 +657,9 @@ describe('teacher side chat', () => {
...first, revision: 3, requests: [accepted, suggestionsRequest({ id: input.requestId })],
}));
const view = render( );
- await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled());
+ await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled());
await waitFor(() => expect(streams.has('first')).toBe(true));
- fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' }));
+ fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' }));
await screen.findByText('响应中途断开');
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '我的自由提问继续留着' } });
const originalRequest = api.send.mock.calls[0][2] as TeacherSend;
@@ -673,10 +673,10 @@ describe('teacher side chat', () => {
act(() => streams.get('first')!.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify(recovered) })));
}
await screen.findByText('智能体已接到,但这次回复失败了');
- await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: '.consultation-retry' })).toBeEnabled());
+ await waitFor(() => expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: '.consultation-retry' })).toBeEnabled());
expect(screen.getByLabelText('向智能体提问')).toHaveValue('我的自由提问继续留着');
expect(api.send).toHaveBeenCalledTimes(1);
- fireEvent.click(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: '.consultation-retry' }));
+ fireEvent.click(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: '.consultation-retry' }));
await screen.findByText('我们可以从最近遇到的这两个地方聊起。');
expect(api.send).toHaveBeenCalledTimes(2);
expect(api.send.mock.calls[1][2].requestId).not.toBe(originalRequest.requestId);
@@ -699,7 +699,7 @@ describe('teacher side chat', () => {
expect(api.send.mock.calls[0][2].intent).toBeUndefined();
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
expect(screen.queryByRole('button', { name: '移除引用' })).not.toBeInTheDocument();
- expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeEnabled();
+ expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeEnabled();
view.unmount();
render( );
expect(screen.getByLabelText('向智能体提问')).toHaveValue('');
@@ -787,7 +787,7 @@ describe('teacher side chat', () => {
fireEvent.click(screen.getByRole('button', { name: '运营配置的预览问题' }));
expect(screen.getByLabelText('向智能体提问')).toHaveValue('运营配置的预览问题');
expect(api.send).not.toHaveBeenCalled();
- expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
+ expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '智能体新话题' }));
await waitFor(() => expect(api.create).toHaveBeenCalledWith('preview/sample', 7, '学生的练习代码', undefined));
});
@@ -911,7 +911,7 @@ describe('merged cloud teacher classroom', () => {
api.sendConversation.mockRejectedValue(new Error('结果暂未确认'));
const props = { projectId: 'p', sourceId: 'original-source', selectedAgent: agent };
const view = render( );
- const hint = () => screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' });
+ const hint = () => screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' });
await waitFor(() => expect(hint()).toBeEnabled());
expect(api.sendConversation).not.toHaveBeenCalled();
fireEvent.click(hint());
@@ -984,10 +984,10 @@ describe('merged cloud teacher classroom', () => {
render( );
await waitFor(() => expect(screen.getByText('智能体暂未开放,历史仍可查看。')).toBeVisible());
const input = screen.getByLabelText('向智能体提问');
- expect(screen.getByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).toBeDisabled();
+ expect(screen.getByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).toBeDisabled();
fireEvent.change(input, { target: { value: '继续讨论' } });
expect(screen.getByRole('button', { name: '提问', exact: true })).toBeDisabled();
- expect(screen.queryByText((_text, element) => element?.textContent === '不知道问什么,你先帮我看看👀', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
+ expect(screen.queryByText((_text, element) => element?.textContent === '帮我整体看🧠', { selector: 'form .consultation-hint' })).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: 'Enter' });
expect(api.send).not.toHaveBeenCalled();
});
diff --git a/tests/unit/community.test.tsx b/tests/unit/community.test.tsx
new file mode 100644
index 00000000..29257c4d
--- /dev/null
+++ b/tests/unit/community.test.tsx
@@ -0,0 +1,33 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { invokeIpc } from '@/lib/api-client';
+import { COMMUNITY_URL } from '@/lib/community';
+import { CommunityLink } from '@/components/community/CommunityLink';
+import { CommunityInvitation } from '@/components/community/CommunityInvitation';
+
+vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() }));
+beforeEach(() => { vi.mocked(invokeIpc).mockReset().mockResolvedValue(undefined); });
+afterEach(cleanup);
+
+it('only opens the public explore URL after a click, without fetching or embedding works', async () => {
+ const fetch = vi.spyOn(globalThis, 'fetch');
+ render( );
+ expect(invokeIpc).not.toHaveBeenCalled();
+ expect(fetch).not.toHaveBeenCalled();
+ expect(document.querySelector('iframe')).toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: '去作品广场逛逛' }));
+ await waitFor(() => expect(invokeIpc).toHaveBeenCalledExactlyOnceWith('shell:openExternal', COMMUNITY_URL));
+ fetch.mockRestore();
+});
+
+it('provides an accessible collapsed sidebar entry and a retry after opening fails', async () => {
+ vi.mocked(invokeIpc).mockRejectedValueOnce(new Error('unavailable'));
+ render( );
+ const button = screen.getByRole('button', { name: '作品广场' });
+ fireEvent.click(button);
+ await screen.findByRole('alert');
+ await waitFor(() => expect(button).toBeEnabled());
+ fireEvent.click(button);
+ await waitFor(() => expect(invokeIpc).toHaveBeenCalledTimes(2));
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+});