fix: 显示下发智能体的云端图标

This commit is contained in:
2026-09-26 16:11:09 +08:00
parent 9147b509c7
commit a3c02e7464
10 changed files with 127 additions and 12 deletions

View File

@@ -0,0 +1,49 @@
# Task: Diagnose distributed agent avatar propagation
## Identity
- Task ID: 20260926-agent-avatar-ml-3c3686ec
- Mode: Feature
- Branch: codex/20260926-agent-avatar-ml-3c3686ec-agent-avatar-ml
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260926-agent-avatar-ml-3c3686ec
- Base commit: 9147b509c71f3ea765e5aaa8f542fd0a74bd4653
- Owner: codex
- Status: Ready for Integration
## Scope
- Diagnose and repair the missing Yuxi icon across publication, distribution and client rendering. User authorized implementation after the source diagnosis.
## Intent And Constraints
- Preserve immutable publication/replay and old-topic semantics, student billing, source storage ownership and existing local avatar fallback.
- No live configuration, deployment, automatic synchronization, main-branch merge or modification of peer worktrees.
- Concurrent ownership and Planning Gate passed in the same isolated task worktree. Same-base project memory and peer task scopes were retained from diagnosis; no new peers at resumption. Historical placeholder peer scopes remain unknown with no identified semantic conflict.
- Plan completed: froze the icon in Yuxi, resolved and preserved it in Works Square, rendered it consistently in Makelore, then verified each boundary. The one user-authorized fresh read-only reviewer approved the complete change with no blocking findings.
## Outcome
- Added optional icon_url to the distributed TeacherDefinition. TeacherAvatar handles the configured image and falls back to avatar_id when absent or when loading fails; new image URLs can be attempted after an earlier failure.
- Topbar entries, legacy/history companion entry, invitation speaker, panel header, welcome and assistant message rows use TeacherAvatar. Custom images use normal rendering; bundled pixel art keeps pixelated rendering.
- Updated README and added actual UI/browser regressions.
## Verification
- RED: both new tests failed with built-in avatar src instead of the expected cloud URL before implementation.
- GREEN: pnpm exec vitest run tests/unit/teacher-companion.test.tsx tests/unit/coding-teacher-ui.test.tsx: 56 passed.
- pnpm exec tsc --noEmit and focused ESLint passed. pnpm run build:vite passed for renderer, Main, preload and utility worker; existing Browserslist/chunk warnings remain.
- Real Edge Playwright distributed-icons case passed, including actual image decoding, independent 404 fallback, normal image rendering and the invitation speaker. Screenshot inspected in test-results; test route supplies deterministic image bytes, not production OSS.
- No installed Electron package or live production visual acceptance.
## Follow-ups
- Independent review passed. One non-blocking evidence gap remains: no combined test updates catalog metadata while an old topic is already open; existing separate data-flow tests and source inspection support the pinned-topic behavior.
- Task documentation drift checks passed across all three repositories; commit the scoped result for integration.
- Deploy compatible Yuxi, Works Square and Makelore versions, then use Operations sync to publish the existing agent's icon. Older topics keep their original published definitions.
## Promotion Candidates
- Target: .project-docs/20-architecture/system-overview.md and integrated current-state when useful.
- Proposal: record the versioned icon contract: native Yuxi icon becomes a frozen teacher snapshot field, WS resolves the public URL, client renders it with local fallback.
- Evidence: production diff plus regression results above. Future impact: visual identity follows the same publication boundary as other agent metadata.
- Semantic conflicts: none identified. Human confirmation: implementation authorized; canonical promotion and main-branch merge remain a separate integration task.

View File

@@ -180,6 +180,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
- 当前输入是按预算截取的所选操作对话已完成分支文本、明确引用、咨询历史和适用的当前组件,并支持上述只读原文恢复;没有跨全部操作会话自动读取,也未向操作 Agent 注入咨询共识。“先这些”仍仅结束本轮整理,保留各条目状态,不确认全部候选、不自动制作。提示词不宣称这些系统能力已接通,也不声称看过未提供的画面或试玩过作品。Yuxi 话题经云端原生执行,旧版本地智能体由 Main 调用计费网关;均不创建本地 Pi 编程 worker。
- 智能体输入裁剪区分两种计量:本地模型以约每 2 个 UTF-8 字节估算 1 Token,并计入工具与读取预留;这不是精确分词或扣费依据,模型上限与实际 usage 仍由服务端拥有。云端沿用 query 字节上限,以完整 JSON 转义后的大小裁剪来源节选。智能体指令、当前问题和明确引用不会被静默裁掉;固定配置或当前整理本身超限时明确提示调整配置,避免让学生反复缩短短问题或新建话题。完整来源仍可由只读工具按需读取。
- `tests/fixtures/teacher-guidance-examples.json` 提供宠物、种植、射击、蛋糕的人工多轮输入输出样例,由真实解析器、上下文编译器与讨论状态机校验;样例不加载到运行时,也不代表真实模型教学效果已验收。云端定义由 Yuxi 维护并由运营显式同步下发;本地代码修改不会自动改写云端配置或学生已有话题的固定发布版本。
- 下发智能体的图标随发布版本保存,顶栏、咨询面板、欢迎区和消息使用定义中的 `icon_url`;未设置或图片加载失败时使用内置头像。运营在 Yuxi 更换图标后需同步下发配置,新话题采用新版本,已有话题保持原图标。相对图片地址由服务端使用 Yuxi 公网入口补全。
- 咨询话题及未发送草稿按当前云端账号、项目保存;切换操作对话仍延续该项目下的咨询,每轮发送时绑定当时的操作对话来源。创建话题时固定发布版本,每轮重新检查启停。关闭面板或切换会话不取消已接受的问题,退出账号会中止。请求先落盘再调用模型,同一请求标识不重复发送,重启后未完成请求标记中断。云端断线按游标和持久状态恢复;停止或退出账号会取消原云端问题,下次提问先停止旧的未完成问题再绑定新上下文。只接收云端主线程回答,子智能体文本不混入正文。没有操作对话时也可咨询,但无操作文本上下文。
- 删除操作对话会停止正在使用该来源的回复,保留项目级咨询历史;归档来源需先恢复后才能引用。原有来源会话级智能体 API 与存储保持兼容,原路径历史不自动迁移到新项目级咨询栏。运营草稿试聊仍使用 `niancode://coding-teacher/preview?draft_revision=N` 打开独立示例上下文,不读取学生项目,要求桌面当前账号具有运营权限。

View File

@@ -16,6 +16,7 @@ export interface TeacherDefinition {
name: string;
description: string;
avatar_id: string;
icon_url?: string | null;
welcome_message: string;
suggested_questions: string[];
system_prompt: string;

View File

@@ -0,0 +1,17 @@
import { useState } from 'react';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import type { TeacherDefinition } from '../../../shared/coding-teacher';
/** 展示发布版本的图标,缺图或加载失败时使用原有内置头像。 */
export function TeacherAvatar({ definition, className }: {
definition?: Pick<TeacherDefinition, 'avatar_id' | 'icon_url'> | null;
className?: string;
}) {
const [failedIcon, setFailedIcon] = useState<string | null>(null);
const icon = definition?.icon_url;
const custom = Boolean(icon && icon !== failedIcon);
return <img className={className} alt=""
src={custom ? icon! : getAgentAvatarSrc(definition?.avatar_id)}
style={{ objectFit: 'contain', imageRendering: custom ? 'auto' : 'pixelated' }}
onError={custom ? () => setFailedIcon(icon!) : undefined} />;
}

View File

@@ -12,7 +12,7 @@ import type {
} from '../../../shared/coding-teacher';
import { TeacherDiscussionPanel, DiscussionFocus } from './TeacherDiscussionPanel';
import type { TeacherDiscussionAction } from '../../../shared/coding-teacher';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { TeacherAvatar } from './TeacherAvatar';
import { TeacherReply } from './TeacherReply';
export function TeacherChatPanel({
@@ -67,7 +67,6 @@ export function TeacherChatPanel({
const [historyOpen, setHistoryOpen] = useState(false);
const [focus, setFocus] = useState<{ topicId: string; id: string; label: string } | null>(null);
const [definition, setDefinition] = useState<TeacherDefinition | null>(null);
const avatar = getAgentAvatarSrc(definition?.avatar_id);
const externalTopicRef = useRef(externalTopic);
externalTopicRef.current = externalTopic;
const selectedAgentId = selectedAgent?.teacher_id;
@@ -383,7 +382,7 @@ export function TeacherChatPanel({
<aside className="consultation-pane flex h-full min-h-0 w-full flex-col"
data-testid="teacher-chat-panel" aria-label={`${label}聊天`}>
<header className="consultation-header flex h-[68px] shrink-0 items-center gap-3 px-5">
<img className="consultation-avatar h-9 w-9 rounded-xl [image-rendering:pixelated]" src={avatar} alt="" />
<TeacherAvatar className="consultation-avatar h-9 w-9 rounded-xl" definition={definition} />
<div className="min-w-0 flex-1"><h2 className="truncate text-sm font-semibold">{definition?.name ?? '智能体'}</h2>
<p className="mt-1 text-[11px] text-muted-foreground">{draftRevision ? '运营草稿试聊' : definition?.description}</p></div>
<button aria-label={`${label}新话题`} title="新话题" disabled={busy || !enabled} onClick={() => void newTopic()} className="consult-icon"><Plus className="h-4 w-4" /></button>
@@ -407,7 +406,7 @@ export function TeacherChatPanel({
}} />}
<div ref={scrollRef} className="consultation-messages min-h-0 flex-1 overflow-y-auto px-5 py-6">
{!topic?.requests.length && <div className="flex min-h-full flex-col items-center justify-center pb-5 text-center">
<img className="mb-6 h-[68px] w-[68px] rounded-3xl [image-rendering:pixelated]" src={avatar} alt="" />
<TeacherAvatar className="mb-6 h-[68px] w-[68px] rounded-3xl" definition={definition} />
<h3 className="text-lg font-medium tracking-tight">{definition?.welcome_message || '开始对话'}</h3>
<div className="mt-6 flex w-full max-w-64 flex-col gap-2">{questions.map((question) => <button key={question} onClick={() => changeText(question)} className="consultation-suggestion flex items-center justify-between gap-2 rounded-lg border bg-white px-3 py-3 text-left text-xs">{question}<ChevronRight className="h-3 w-3 shrink-0 opacity-50" /></button>)}</div>
</div>}
@@ -416,7 +415,7 @@ export function TeacherChatPanel({
? <p className="text-[11px] text-muted-foreground">智能体来看看你的进展</p>
: <div className="flex justify-end"><p className="consultation-user max-w-[92%] whitespace-pre-wrap break-words rounded-2xl rounded-br-sm px-4 py-3 text-[13px] leading-7">{request.text}</p></div>}
{request.references.length > 0 && <details className="text-xs text-muted-foreground"><summary>本轮引用 {request.references.length} 项</summary>{request.references.map((ref, index) => <pre className="whitespace-pre-wrap" key={index}>{ref.text}</pre>)}</details>}
<div className="flex items-center gap-2 text-[11px] text-muted-foreground"><img src={avatar} alt="" className="h-6 w-6 rounded-lg [image-rendering:pixelated]" />{definition?.name ?? label}</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground"><TeacherAvatar definition={definition} className="h-6 w-6 rounded-lg" />{definition?.name ?? label}</div>
{!!request.toolActivity?.length && <details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">使用了 {request.toolActivity.length} 项工具</summary>
<ul className="mt-2 space-y-2" aria-label="智能体工具活动">

View File

@@ -1,6 +1,6 @@
import { ArrowUpRight, Loader2, X } from 'lucide-react';
import type { Ref } from 'react';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import { TeacherAvatar } from './TeacherAvatar';
import type { useTeacherCompanion } from './use-teacher-companion';
type Companion = ReturnType<typeof useTeacherCompanion>;
@@ -12,7 +12,6 @@ export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
}) {
const { definition, invitation, welcome, loading, enabled, checking, error } = companion;
const name = definition?.name || '智能体';
const avatar = getAgentAvatarSrc(definition?.avatar_id);
const activeId = companion.selectedAgent?.teacher_id;
const agents = companion.selectedAgent && !companion.agents.some((item) => item.teacher_id === activeId)
? [...companion.agents, companion.selectedAgent] : companion.agents;
@@ -28,13 +27,13 @@ export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
aria-expanded={open && activeId === agent.teacher_id} aria-pressed={open && activeId === agent.teacher_id}
aria-controls="coding-consultation-dock" data-agent-id={agent.teacher_id}
onMouseDown={(event) => event.preventDefault()} onClick={() => onOpen(agent.teacher_id)}>
<span className="teacher-companion-avatar"><img src={getAgentAvatarSrc(agent.definition.avatar_id)} alt="" />
<span className="teacher-companion-avatar"><TeacherAvatar definition={agent.definition} />
{activeId === agent.teacher_id && invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
<span className="teacher-companion-name">{agent.definition.name}</span>
{activeId === agent.teacher_id && checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
</button>)}
{(!agents.length || (!activeId && companion.topic)) && <button type="button" className="teacher-companion-person" title={status} aria-label="智能体" aria-expanded={open} aria-controls="coding-consultation-dock" onMouseDown={(event) => event.preventDefault()} onClick={() => onOpen()}>
<span className="teacher-companion-avatar"><img src={avatar} alt="" />{invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
<span className="teacher-companion-avatar"><TeacherAvatar definition={definition} />{invitation && <span className="teacher-companion-unread" aria-hidden="true" />}</span>
<span className="teacher-companion-name">{name}</span>
{checking && <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none" aria-label="智能体正在思考" />}
</button>}
@@ -52,7 +51,7 @@ export function TeacherCompanion({ companion, open, onOpen, bubbleRef }: {
</div>
</div>
<button type="button" className="teacher-companion-speaker" aria-label="打开这条智能体消息" onMouseDown={(event) => event.preventDefault()} onClick={viewConversation}>
<img src={avatar} alt="" />
<TeacherAvatar definition={definition} />
</button>
</div>}
</section>

View File

@@ -17,11 +17,12 @@ export function Layout() {
const [selected, setSelected] = useState(0);
const agents = Array.from({ length: count }, (_, index) => ({
teacher_id: `agent-${index}`, version: index + 1, is_default: index === 0,
definition: { config_id: `agent-${index}`, name: `智能体${index + 1}`, avatar_id: 'avatar-06' },
definition: { config_id: `agent-${index}`, name: `智能体${index + 1}`, avatar_id: 'avatar-06',
icon_url: new URLSearchParams(location.search).has('icons') ? `https://yuxi.example.test/api/storage/public/images/agent-${index}.png` : undefined },
}));
const companion = {
agents, selectedAgent: agents[selected],
definition: { name: '编程老师', avatar_id: 'avatar-06' },
definition: agents[selected]?.definition ?? { name: '编程老师', avatar_id: 'avatar-06' },
invitation: visible ? { response: long ? '你刚才说想做一个宠物游戏。我们一起想想,什么样的小动作,会让你愿意一直陪它玩下去?可以先挑一个最想试的想法。' : '有什么编程问题想一起讨论?' } : null,
welcome: null, enabled: true, loading: false, checking: false, error: null,
dismiss: () => setVisible(false),

View File

@@ -70,6 +70,26 @@ async function geometry(page: Page) {
});
}
test('distributed icons load over HTTP and broken images fall back in the titlebar', async ({ page }) => {
await page.route('https://yuxi.example.test/api/storage/public/images/agent-0.png', route => route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="16" cy="16" r="15" fill="blue"/></svg>',
}));
await page.route('https://yuxi.example.test/api/storage/public/images/agent-1.png', route => route.fulfill({ status: 404 }));
await page.goto(`${baseUrl}?agents=2&icons=1`);
const custom = page.getByRole('button', { name: '与智能体1聊天' }).locator('img');
const fallback = page.getByRole('button', { name: '与智能体2聊天' }).locator('img');
const speaker = page.getByRole('button', { name: '打开这条智能体消息' }).locator('img');
await expect(custom).toHaveAttribute('src', 'https://yuxi.example.test/api/storage/public/images/agent-0.png');
await expect(custom).toHaveCSS('image-rendering', 'auto');
await expect(speaker).toHaveAttribute('src', 'https://yuxi.example.test/api/storage/public/images/agent-0.png');
await expect(fallback).toHaveAttribute('src', /avatar-06\.png/);
for (const img of [custom, fallback, speaker]) {
await expect.poll(() => img.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0);
}
await page.screenshot({ path: test.info().outputPath('distributed-icons.png') });
});
for (const size of [{ width: 1440, height: 900 }, { width: 1180, height: 720 }, { width: 1000, height: 640 }]) {
test(`preview only clears overlapping speech at ${size.width}x${size.height}`, async ({ page }) => {
await page.setViewportSize(size);

View File

@@ -96,6 +96,16 @@ async function ready() {
await waitFor(() => expect(streams.has('first')).toBe(true));
}
describe('teacher side chat', () => {
it('renders the pinned topic icon in the header and assistant messages', async () => {
const icon = 'https://yuxi.example.test/api/storage/public/images/topic.png';
api.read.mockResolvedValue({ ...first, definition: { ...definition, icon_url: icon }, requests: [request({ response: '继续原来的讨论' })] });
const view = render(<TeacherChatPanel projectId="p" sourceId="c" />);
await ready();
const images = view.container.querySelectorAll('[data-testid="teacher-chat-panel"] img');
expect(images).toHaveLength(2);
for (const img of images) expect(img).toHaveAttribute('src', icon);
});
it('renders assistant Markdown, tables, code and images instead of literal markup', async () => {
const response = '## 项目建议\n\n**先验证跳跃**\n\n- 保留现有玩法\n\n| 文件 | 用途 |\n| --- | --- |\n| main.ts | 入口 |\n\n```json\n{"name":"flybot"}\n```\n\n[说明](https://example.com/readme)\n\n![项目截图](https://example.com/preview.png)';
api.read.mockResolvedValue({ ...first, requests: [request({ response })] });

View File

@@ -43,6 +43,24 @@ beforeEach(() => {
afterEach(() => { cleanup(); vi.useRealTimers(); });
describe('teacher presence and periodic guidance', () => {
it('uses each published icon in the topbar, welcome and panel, and falls back after an image error', async () => {
const a = { teacher_id: 'a', version: 1, is_default: true, definition: { ...definition, icon_url: 'https://yuxi.example.test/api/storage/public/images/a.png' } };
const b = { teacher_id: 'b', version: 2, is_default: false, definition: { ...definition, name: '另一个智能体', icon_url: 'https://yuxi.example.test/api/storage/public/images/b.webp' } };
api.catalog.mockResolvedValue({ items: [a, b] });
const view = await mount();
const first = screen.getByRole('button', { name: '与运营配置的智能体聊天' }).querySelector('img')!;
const second = screen.getByRole('button', { name: '与另一个智能体聊天' }).querySelector('img')!;
expect(first).toHaveAttribute('src', a.definition.icon_url);
expect(second).toHaveAttribute('src', b.definition.icon_url);
fireEvent.error(first);
expect(first.src).toContain('avatar-06.png');
expect(second).toHaveAttribute('src', b.definition.icon_url);
await act(async () => fireEvent.click(screen.getByRole('button', { name: '与另一个智能体聊天' })));
const images = view.container.querySelectorAll('[data-testid="teacher-chat-panel"] img');
expect(images).toHaveLength(2);
for (const img of images) expect(img).toHaveAttribute('src', b.definition.icon_url);
});
it('shows every distributed agent in the header and preserves separate drafts without dispatching on switch', async () => {
const a = { teacher_id: 'method', version: 3, is_default: true, definition: { ...definition, config_id: 'method', name: '方法老师' } };
const b = { teacher_id: 'experience', version: 9, is_default: false, definition: { ...definition, config_id: 'experience', name: '体验朋友', avatar_id: 'avatar-09' } };