From 13336cfe12d241a098cd7f0f250c2fcc787ca760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 10:53:39 +0800 Subject: [PATCH 01/15] fix(home): hide Robot entry and balance three module cards --- .../20261013-hide-home-robot-6c1f8a3d.md | 67 +++++++++++ README.md | 2 +- src/pages/ModuleSelection/index.tsx | 6 +- src/styles/globals.css | 37 +++++- tests/e2e/main-navigation.spec.ts | 113 ++++++++---------- tests/unit/module-navigation.test.tsx | 23 ++-- 6 files changed, 167 insertions(+), 81 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261013-hide-home-robot-6c1f8a3d.md diff --git a/.project-docs/30-worklog/tasks/20261013-hide-home-robot-6c1f8a3d.md b/.project-docs/30-worklog/tasks/20261013-hide-home-robot-6c1f8a3d.md new file mode 100644 index 00000000..5a8ee659 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-hide-home-robot-6c1f8a3d.md @@ -0,0 +1,67 @@ +# Task: Hide Robot home entry and arrange three modules + +## Identity + +- Task ID: 20261013-hide-home-robot-6c1f8a3d +- Mode: Feature +- Branch: codex/20261013-hide-home-robot-6c1f8a3d-hide-home-robot +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-hide-home-robot-6c1f8a3d +- Base commit: 23ddce11da5c4c7aa31af81213a5aafd474cdad0 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Hide Robot only from the home module selection. Arrange Agents, Code and Canvas as three equal desktop cards and single-column compact cards, preserving existing artwork and navigation contracts. + +## Intent And Constraints + +- User explicitly requests Robot home-entry hiding and remaining-entry re-layout. Prior direction still defers temporary-app updates. Preserve global Robot definition, routes, permissions and capabilities; do not restart existing apps, publish, push, merge main or adopt peer changes. + +## Outcome + +- Robot is filtered only in home selection; global module definitions, Robot routes/APIs and policy access are unchanged. Agents, Code and Canvas share an equal-width desktop row beneath the centered welcome heading, with proportional card heights; below 768px they use a single vertical column of horizontal image/text cards. Existing artwork, click hit areas, disabled states and hover/focus behavior are preserved. README and navigation regressions match. + +## Verification + +- Module navigation unit tests: 9/9 passed, including hidden Robot with enabled permission, Canvas/Code routing, disabled policy, login/profile gates and Robot sidebar identity. +- Pinned pnpm 10.33.4: typecheck, scoped ESLint and production build:vite passed. Build repeated after visual review reduced extra card whitespace at 768px. +- Targeted Electron navigation fixtures: 3/3 passed (layout, retired Learning route, sidebar return plus direct Robot route); layout fixture rechecked after final CSS refinement, 1/1 passed. +- Layout geometry and screenshots checked at 1440, 1024, 768 and 375px: equal columns/gaps, compact stack, loaded artwork, text/page overflow checks, whole-card click area and stable hover/focus. +- git diff --check and task-aware project document drift check passed. Test Electron instances use independent temporary profiles; existing temporary/installed applications are unchanged. +- Dependencies reused through an ignored node_modules symlink. No runtime data, dependencies, build/test output or secrets included in commits. + +## Follow-ups + +- Integrate this source commit into the code chosen for the next temporary-app update. No current temporary-app update/restart, main merge, push or publication was performed. + +## Promotion Candidates + +- Target: integrated module-selection/current-state documentation. +- Proposal: home offers Agents, Code and Canvas only, with an equal desktop row and compact vertical stack. Robot remains a supported guarded route/capability whose home card is hidden. +- Evidence: explicit screenshot request, focused source diff, 9 unit tests, 3 Electron cases and four-size visual/geometry review. +- Future impact: later home UI work should preserve three visible entries unless explicitly changed. This supersedes historical four-card home presentation only; module policies and Robot capability remain unchanged. Human confirmation is already supplied; canonical promotion remains serialized integration work. + +## Project Context Loaded + +- Task ID: 20261013-hide-home-robot-6c1f8a3d; feature mode. +- Branch: codex/20261013-hide-home-robot-6c1f8a3d-hide-home-robot. +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-hide-home-robot-6c1f8a3d. +- Base: 23ddce11da5c4c7aa31af81213a5aafd474cdad0 (latest locally fetched origin/main). start exited 0; status JSON confirms identity, branch, root, mode and reservation. +- Read: AGENTS; entry/read-before-planning, memory-index and planning-gate; this task; positioning, current-state, decision-index/system-overview; business-rules and success-criteria; related accepted navigation/permission context. Same-task read-only subagent reviewed required context and every peer Scope/Intent/Promotion. +- Project goal: student/creator desktop creation with Main-owned authentication/service access and a single light visual system. +- Current integrated focus: combined personal Agent UI and runtime release, separate homepage module display. +- Active scope/constraints: only hide one home choice and balance the other three; keep underlying Robot access and module-policy denial semantics. +- Other active tasks: Canvas session diagnosis and knowledge workspace; retained teacher/docs tasks, including a legacy child-language task with undefined scope. No concrete homepage dependency or semantic conflict. All other worktrees remain read-only. +- Decisions: older four-card vertical home layout is superseded only by this user's explicit hide/re-layout instruction. Robot remains a supported guarded module; missing/denied permissions keep existing handling. +- Evidence/commitments: existing module-navigation unit tests and main-navigation Electron fixtures available; no external or timed obligation. +- Modules: ModuleSelection/index.tsx, scoped module card CSS in globals.css, navigation tests and README. +- Unknown/stale context: older AGENTS/module counts and integrated memory include historical three/four-module summaries; feature mode records promotion candidates instead of rewriting accepted docs. +- Gate result: Passed. + +## Plan + +- Filter Robot only in home rendering; center the greeting above a three-column desktop grid and use horizontal single-column cards below 768px. +- Preserve artwork, hover/focus stability, policy-disabled states, login/profile gates and navigation destinations. +- Adapt focused unit/Electron regressions and check actual layout at wide/narrow windows, typecheck, lint and production build. +- Update README and this task record; commit a tested source handoff without changing current app runtime. diff --git a/README.md b/README.md index 9447b4ef..ef527d5f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,源码提供四个模块入口;云智能体需配套配置 WS/Yuxi 服务。模块入口页按 Agents、Code、Canvas、Robot 排列,统一采用横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。三个入口在桌面等宽并排、插画在上文字在下,小窗口改为纵向排列的横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 91989e4a..80fe3c57 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -57,8 +57,8 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea Makelore -
-
+
+

{userDisplayName ? `${userDisplayName},欢迎回来!` : '欢迎回来!'}

@@ -70,7 +70,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea aria-label="AI 模块列表" className="module-selection-options" > - {aiModules.map((module) => { + {aiModules.filter((module) => module.id !== 'robot').map((module) => { const content = moduleSelectionContent[module.id]; const enabledByPolicy = isAiModuleAllowed(module.id, moduleAccess); const enabled = module.enabled && enabledByPolicy; diff --git a/src/styles/globals.css b/src/styles/globals.css index ce9b88c4..46051094 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -423,7 +423,8 @@ display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--module-option-gap); - width: min(100%, 43rem); + width: min(100%, 69rem); + margin-inline: auto; min-width: 0; justify-self: center; align-items: stretch; @@ -497,7 +498,7 @@ flex-direction: column; justify-content: center; padding: clamp(0.75rem, 1.6vw, 1.25rem); - padding-right: clamp(2.75rem, 5vw, 4rem); + padding-right: clamp(0.75rem, 1.6vw, 1.25rem); } .module-option-card-title { @@ -517,6 +518,38 @@ line-height: 1.25; } +@media (min-width: 768px) { + .module-selection-options { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .module-selection-options .module-option-card { + height: auto; + min-height: clamp(17.5rem, 24vw, 21rem); + } + + .module-selection-options .module-option-card-surface { + position: relative; + flex-direction: column; + overflow: hidden; + height: 100%; + } + + .module-selection-options .module-option-card-image { + flex: 0 0 auto; + width: calc(100% - 0.125rem); + height: auto; + aspect-ratio: 16 / 10; + margin: 0.0625rem 0.0625rem 0; + border-radius: 0.75rem 0.75rem 0 0; + } + + .module-selection-options .module-option-card-content { + justify-content: flex-start; + padding: 1.25rem; + } +} + .module-option-card.module-option-card-disabled::before, .module-option-card.module-option-card-disabled::after { display: none; diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index cf9f9590..974eed21 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -200,7 +200,7 @@ test.describe('Makelore module navigation without setup flow', () => { } }); - test('keeps the four module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { + test('lays out three module cards in a desktop row and a compact mobile stack', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -209,17 +209,18 @@ test.describe('Makelore module navigation without setup flow', () => { const moduleCards = page.locator('[data-testid^="ai-module-option-"]'); await expect(moduleCards.locator('.module-option-card-title')).toHaveText([ - 'Agents 智能体', 'Code 编程', 'Canvas 设计', 'Robot 机器', + 'Agents 智能体', 'Code 编程', 'Canvas 设计', ]); + await expect(page.getByTestId('ai-module-option-robot')).toHaveCount(0); await expect(moduleCards.first().locator('.module-option-card-description')).toHaveText('打造你想象中的AI助手'); - await expect(moduleCards.locator('img')).toHaveCount(4); + await expect(moduleCards.locator('img')).toHaveCount(3); await expect.poll(async () => await moduleCards.locator('img').evaluateAll((images) => images.every((image) => image.complete && image.naturalWidth > 0), )).toBe(true); - await page.screenshot({ path: test.info().outputPath('module-entry-artwork.png') }); const readCardMetrics = async () => await moduleCards.evaluateAll((elements) => elements.map((element) => { const bounds = element.getBoundingClientRect(); const image = element.querySelector('.module-option-card-image')?.getBoundingClientRect(); + const content = element.querySelector('.module-option-card-content')?.getBoundingClientRect(); return { left: bounds.left, top: bounds.top, @@ -227,70 +228,59 @@ test.describe('Makelore module navigation without setup flow', () => { height: bounds.height, imageWidth: image?.width ?? 0, imageHeight: image?.height ?? 0, + imageLeft: image?.left ?? 0, + imageTop: image?.top ?? 0, + contentLeft: content?.left ?? 0, + contentTop: content?.top ?? 0, }; })); - const initialMetrics = await readCardMetrics(); - expect(initialMetrics).toHaveLength(4); await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0); await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0); - await expect.poll(async () => await moduleCards.first().evaluate((element) => { - const surface = element.querySelector('.module-option-card-surface'); - const frame = element.querySelector('.module-option-card-frame'); - if (!surface || !frame) return false; - const surfaceStyle = getComputedStyle(surface); - const frameStyle = getComputedStyle(frame); - const surfaceInsets = [surfaceStyle.top, surfaceStyle.right, surfaceStyle.bottom, surfaceStyle.left] - .map((value) => Number.parseFloat(value)); - return frameStyle.display === 'none' - && surfaceInsets.every((value) => value === 0) - && surfaceStyle.transform === 'none'; - })).toBe(true); - const initialImageStyles = await moduleCards.first().evaluate((element) => { - const image = element.querySelector('.module-option-card-image'); - const cardHeight = element.getBoundingClientRect().height; - if (!image) return null; - const style = getComputedStyle(image); - return { - height: Number.parseFloat(style.height), - cardHeight, - marginTop: Number.parseFloat(style.marginTop), - marginBottom: Number.parseFloat(style.marginBottom), - marginLeft: Number.parseFloat(style.marginLeft), - }; - }); - expect(initialImageStyles).not.toBeNull(); - expect(initialImageStyles!.height).toBeLessThan(initialImageStyles!.cardHeight); - expect(initialImageStyles!.marginTop).toBe(1); - expect(initialImageStyles!.marginBottom).toBe(1); - expect(initialImageStyles!.marginTop).toBe(initialImageStyles!.marginBottom); - expect(initialImageStyles!.marginLeft).toBeGreaterThan(0); - expect(initialImageStyles!.marginLeft).toBe(initialImageStyles!.marginTop); - expect(Math.max(...initialMetrics.map((card) => card.left)) - Math.min(...initialMetrics.map((card) => card.left))).toBeLessThan(1); - expect(initialMetrics.map((card) => card.top)).toEqual( - [...initialMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top), - ); - - const initialWidth = initialMetrics[0]?.width ?? 0; - expect(initialMetrics[0]?.imageWidth ?? 0).toBeGreaterThan(initialWidth * 0.24); - expect(initialMetrics[0]?.imageWidth ?? 0).toBeLessThan(initialWidth / 2); - expect(initialMetrics[0]?.imageHeight ?? 0).toBeGreaterThan((initialMetrics[0]?.height ?? 0) * 0.75); - await app.evaluate(({ BrowserWindow }) => { - BrowserWindow.getAllWindows()[0]?.setSize(1100, 700); + BrowserWindow.getAllWindows()[0]?.setMinimumSize(360, 600); }); - await expect.poll(async () => (await readCardMetrics())[0]?.width ?? 0).toBeLessThan(initialWidth - 1); + for (const width of [1440, 1024, 768]) { + await app.evaluate(({ BrowserWindow }, windowWidth) => BrowserWindow.getAllWindows()[0]?.setSize(windowWidth, 900), width); + await expect.poll(() => page.evaluate(() => innerWidth)).toBe(width); + const cards = await readCardMetrics(); + expect(cards).toHaveLength(3); + expect(Math.max(...cards.map(card => card.top)) - Math.min(...cards.map(card => card.top))).toBeLessThan(1); + expect(Math.max(...cards.map(card => card.width)) - Math.min(...cards.map(card => card.width))).toBeLessThan(1); + const gaps = cards.slice(1).map((card, index) => card.left - cards[index]!.left - cards[index]!.width); + expect(Math.min(...gaps)).toBeGreaterThan(0); + expect(Math.abs(gaps[0]! - gaps[1]!)).toBeLessThan(1); + for (const card of cards) { + expect(card.left).toBeGreaterThanOrEqual(0); + expect(card.left + card.width).toBeLessThanOrEqual(width); + expect(card.contentTop).toBeGreaterThanOrEqual(card.imageTop + card.imageHeight - 1); + } + await expect(page.getByRole('heading', { name: '今天,想创作什么?' })).toHaveCSS('text-align', 'center'); + expect(await moduleCards.evaluateAll(elements => elements.every(element => { + const bounds = element.getBoundingClientRect(); + return [[2, 2], [bounds.width - 2, bounds.height - 2], [bounds.width / 2, bounds.height / 2]].every(([x, y]) => + element.contains(document.elementFromPoint(bounds.left + x!, bounds.top + y!)), + ); + }))).toBe(true); + await page.screenshot({ path: test.info().outputPath(`module-entry-${width}.png`) }); + } - const resizedMetrics = await readCardMetrics(); - expect(resizedMetrics).toHaveLength(4); - expect(Math.max(...resizedMetrics.map((card) => card.left)) - Math.min(...resizedMetrics.map((card) => card.left))).toBeLessThan(1); - expect(resizedMetrics.map((card) => card.top)).toEqual( - [...resizedMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top), - ); - expect(resizedMetrics[0]!.imageWidth).toBeGreaterThan(resizedMetrics[0]!.width * 0.24); - expect(resizedMetrics[0]!.imageWidth).toBeLessThan(resizedMetrics[0]!.width / 2); - expect(resizedMetrics[0]!.imageHeight).toBeGreaterThan(resizedMetrics[0]!.height * 0.75); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.setSize(375, 800)); + await expect.poll(() => page.evaluate(() => innerWidth)).toBe(375); + const compactCards = await readCardMetrics(); + expect(Math.max(...compactCards.map(card => card.left)) - Math.min(...compactCards.map(card => card.left))).toBeLessThan(1); + for (const [index, card] of compactCards.entries()) { + if (index > 0) expect(card.top).toBeGreaterThan(compactCards[index - 1]!.top + compactCards[index - 1]!.height); + expect(card.imageWidth).toBeLessThan(card.width / 2); + expect(card.imageHeight).toBeGreaterThan(card.height * 0.75); + expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + expect(await moduleCards.locator('.module-option-card-title, .module-option-card-description').evaluateAll(elements => + elements.every(element => element.scrollWidth <= element.clientWidth), + )).toBe(true); + await page.screenshot({ path: test.info().outputPath('module-entry-375.png') }); await moduleCards.first().hover(); await moduleCards.first().focus(); @@ -323,7 +313,7 @@ test.describe('Makelore module navigation without setup flow', () => { const page = await getStableWindow(app); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); - await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(4); + await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); await expect(page.getByTestId('ai-module-option-learning')).toHaveCount(0); await page.evaluate(() => { window.location.hash = '#/learning'; @@ -385,7 +375,8 @@ test.describe('Makelore module navigation without setup flow', () => { await moduleHomeTrigger.click(); await expect(page).toHaveURL(/\/module-select$/); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); - await page.getByTestId('ai-module-option-robot').click(); + await expect(page.getByTestId('ai-module-option-robot')).toHaveCount(0); + await page.evaluate(() => { window.location.hash = '#/ai-hardware'; }); await expect(page).toHaveURL(/\/ai-hardware$/); await expect(page.getByTestId('ai-hardware-page')).toBeVisible(); await expect(page.getByTestId('sidebar-robot-navigation')).toBeVisible(); diff --git a/tests/unit/module-navigation.test.tsx b/tests/unit/module-navigation.test.tsx index 357360d2..3128e9b2 100644 --- a/tests/unit/module-navigation.test.tsx +++ b/tests/unit/module-navigation.test.tsx @@ -52,7 +52,7 @@ describe('AI module navigation', () => { }); }); - it('shows the four supported module choices and routes Canvas from the chooser', async () => { + it('shows Agents, Code and Canvas on the home page and routes Canvas from the chooser', async () => { render( @@ -64,18 +64,16 @@ describe('AI module navigation', () => { expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: '今天,想创作什么?' })).toHaveClass('whitespace-nowrap'); expect(screen.getByTestId('ai-module-options')).toHaveClass('module-selection-options'); expect(await screen.findByText('小明,欢迎回来!')).toBeInTheDocument(); - expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card', 'module-option-card-horizontal'); - for (const moduleId of ['programming', 'painting', 'robot', 'cloud_agents'] as const) { + expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card'); + for (const moduleId of ['programming', 'painting', 'cloud_agents'] as const) { expect(screen.getByTestId(`ai-module-option-${moduleId}`).querySelector('.module-option-artwork')).toBeNull(); } expect(screen.getAllByTestId(/^ai-module-option-/u).map((option) => option.dataset.testid)).toEqual([ 'ai-module-option-cloud_agents', 'ai-module-option-programming', 'ai-module-option-painting', - 'ai-module-option-robot', ]); expect(screen.queryByTestId('ai-module-option-learning')).not.toBeInTheDocument(); expect(screen.queryByText('先选一个入口。进入后,你还可以从左下角随时切换到其他模块。')).not.toBeInTheDocument(); @@ -89,12 +87,7 @@ describe('AI module navigation', () => { expect(screen.getByText('Canvas 设计')).toBeInTheDocument(); expect(screen.getByAltText('数位板设计创作')).toBeInTheDocument(); expect(screen.getByTestId('ai-module-option-painting')).toHaveTextContent('用灵感描绘色彩缤纷的世界'); - const robotOption = screen.getByTestId('ai-module-option-robot'); - expect(robotOption).toBeEnabled(); - expect(robotOption).not.toHaveClass('module-option-card-disabled'); - expect(robotOption).toHaveTextContent('Robot 机器'); - expect(robotOption).toHaveTextContent('制作你的第一个机器人小伙伴'); - expect(screen.getByAltText('青少年管理机器人硬件与设备绑定')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-module-option-robot')).not.toBeInTheDocument(); expect(screen.getByAltText('个人云智能体')).toHaveAttribute('src', expect.stringContaining('module-agents.webp')); expect(screen.getByAltText('个人云智能体')).toHaveClass('module-option-card-image-media', 'object-cover'); @@ -102,7 +95,8 @@ describe('AI module navigation', () => { expect(await screen.findByTestId('location-path')).toHaveTextContent('/image-canvas'); }); - it('routes Robot from the chooser to the AI hardware workspace', async () => { + it('hides Robot from the chooser even when its module permission is enabled', () => { + useAuthStore.setState({ moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); render( @@ -112,8 +106,9 @@ describe('AI module navigation', () => { , ); - fireEvent.click(screen.getByTestId('ai-module-option-robot')); - expect(await screen.findByTestId('location-path')).toHaveTextContent('/ai-hardware'); + expect(screen.queryByTestId('ai-module-option-robot')).not.toBeInTheDocument(); + expect(screen.queryByText('Robot 机器')).not.toBeInTheDocument(); + expect(screen.queryByAltText('青少年管理机器人硬件与设备绑定')).not.toBeInTheDocument(); }); it('greys out a disabled module and does not navigate when it is clicked', async () => { From 8289f27f3e53ef455b656f1c43d2620d235d5014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 11:32:07 +0800 Subject: [PATCH 02/15] fix(agents): explain missing channel targets --- .../20261013-channel-empty-hint-4e8a2b6c.md | 65 ++++++++++++++ README.md | 2 +- src/pages/CloudAgents/ChannelAccounts.tsx | 12 ++- tests/e2e/cloud-agent-channels.spec.ts | 63 +++++++++++++ tests/unit/channel-accounts-page.test.tsx | 89 +++++++++++++++++-- 5 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261013-channel-empty-hint-4e8a2b6c.md diff --git a/.project-docs/30-worklog/tasks/20261013-channel-empty-hint-4e8a2b6c.md b/.project-docs/30-worklog/tasks/20261013-channel-empty-hint-4e8a2b6c.md new file mode 100644 index 00000000..c6bdb561 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-channel-empty-hint-4e8a2b6c.md @@ -0,0 +1,65 @@ +# Task: Explain unavailable WeChat channel binding without an Agent + +## Identity + +- Task ID: 20261013-channel-empty-hint-4e8a2b6c +- Mode: Feature +- Branch: codex/20261013-channel-empty-hint-4e8a2b6c-channel-empty-hint +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-channel-empty-hint-4e8a2b6c +- Base commit: 13336cfe12d241a098cd7f0f250c2fcc787ca760 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Show a visible explanation on the Agent channel page when there is no available published/enabled Agent, with a route to the existing My Agents list. +- Clarify the same prerequisite in the Add/Manage WeChat dialog. Preserve existing channel cards, management and eligibility rules. +- Synchronize README and focused unit/Electron UI coverage. + +## Project Context Loaded + +- Concurrent Task Gate: Passed. `task_context.py status --json` matches this task ID, worktree, branch, feature mode and base commit; primary checkout belongs to a separate integration task and remains untouched. +- Planning Gate: Passed. Read the entry instructions and active record; retained already-loaded memory-index, project-positioning, current-state, decision-index, system-overview, business-rules and success-criteria from the same canonical snapshot at `23ddce11`. Base `13336cf` adds only this chat's already-verified homepage feature and its own task record. +- Accepted authority: ADR-2026-09-11-personal-cloud-agents; user-level channel accounts require an available published Agent, and enabling/connecting remains explicit through the Main-owned API. +- Peers: knowledge workspace changes concern knowledge/files; create diagnosis has no declared source dependency; Canvas diagnosis is separate. This task edits ChannelAccounts and its tests only, plus README and this record. No cross-task dependency or canonical documentation change. +- Source evidence: sidebar Channel button already navigates normally; the disabled Next step requires `targetAgent`. Successful Agent reads filter out unpublished and disabled Agents; the current modal has a vague prerequisite but the channel page does not explain it. +- Existing create flow uses name/avatar then configuration and “开始使用”; its former “使用与接入” navigation is absent, so do not refer users to that stale screen. + +## Intent And Constraints + +- User asks for a helpful explanation when no Agent is available to connect. Use “还没有可连接的智能体” and explain creating/configuring and clicking “开始使用”, with “去我的智能体”. +- Show the explanation only after successful loading, and never replace existing WeChat management cards. +- Do not change any Agent/channel API, publish/enable automatically, send a message, invoke paid services, update/restart the current temporary application, merge primary, or push. +- The earlier Robot homepage feature is already present in this base; preserve it for later combined integration. + +## Plan + +1. Add the shared explanatory panel to the successful empty-target state and existing dialog. +2. Add behavior coverage for absence, draft-only eligibility, navigation without mutation, load failure and existing account access. +3. Run focused tests, typecheck, scoped lint, build and fixture-only Electron UI validation; record and commit a clean handoff. + +## Outcome + +- ChannelAccounts shows “还没有可连接的智能体” after a successful read with no available target, followed by “先创建一个智能体,配置完成后点击‘开始使用’,就能连接到微信。” (UI uses double curly quotes). +- “去我的智能体” navigates to `/cloud-agents?view=mine`. The same panel explains the disabled Next step in the Add/Manage dialog. +- With no accounts or targets, the explanatory panel replaces the premature “添加第一个微信” empty state; existing accounts retain their cards and management actions. +- No backend, eligibility, publication, enablement, paid service or current temporary-application changes. README synchronized. +- Read-only independent review found no blocking issue. + +## Verification + +- Focused `channel-accounts-page.test.tsx`: 34/34 passed. Covers no Agents/draft-only/disabled-only, CTA navigation with no binding writes, modal hint, successful-load gating, failed initial read and preserved existing-account management. +- Exact pinned pnpm 10.33.4: typecheck passed; scoped lint of component and both test files passed; build:vite passed. +- Electron `cloud-agent-channels.spec.ts`: 2/2 passed with fixture-only API responses. New empty-channel test enters from the sidebar, checks both explanations, checks disabled Next, then returns to My Agents without writes. Existing connect/switch behavior remains passing. +- Visually checked captured screenshots at 1366×900 and 980×680; text/button fit and main content has no horizontal overflow. Local evidence: `test-results/cloud-agent-channels-guide-e93c9-e-sidebar-back-to-My-Agents/wechat-no-agents-desktop.png` and `wechat-no-agents-compact.png`. +- `git diff --check` passed; project-doc ownership drift check passed. + +## Follow-ups + +- Integrate this branch when combining changes for the user's new temporary application. Its base includes prior homepage Robot-hiding commit `13336cf`; do not update the existing temporary application. +- Published-but-disabled Agents remain excluded by the existing contract; this task does not restore the removed use/access controls or change Agent lifecycle behavior. + +## Promotion Candidates + +- Current state / success criteria: channel page explains the available-Agent prerequisite before the user enters a blocked binding step, and links to My Agents. Promote only at integration. +- No new architectural decision or backend contract. diff --git a/README.md b/README.md index ef527d5f..e56e421e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 - Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。 + Agents 侧栏的「渠道」用于把自己的智能体接到微信。每个微信一张卡,展示备注、绑定的智能体头像/名称和是否可用;一个账号同时只有一个目标,多个账号可以使用同一智能体。没有可连接的智能体时,渠道页和添加窗口说明需先创建、配置并点击“开始使用”,提供“去我的智能体”入口;加载失败不显示该空态,已有微信卡片仍可管理。添加流程为选择自己已开始使用的智能体、扫码连接、明确完成绑定并启用;备注可选,扫码确认后收起二维码与验证码,未确认或启用失败不显示完成。管理已有账号不会重新创建账号;更换目标保留微信登录与原启停状态,解除智能体绑定清空目标并停用但保留微信登录,暂停保留绑定/登录并停止该渠道当前任务,断开微信连接则需要重新扫码。更换、解除和断开有明确确认,历史保留。渠道配置页不展示微信对话或活动,不读取这些列表;聊天在微信中进行,既有云端历史、文件、审批、费用和账号隔离合同不变。页面保留加载失败重试、未确认操作恢复,创建后绑定失败可继续使用原账号,重试沿用原操作身份;仅添加/扫码不自动启用。实际微信身份与重复扫码识别仍由云端负责,客户端不以备注推测同一微信。渠道触发的应计费模型调用仍由智能体创建者支付,Main 持有认证与恢复;用户无需填写服务凭据。应用 API 管理独立于微信渠道。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace,并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器,AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。任务中的已生成图片可通过“放大查看图片”按钮直接打开大图,支持适应窗口、原始尺寸及 Esc 关闭,无需先下载。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 diff --git a/src/pages/CloudAgents/ChannelAccounts.tsx b/src/pages/CloudAgents/ChannelAccounts.tsx index 95dde3f9..10c3b269 100644 --- a/src/pages/CloudAgents/ChannelAccounts.tsx +++ b/src/pages/CloudAgents/ChannelAccounts.tsx @@ -251,6 +251,11 @@ export function ChannelAccounts() { {flow?.target && !targetAgent && selected?.target_agent_slug === flow.target && } {agents.map(agent => )} ; + const agentRequiredHint =
+

还没有可连接的智能体

+

先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。

+ +
; return
@@ -266,12 +271,13 @@ export function ChannelAccounts() {
{!flow && messages} + {!loading && loaded && !agents.length && !flow && agentRequiredHint} {loading ?
正在查看你的微信绑定…
: !loaded ?

暂时没能读到微信绑定,请刷新后再试。

- : !accounts.length ?
+ : !accounts.length ? (agents.length > 0 &&

把你的智能体带到微信吧

绑定后,就可以回到微信和它聊聊。每个微信绑定一个智能体,也可以把多个微信交给同一个智能体。

-
:
+
) :
{accounts.map(account => { const agent = agents.find(item => item.slug === account.target_agent_slug); const state = accountState(account); @@ -298,7 +304,7 @@ export function ChannelAccounts() { {recoveryBlocked &&

有一次绑定操作的结果需要确认,请先关闭此窗口并处理页面上的恢复提醒。

} {flow.step === 'choose' && <> {flow.mode === 'manage' && selectedState &&
微信连接与绑定{selectedState.text}
} - {!agents.length ?

先准备一个可以使用的智能体,再把它带到微信里。

+ {!agents.length ? agentRequiredHint : } {flow.mode === 'new' && !flow.accountId && } {flow.mode === 'manage' && selected?.blockers.length ?

{selected.blockers.map(item => blockerText[item] ?? '连接暂时不可用,请刷新后重试').join(';')}

: null} diff --git a/tests/e2e/cloud-agent-channels.spec.ts b/tests/e2e/cloud-agent-channels.spec.ts index 8b0190e4..4c21a10c 100644 --- a/tests/e2e/cloud-agent-channels.spec.ts +++ b/tests/e2e/cloud-agent-channels.spec.ts @@ -1,5 +1,68 @@ import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +test('guides an empty channel workspace from the sidebar back to My Agents', async ({ launchElectronApp }, testInfo) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + const applicationUrl = page.url(); + await page.goto('about:blank'); + await app.evaluate(({ ipcMain, BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setSize(1366, 900); + const calls: { method: string; path: string; operation?: string }[] = []; + Object.assign(globalThis, { cloudChannelEmptyFixtureCalls: calls }); + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => { + const path = request.path ?? ''; + if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'ui-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } }); + if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }); + if (!path.startsWith('/api/cloud-agents/')) return result({ success: true }); + const body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body ?? {}; + calls.push({ method: request.method ?? 'GET', path, operation: body.operation }); + if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); + if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [], next_cursor: null }); + if (path.endsWith('/actions') && body.operation === 'channelAccounts') return result({ items: [] }); + throw new Error('Unexpected empty channel fixture request ' + path); + }); + }); + await page.goto(applicationUrl); + await page.getByTestId('ai-module-option-cloud_agents').click(); + const sidebar = page.getByTestId('sidebar-cloud-agents-navigation'); + await sidebar.getByRole('button', { name: '渠道', exact: true }).click(); + await expect(page).toHaveURL(/\/cloud-agents\/channels$/); + const hint = page.getByRole('heading', { name: '还没有可连接的智能体', exact: true }); + await expect(hint).toBeVisible(); + await expect(page.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '添加第一个微信', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); + await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-desktop.png'), fullPage: true }); + + await page.getByRole('button', { name: '添加微信', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '添加微信', exact: true }); + await expect(dialog.getByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + await expect(dialog.getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled(); + await dialog.getByRole('button', { name: '关闭', exact: true }).click(); + + await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(800, 600); window.setSize(980, 680); }); + await expect(hint).toBeVisible(); + await expect.poll(() => page.getByTestId('main-content').evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); + await page.screenshot({ path: testInfo.outputPath('wechat-no-agents-compact.png'), fullPage: true }); + await page.getByRole('button', { name: '去我的智能体', exact: true }).click(); + await expect(page).toHaveURL(/\/cloud-agents\?view=mine$/); + await expect(page.getByTestId('cloud-agents-page')).toBeVisible(); + await expect(page.getByRole('heading', { name: '做一个属于你的 AI 助手吧', exact: true })).toBeVisible(); + await expect(sidebar.getByRole('button', { name: '我的智能体', exact: true })).toHaveAttribute('aria-current', 'page'); + const calls = await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelEmptyFixtureCalls: { method: string; operation?: string }[] }).cloudChannelEmptyFixtureCalls); + expect(calls.filter(call => call.operation).map(call => call.operation)).toEqual(['channelAccounts']); + expect(calls.every(call => call.method === 'GET' || call.operation === 'channelAccounts')).toBe(true); + } finally { + await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); + await closeElectronApp(app); + } +}); + test('WeChat binding cards connect an account and switch its Agent through the channel workspace', async ({ launchElectronApp }, testInfo) => { const app = await launchElectronApp({ skipSetup: true }); const agentSlug = 'ml-' + 'a'.repeat(32); diff --git a/tests/unit/channel-accounts-page.test.tsx b/tests/unit/channel-accounts-page.test.tsx index e25e3bb9..9e8fe991 100644 --- a/tests/unit/channel-accounts-page.test.tsx +++ b/tests/unit/channel-accounts-page.test.tsx @@ -1,5 +1,5 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { ChannelAccounts } from '@/pages/CloudAgents/ChannelAccounts'; import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudUserChannelAccount, type CloudWechatBindState } from '../../shared/cloud-agents'; @@ -23,7 +23,16 @@ let verifyStatus: CloudWechatBindState['status']; const calls = (operation: string) => api.call.mock.calls.filter(([name]) => name === operation); const writes = () => api.call.mock.calls.filter(([name]) => !['channelAccounts', 'channelAccountWechatBindStatus'].includes(name)); -function renderPage(entry = '/cloud-agents/channels') { return render(); } +function LocationProbe() { + const location = useLocation(); + return {location.pathname + location.search}; +} +function renderPage(entry = '/cloud-agents/channels') { + return render( + } /> + 智能体列表
} /> + ); +} async function addWechat(fakeTimers = false) { fireEvent.click(await screen.findByRole('button', { name: '添加微信', exact: true })); const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true }); @@ -80,6 +89,69 @@ beforeEach(() => { }); afterEach(() => { cleanup(); vi.useRealTimers(); }); +it.each([ + ['no Agents', []], + ['only drafts', [{ ...agentA, published_version: null }]], + ['only disabled Agents', [{ ...agentA, enabled: false }]], +] satisfies [string, CloudAgentDraft[]][])('guides a workspace with %s to My Agents without starting a binding', async (_name, unavailable) => { + agents = unavailable; accounts = []; + renderPage(); + expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(screen.getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); + expect(screen.queryByRole('button', { name: '添加第一个微信', exact: true })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); + fireEvent.click(screen.getByRole('button', { name: '去我的智能体', exact: true })); + expect(await screen.findByText('智能体列表', { exact: true })).toBeVisible(); + expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine'); + expect(writes()).toEqual([]); +}); + +it('uses the same preparation hint in the add dialog without duplicating it on the page', async () => { + agents = []; accounts = []; + renderPage(); + await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true }); + fireEvent.click(screen.getByRole('button', { name: '添加微信', exact: true })); + const dialog = screen.getByRole('dialog', { name: '添加微信', exact: true }); + expect(within(dialog).getByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(within(dialog).getByText('先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。', { exact: true })).toBeVisible(); + expect(screen.getAllByRole('heading', { name: '还没有可连接的智能体', exact: true, hidden: true })).toHaveLength(1); + expect(within(dialog).getByRole('button', { name: '下一步:扫码连接', exact: true })).toBeDisabled(); + fireEvent.click(within(dialog).getByRole('button', { name: '去我的智能体', exact: true })); + expect(await screen.findByText('智能体列表', { exact: true })).toBeVisible(); + expect(screen.getByTestId('location-path')).toHaveTextContent('/cloud-agents?view=mine'); + expect(writes()).toEqual([]); +}); + +it('waits for the Agent read before showing a preparation hint', async () => { + accounts = []; + let finish!: (value: { agents: CloudAgentDraft[]; next_cursor: null }) => void; + api.list.mockImplementation(() => new Promise(resolve => { finish = resolve; })); + renderPage(); + expect(screen.getByText('正在查看你的微信绑定…', { exact: true })).toBeVisible(); + expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '去我的智能体', exact: true })).not.toBeInTheDocument(); + await act(async () => finish({ agents: [], next_cursor: null })); + expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(writes()).toEqual([]); +}); + +it('keeps existing WeChat cards manageable when no connectable Agent remains', async () => { + agents = []; + renderPage(); + expect(await screen.findByRole('heading', { name: '还没有可连接的智能体', exact: true })).toBeVisible(); + expect(screen.getByRole('article', { name: '原有微信微信', exact: true })).toBeVisible(); + expect(screen.getByRole('article', { name: '备用微信微信', exact: true })).toBeVisible(); + const dialog = await manage(); + expect(within(dialog).getByRole('button', { name: '暂停使用', exact: true })).toBeEnabled(); + expect(within(dialog).getByRole('button', { name: '解除绑定', exact: true })).toBeEnabled(); + expect(within(dialog).getByRole('button', { name: '断开微信连接', exact: true })).toBeEnabled(); + fireEvent.click(within(dialog).getByRole('button', { name: '暂停使用', exact: true })); + await waitFor(() => expect(calls('pauseChannelAccount')).toHaveLength(1)); + expect(accounts[0]).toMatchObject({ enabled: false, health: 'connected', target_agent_slug: agentA.slug }); + expect(calls('createChannelAccount')).toHaveLength(0); + expect(calls('routeChannelAccount')).toHaveLength(0); +}); + it('shows binding cards and never fetches or embeds conversation or activity panels', async () => { renderPage(); const card = await screen.findByRole('article', { name: '原有微信微信', exact: true }); @@ -89,6 +161,7 @@ it('shows binding cards and never fetches or embeds conversation or activity pan expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByText('微信对话', { exact: true })).not.toBeInTheDocument(); expect(screen.queryByText('活动记录', { exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); expect(writes()).toEqual([]); expect(api.call.mock.calls.every(([operation]) => operation === 'channelAccounts')).toBe(true); }); @@ -295,13 +368,19 @@ it('does not call a blocked or unconfirmed existing account ready to use', async }); -it('keeps adding unavailable after a failed initial read instead of showing a false empty account list', async () => { - api.call.mockRejectedValue(new Error('微信列表暂时不可用')); +it.each(['accounts', 'agents'])('keeps adding unavailable after a failed initial %s read instead of showing a false empty list', async source => { + agents = []; + const message = source === 'accounts' ? '微信列表暂时不可用' : '智能体列表暂时不可用'; + if (source === 'accounts') api.call.mockRejectedValue(new Error(message)); + else api.list.mockRejectedValue(new Error(message)); renderPage(); - await screen.findByText('微信列表暂时不可用', { exact: true }); + await screen.findByText(message, { exact: true }); expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeDisabled(); expect(screen.queryByText('还没有微信绑定', { exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: '还没有可连接的智能体', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '去我的智能体', exact: true })).not.toBeInTheDocument(); installMock(); + api.list.mockImplementation(async () => ({ agents, next_cursor: null })); fireEvent.click(screen.getByRole('button', { name: '刷新微信列表', exact: true })); expect(await screen.findByRole('article', { name: '原有微信微信', exact: true })).toBeVisible(); expect(screen.getByRole('button', { name: '添加微信', exact: true })).toBeEnabled(); From 7c51bb4a008e388071ec37156adeae80eff24167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 12:11:56 +0800 Subject: [PATCH 03/15] feat(trial): assemble completed Agent workspace updates --- ...20261013-combined-trial-update-c42d19a7.md | 65 ++++ README.md | 7 +- electron/api/routes/cloud-agents.ts | 5 +- electron/services/cloud-agent-journal.ts | 7 +- electron/services/cloud-agent-operations.ts | 19 +- electron/services/cloud-agents.ts | 118 +++++- shared/cloud-agents.ts | 8 + .../CloudAgents/CloudChannelConversations.tsx | 5 +- src/pages/CloudAgents/CloudChat.tsx | 56 ++- src/pages/CloudAgents/CloudKnowledge.tsx | 26 +- .../CloudKnowledgeAttachmentPicker.tsx | 280 ++++++++++++++ .../CloudAgents/CloudKnowledgeDocuments.tsx | 62 +--- .../CloudAgents/CloudKnowledgeSelector.tsx | 18 +- src/pages/CloudAgents/CloudLifecycle.tsx | 2 +- src/pages/CloudAgents/CloudMessageBody.tsx | 36 ++ src/pages/CloudAgents/CloudRecovery.tsx | 11 +- src/pages/CloudAgents/CloudSchedules.tsx | 2 +- src/pages/CloudAgents/ConfigurationFields.tsx | 2 +- src/pages/CloudAgents/agent-config.css | 69 +++- src/pages/CloudAgents/cloud-chat-stream.ts | 133 ++++--- src/pages/CloudAgents/index.tsx | 24 +- tests/e2e/cloud-agent-chat-layout.spec.ts | 6 +- tests/e2e/cloud-agent-home.spec.ts | 2 +- tests/e2e/cloud-agent-knowledge.spec.ts | 286 +++++++++++++-- .../e2e/cloud-agent-navigation-layout.spec.ts | 4 +- .../e2e/cloud-agent-platform-avatars.spec.ts | 4 +- tests/e2e/cloud-agents.spec.ts | 50 ++- tests/e2e/cloud-chat-rendering.spec.ts | 101 +++++ tests/unit/cloud-agent-avatar-routes.test.ts | 25 +- tests/unit/cloud-agents-main.test.ts | 153 ++++++++ tests/unit/cloud-agents-page.test.tsx | 149 +++++++- tests/unit/cloud-agents-workflows.test.tsx | 4 +- tests/unit/cloud-chat-reasoning.test.tsx | 38 +- tests/unit/cloud-chat-rendering.test.tsx | 185 ++++++++++ tests/unit/cloud-chat-stream.test.ts | 14 +- tests/unit/cloud-knowledge-import.test.tsx | 345 ++++++++++++++++++ tests/unit/cloud-knowledge-picker.test.tsx | 18 +- tests/unit/cloud-knowledge-status.test.tsx | 18 +- 38 files changed, 2081 insertions(+), 276 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md create mode 100644 src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx create mode 100644 src/pages/CloudAgents/CloudMessageBody.tsx create mode 100644 tests/e2e/cloud-chat-rendering.spec.ts create mode 100644 tests/unit/cloud-chat-rendering.test.tsx create mode 100644 tests/unit/cloud-knowledge-import.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md new file mode 100644 index 00000000..0c825756 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -0,0 +1,65 @@ +# Task: Update trial application with completed Agent and homepage changes + +## Identity + +- Task ID: 20261013-combined-trial-update-c42d19a7 +- Mode: Feature +- Branch: codex/20261013-combined-trial-update-c42d19a7-combined-trial-update +- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-combined-trial-update-c42d19a7 +- Base commit: 8289f27f3e53ef455b656f1c43d2620d235d5014 +- Owner: codex +- Status: Planning + +## Scope + +- Assemble and validate completed homepage and Agent changes, then update the existing local temporary application with both Main and Renderer while retaining its profile and login. +- Keep the current temporary application as the target; no production backend deployment, repository push, primary checkout change or worktree cleanup. + +## Project Context Loaded + +- Task ID / mode: 20261013-combined-trial-update-c42d19a7 / feature; exact registry match verified after successful isolated start. +- Branch / worktree / base: codex/20261013-combined-trial-update-c42d19a7-combined-trial-update; this Identity path; 8289f27f3e53ef455b656f1c43d2620d235d5014. +- Concurrent and Planning Gates: Passed. Primary main is occupied by teacher integration and has an unrelated modified record; leave it untouched. Feature mode assembles product code and owns only this task record; do not promote canonical docs or edit source task records. +- Read: startup instructions, planning gate, this task record; retained previously read memory-index, positioning, current-state, decision-index, system-overview, business rules and success criteria at this base. Read fetched main's ADR/current-state/data-flow/decision-index delta, evidence index, commitments/stale items, and linked chat Agent-待更新. +- Peer scopes: independent read-only review checked all 31 peer records. Ready knowledge c634425 and create-recovery 21852fb are compatible. Old teacher/Alice changes are already present or superseded; undefined old release/child-language tasks and Canvas investigation have no releasable product code. +- Product goal/current focus: single light Electron workspace, independent personal Agent creation/configuration/chat, explicit channel binding, selected knowledge and safe conversation process rendering. +- Sources: base retains Robot-home hiding 13336cf and channel hint 8289f27; fetched origin/main bf39f40 includes message/tool rendering 70a3212 and capability copy 821fd6f; knowledge c634425; create recovery 21852fb with compatibility-only record 66c49c9. +- Decisions: latest accepted personal-cloud-Agent ADR supersedes purpose wizard/embedded trial/access navigation. Main owns credentials and safe history projection; explicit publish/channel enablement, request identities, owner isolation and billing remain. +- Evidence/unknowns: current Main PID 7459, port 62864, original entry-nav stage and profile .trial-runtime/user-data; active Renderer be53e44, product Main predates fetched updates. History/capability labels require matching Yuxi; compatibility audit says deployed-source purpose still requires nonempty text. Client update does not deploy that backend or resolve the empty-introduction contract. No paid-call validation. +- Likely files: CloudChat/message stream, knowledge workspace/picker, creation/recovery, cloud-agent Main projection/service/journal, README, relevant tests and runtime manifests. README and shared tests require semantic combination. + +## Intent And Constraints + +- User now explicitly authorizes updating the temporary application and combining eligible completed changes, revoking the prior no-update instruction for this task. +- Retain login/profile and avoid switching desktop focus; do not send messages, create real Agents, import real files or use paid generation during verification. +- Stage and verify a complete build before replacing the runtime. Main changed, so a Renderer-only refresh is insufficient. Preserve a rollback path and check current activity before a bounded background restart. +- Import source product patches and their tests; keep upstream/source project-doc records read-only. Record provenance here instead of canonical reconciliation. + +## Plan + +1. Combine latest main product changes, knowledge workspace and create recovery with the base's homepage/channel improvements; resolve only verified compatible conflicts. +2. Verify combined unit/type/lint/build and fixture Electron scenarios, covering the changed screens and Main contracts. +3. Prepare a durable runtime stage, preserve the current profile/window state and replace Main/Renderer together in the background; verify loaded source/bytes, local health and visible entries without real writes. +4. Commit source and this handoff record, save verified runtime provenance, and report updated scope plus material backend limits. + +## Outcome + +- Product assembly complete: retained base homepage/channel changes, applied bf39f40 product delta, knowledge c634425 and creation 21852fb without modifying their source task/canonical records. Runtime deployment pending. +- 22 changed product files match exact accepted source commit blobs; README combines all current behaviors. The only test conflict combined the knowledge static harness with main's normalized cross-platform import paths. + +## Verification + +- Pinned pnpm 10.33.4: 24 focused unit files / 406 tests passed; typecheck and complete build:vite passed. +- Scoped ESLint 35 TypeScript files passed with 0 errors; 2 existing CloudChat warnings retained. +- Combined UI suite: 50 passed, 1 historical Code navigation assertion failed. Targeted retry reproduces only the same mismatch (brand 46px vs inner tablist 32px). Independent review verified d787267 introduced the inner-tab layout on Sept 30 without updating this assertion; Code CSS/page/Sidebar/test blobs are identical to base. All updated Agent and homepage scenarios passed. Do not weaken the test or change unrelated product code in this task. +- Source composition and `git diff --check` passed; task documentation ownership drift passed. +- Runtime snapshot, hidden probe and actual application switch remain pending. + +## Follow-ups + +- Historical Code navigation E2E compares brand against inner tablist height; review the assertion against the existing toolbar contract in a separate scoped task. +- A client update does not deploy Yuxi history/capability/empty-introduction support; preserve these known backend limits. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index e56e421e..cf0dfc14 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 -- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考独立于正文展示,运行时可展开、手动折叠,完成后折叠;无思考内容时不显示。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要 Yuxi API 思考投影补丁上线,当前未部署;实时思考沿用既有事件流。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 +- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 - 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。我的知识库以服务端本人管理目录为准,提供「上传自己的文件」入口,可新建库或追加到已有个人库,上传后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,提供「从电脑上传」和「从聊天选择文件」;聊天文件选择面板显示目标知识库、可搜索的来源聊天和文件名称/类型/大小,支持多选,逐文件导入并保留独立操作身份,部分失败只重试失败项;也可明确确认放弃本页重试,已保存文件仍保留。仅选本人已上传的聊天附件,聊天正文和助手生成的文件不直接入库。上传和导入后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 @@ -27,6 +27,8 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。从列表移除项目只取消登记,不删除磁盘文件;在“新建项目”中直接选择已有项目文件夹会重新打开,并保留原有项目身份、类型、智能体、对话和知识文件。“新建下级文件夹”仍拒绝已存在的同名目录。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取并为项目启用官方 bundled `makelore.project-scaffold` 插件后,每个父智能体都可按需明确调用 `makelore-project-scaffold` Skill,无需伙伴分配;它以不覆盖既有路径的方式生成固定六文件 Vite 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 +创建智能体时,如果本次新操作被服务明确拒绝,会显示失败原因、保留名称和头像,并允许修改后重新提交;不会留下“结果尚未确认”提醒。断网、响应异常或已有待确认操作的重试失败仍保留原创建请求,可重试确认结果,避免重复创建。当前创建表单一旦出现结果不确定,即使移除提醒后重试被拒绝,也继续保留原请求;移除提醒不代表取消创建。创建页面会及时刷新本机恢复记录。服务器的字段校验错误只转换为固定中文提示,不展示内部错误或提交原文。一句话介绍仍可留空;创建与保存需要配套的智能体云端服务支持。 + ## 当前产品状态 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 @@ -41,6 +43,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 - 智能体头像:创建和编辑个人云智能体时可上传、更换或移除头像,支持 PNG、JPEG、WebP、GIF,最大 5 MB。复用 Yuxi 的 `Agent.icon` 和公开图片存储;上传后先预览,保存草稿后生效,正式使用的头像随发布版本固定。列表、编辑页、收到的分享、会话入口和版本对比展示对应头像,未设置或加载失败时显示默认图标。Main 负责文件选择、上传和相对地址解析,客户端不维护第二份头像数据;需配套更新 Yuxi 的 Makelore 头像接口。 +- 智能体对话:试用、正式对话和历史记录使用相同的 Markdown 正文与可折叠过程区,展示模型实际返回的思考、工具输入、结果和失败。实时事件按消息与工具身份累积,重连去重;开始输出正文后过程区自动收起,可手动展开。配套 Yuxi 历史接口投影已有持久思考和工具记录,重新打开及微信历史均可查看;没有思考或工具内容时不显示空面板。 - 客户端更新:Electron Main 按平台与架构选择更新源并保留原始诊断;设置页只显示一条脱敏后的中文状态。正式源缺少对应安装包时保持错误并允许重试,不会误报为已是最新版。 - 个人资料:姓名、年龄、性别与个人头像按账号同步到云端;首次登录进入模块选择页时会要求先完善姓名,首页欢迎语和主平台左下角账号区优先展示个人资料姓名;头像支持 PNG、JPEG、WebP,保存时自动居中裁剪并以圆形缩略图展示,未设置或加载失败时回退为姓名首字母。 - 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。 diff --git a/electron/api/routes/cloud-agents.ts b/electron/api/routes/cloud-agents.ts index b703e62b..1e888bec 100644 --- a/electron/api/routes/cloud-agents.ts +++ b/electron/api/routes/cloud-agents.ts @@ -73,9 +73,10 @@ export async function handleCloudAgentsRoutes(req: IncomingMessage, res: ServerR sendJson(res, 200, data); } catch (error) { const failure = error instanceof CloudAgentsError ? error - : new CloudAgentsError(error instanceof SyntaxError ? 400 : 502, error instanceof SyntaxError ? 'invalid_input' : 'cloud_service_unavailable'); + : new CloudAgentsError(error instanceof SyntaxError ? 400 : 502, error instanceof SyntaxError ? 'invalid_input' : 'cloud_service_unavailable', + error instanceof SyntaxError ? 'definitive_failure' : 'unknown'); if (res.headersSent) res.end(); - else sendJson(res, failure.status, { success: false, error: failure.message, code: failure.code }); + else sendJson(res, failure.status, { success: false, error: failure.message, code: failure.code, commandOutcome: failure.commandOutcome }); } return true; } diff --git a/electron/services/cloud-agent-journal.ts b/electron/services/cloud-agent-journal.ts index 103863a5..0a21ef9c 100644 --- a/electron/services/cloud-agent-journal.ts +++ b/electron/services/cloud-agent-journal.ts @@ -26,14 +26,17 @@ export class CloudAgentJournal { return this.update(accountKey, validate, value => ({ ...value, recent })); } - put(accountKey: string, entry: CloudPendingOperation, validate: () => void) { - return this.update(accountKey, validate, value => { + async put(accountKey: string, entry: CloudPendingOperation, validate: () => void): Promise { + let alreadyPending = false; + await this.update(accountKey, validate, value => { const existing = value.pending.find(item => item.id === entry.id); if (existing && JSON.stringify(existing.input) !== JSON.stringify(entry.input)) { throw new Error('同一次待确认操作不能更改输入'); } + alreadyPending = Boolean(existing); return { ...value, pending: existing ? value.pending : [...value.pending, entry] }; }); + return alreadyPending; } remove(accountKey: string, id: string, validate: () => void) { diff --git a/electron/services/cloud-agent-operations.ts b/electron/services/cloud-agent-operations.ts index e29b7645..22477711 100644 --- a/electron/services/cloud-agent-operations.ts +++ b/electron/services/cloud-agent-operations.ts @@ -77,11 +77,26 @@ function projectRun(value: unknown): unknown { } function projectConversation(value: unknown): unknown { const source = object(value); - return { ...projectSession(source), messages: list(source.messages, item => copy(item, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at'])), + return { ...projectSession(source), messages: list(source.messages, projectMessage), run: source.run == null ? null : projectRun(source.run), queued_requests: list(source.queued_requests, item => copy(item, ['request_id', 'thread_id', 'run_id', 'status', 'version'])), next_offset: source.next_offset ?? null }; } +function projectMessage(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at', 'message_id', 'reasoning_content']), + ...(Array.isArray(source.tool_calls) ? { tool_calls: source.tool_calls.map(item => { + const tool = object(item); + return { ...copy(tool, ['id', 'name', 'display_name', 'display_description', 'args', 'status', 'error_message']), + ...(tool.tool_call_result == null ? {} : { tool_call_result: copy(tool.tool_call_result, ['content']) }) }; + }) } : {}), + }; +} +function projectHistory(value: unknown): unknown { + const source = object(value); + return { ...copy(source, ['thread_id', 'queued_requests', 'schedule_proposals', 'next_offset']), + messages: list(source.messages, projectMessage), run: source.run == null ? null : projectRun(source.run) }; +} function projectFiles(value: unknown): unknown { const source = object(value); return { session_id: source.session_id, thread_id: source.thread_id ?? null, @@ -253,7 +268,7 @@ const operations: Record = { saveBudget: { method: 'PUT', cloud: 'ws', path: '/agents/:slug/budget', body: ['request_limit_points', 'daily_limit_points'], output: budget }, threads: { method: 'GET', path: '/threads', query: { slug: 'slug', offset: 'offset', archived: 'archived' }, output: ['threads', 'next_offset'] }, createThread: { method: 'POST', path: '/agents/:slug/threads', body: ['thread_id', 'preview', 'expected_revision'], output: ['thread_id', 'client_thread_id'] }, - history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'] }, + history: { method: 'GET', path: '/threads/:thread_id', query: { offset: 'offset' }, output: ['thread_id', 'messages', 'run', 'queued_requests', 'schedule_proposals', 'next_offset'], project: projectHistory }, viewed: { method: 'POST', path: '/threads/:thread_id/viewed', body: ['run_id'], output: ['viewed'] }, submit: { method: 'POST', path: '/agents/:slug/requests', body: prompt, output: request }, preview: { method: 'POST', path: '/agents/:slug/preview', body: prompt, output: request }, diff --git a/electron/services/cloud-agents.ts b/electron/services/cloud-agents.ts index ee055322..1be67387 100644 --- a/electron/services/cloud-agents.ts +++ b/electron/services/cloud-agents.ts @@ -29,7 +29,12 @@ const MESSAGES: Record = { draft_revision_conflict: '草稿已在其他设备更新,你的输入已保留', operation_conflict: '创建操作的内容发生冲突,请刷新列表确认', cloud_service_unavailable: '智能体服务暂时不可用,请重试', - invalid_input: '请检查名称、用途和配置内容', + invalid_input: '提交的信息未被接受,请检查后再试', + invalid_name: '请填写名称,长度不要超过 100 个字', + invalid_introduction: '一句话介绍不符合要求,长度不要超过 2000 个字', + introduction_too_short: '一句话介绍不符合要求,请修改后再试', + introduction_required: '当前智能体服务尚不支持一句话介绍留空,请等待服务更新后重试', + invalid_icon: '头像设置未被接受,请重新选择头像', model_required: '请先选择模型并保存', model_unavailable: '所选模型当前不可用,请刷新模型目录', resource_unavailable: '所选资源已不可用,请刷新配置', @@ -73,11 +78,39 @@ const MESSAGES: Record = { }; export class CloudAgentsError extends Error { - constructor(readonly status: number, readonly code: string) { + constructor(readonly status: number, readonly code: string, + readonly commandOutcome: 'definitive_failure' | 'unknown' = 'unknown') { super(MESSAGES[code] ?? MESSAGES.cloud_service_unavailable); } } +const validationTypes = new Set(['missing', 'string_type', 'string_too_short', 'string_too_long', + 'string_pattern_mismatch', 'uuid_parsing', 'uuid_type', 'value_error']); + +/** Only structural request-validation metadata is trusted; never expose msg, input or ctx. */ +function requestValidation(detail: unknown): boolean { + return Array.isArray(detail) && detail.length > 0 && detail.length <= 100 && detail.every(value => { + const item = record(value); + return Array.isArray(item.loc) && item.loc[0] === 'body' && item.loc.length >= 2 + && typeof item.type === 'string' && validationTypes.has(item.type); + }); +} + +function validationMessageCode(detail: unknown): string { + if (!requestValidation(detail)) return 'invalid_input'; + const codes = (detail as unknown[]).map(value => { + const item = record(value); + if (!Array.isArray(item.loc) || item.loc.length !== 2) return 'invalid_input'; + switch (item.loc[1]) { + case 'name': return 'invalid_name'; + case 'purpose': return item.type === 'string_too_short' ? 'introduction_too_short' : 'invalid_introduction'; + case 'icon': return 'invalid_icon'; + default: return 'invalid_input'; + } + }); + return codes.every(code => code === codes[0]) ? codes[0] : 'invalid_input'; +} + function record(value: unknown): Record { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; @@ -154,6 +187,7 @@ export class CloudAgentsModule { private activityTimer: ReturnType; private activityPolling = false; private activitySeen: Map | null = null; + private readonly creationAttempts = new Map }>(); constructor(private readonly fetchImpl: (input: string, init?: RequestInit) => Promise = proxyAwareFetch, private readonly journal = new CloudAgentJournal()) { @@ -196,17 +230,49 @@ export class CloudAgentsModule { async create(value: unknown): Promise { const input = record(value); - const operationId = textField(input.operation_id, 36); - if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) { - throw new CloudAgentsError(422, 'invalid_input'); + let body: CreateCloudAgent; + try { + const operationId = textField(input.operation_id, 36); + if (!/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(operationId)) { + throw new CloudAgentsError(422, 'invalid_input'); + } + body = { + operation_id: operationId, + name: textField(input.name, 100).trim(), + purpose: textField(input.purpose, 2000, false).trim(), + ...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }), + }; + } catch (error) { + if (error instanceof CloudAgentsError) { + const binding = getWorksSquareAccountBinding(); + const id = typeof input.operation_id === 'string' ? input.operation_id : ''; + let alreadyPending = Boolean(binding && this.creationAttempts.has(`${binding.accountKey}:${binding.epoch}:${id}`)); + if (binding) { + alreadyPending ||= (await this.journal.read(binding.accountKey)).pending.some(item => item.id === `createAgent::${id}`); + this.requireCurrent(binding); + } + throw new CloudAgentsError(error.status, error.code, alreadyPending ? 'unknown' : 'definitive_failure'); + } + throw error; } - const body: CreateCloudAgent = { - operation_id: operationId, - name: textField(input.name, 100).trim(), - purpose: textField(input.purpose, 2000, false).trim(), - ...(input.icon === undefined ? {} : { icon: input.icon === null ? null : textField(input.icon, 255, false) }), - }; - return this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body).then(result => this.withAvatar(projectDraft(result)))); + const binding = getWorksSquareAccountBinding(); + if (!binding) throw new CloudAgentsError(401, 'session_expired'); + const key = `${binding.accountKey}:${binding.epoch}:${body.operation_id}`; + const serialized = JSON.stringify(body); + const existing = this.creationAttempts.get(key); + if (existing) { + if (existing.input !== serialized) throw new CloudAgentsError(409, 'operation_conflict'); + return existing.promise; + } + const promise = this.withRecovery('createAgent', { ...body }, () => this.request('/api/makelore/agents', 'POST', body) + .then(result => this.withAvatar(projectDraft(result))).catch(error => { + if (body.purpose === '' && error instanceof CloudAgentsError && error.code === 'introduction_too_short') { + throw new CloudAgentsError(error.status, 'introduction_required', error.commandOutcome); + } + throw error; + })).finally(() => { this.creationAttempts.delete(key); }); + this.creationAttempts.set(key, { input: serialized, promise }); + return promise; } private async withRecovery(operation: string, input: Record, execute: () => Promise): Promise { @@ -217,9 +283,23 @@ export class CloudAgentsModule { .map(value => encodeURIComponent(typeof value === 'string' ? value : '')).join(':') : input.request_id ?? input.operation_id; const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', operationKey].join(':'); - await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding)); + const alreadyPending = await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding)); this.requireCurrent(binding); - const result = await execute(); + let result: T; + try { + result = await execute(); + } catch (error) { + this.requireCurrent(binding); + if (error instanceof CloudAgentsError) { + // A retry rejection cannot disprove an earlier acceptance whose response was lost. + if (operation === 'createAgent' && !alreadyPending && error.commandOutcome === 'definitive_failure') { + await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); + throw error; + } + throw new CloudAgentsError(error.status, error.code, 'unknown'); + } + throw error; + } this.requireCurrent(binding); await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding)); return result; @@ -671,11 +751,17 @@ export class CloudAgentsModule { if (raw.length > 4_000_000) throw new CloudAgentsError(502, 'cloud_service_unavailable'); const body: unknown = JSON.parse(raw); if (!response.ok) { - const code = record(record(body).detail).code; + const detail = record(body).detail; + const code = record(detail).code; + const rejected = ([400, 422].includes(response.status) && (code === 'invalid_input' || requestValidation(detail))) + || (response.status === 401 && code === 'session_expired') + || (response.status === 403 && ['access_revoked', 'module_unavailable'].includes(String(code))) + || (response.status === 423 && code === 'account_locked'); throw new CloudAgentsError( response.status, typeof code === 'string' && Object.hasOwn(MESSAGES, code) ? code - : response.status === 422 ? 'invalid_input' : response.status === 401 ? 'session_expired' + : response.status === 422 ? validationMessageCode(detail) : response.status === 401 ? 'session_expired' : response.status === 403 ? 'access_revoked' : 'cloud_service_unavailable', + rejected ? 'definitive_failure' : 'unknown', ); } return body; diff --git a/shared/cloud-agents.ts b/shared/cloud-agents.ts index b9bd84ae..f9266556 100644 --- a/shared/cloud-agents.ts +++ b/shared/cloud-agents.ts @@ -98,7 +98,15 @@ export interface CloudInterrupt { export interface CloudMessage { id: number; role: 'user' | 'assistant' | 'system'; content: string; request_id: string; run_id: string | null; created_at: string; + message_id?: string | null; reasoning_content?: string; + tool_calls?: CloudToolCall[]; +} +export interface CloudToolCall { + id: string; name: string; args: unknown; status: string; + display_name?: string; display_description?: string; + tool_call_result?: { content: unknown } | null; + error_message?: string | null; } export interface CloudHistory { thread_id: string; messages: CloudMessage[]; run: CloudRun | null; next_offset: number | null; diff --git a/src/pages/CloudAgents/CloudChannelConversations.tsx b/src/pages/CloudAgents/CloudChannelConversations.tsx index e5a4bfa5..46fa0566 100644 --- a/src/pages/CloudAgents/CloudChannelConversations.tsx +++ b/src/pages/CloudAgents/CloudChannelConversations.tsx @@ -1,8 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Download, Folder, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; +import { CloudMessageBody } from './CloudMessageBody'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import type { CloudAgentOperations, CloudChannelSession, CloudChannelConversation, CloudFile, CloudPendingOperation } from '../../../shared/cloud-agents'; import { CloudApproval, cloudStatus } from './CloudChat'; @@ -185,7 +184,7 @@ function ChannelSession({ sessionId, visible, refreshRevision, onBusy, onNewSess {error &&

{error}

}
{data?.messages.filter(message => message.role !== 'system').map(message =>
-

{message.role === 'user' ? '你 · 微信' : '智能体'}

href && /^(https?:|mailto:)/i.test(href) ? {children} : {children} }}>{message.content}
)} +

{message.role === 'user' ? '你 · 微信' : '智能体'}

)} {data && !data.messages.length &&

还没有消息。

}
{!recoveryReady && } diff --git a/src/pages/CloudAgents/CloudChat.tsx b/src/pages/CloudAgents/CloudChat.tsx index 8a65b6d3..cd504ed2 100644 --- a/src/pages/CloudAgents/CloudChat.tsx +++ b/src/pages/CloudAgents/CloudChat.tsx @@ -7,12 +7,11 @@ import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, import { CloudComposerAttachments } from './CloudComposerAttachments'; import { CloudAvatar } from './CloudAvatar'; import './desktop-workspace.css'; +import { CloudMessageBody } from './CloudMessageBody'; import './agent-chat.css'; -import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; -import remarkGfm from 'remark-gfm'; import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending'; import { agentExamples } from './agent-guidance'; -import { consumeCloudChatEvent, type CloudChatStream } from './cloud-chat-stream'; +import { consumeCloudChatEvent, type CloudChatStream, type CloudLiveMessage } from './cloud-chat-stream'; export const cloudStatus = (status: string) => ({ idle: '尚未开始', queued: '等待执行', dispatching: '准备执行', dispatched: '已提交', pending: '准备中', @@ -22,6 +21,8 @@ export const cloudStatus = (status: string) => ({ const active = (status?: string) => Boolean(status && ['queued', 'dispatching', 'dispatched', 'pending', 'running', 'cancel_requested', 'submitted'].includes(status)); const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法完成操作'; const object = (v: unknown): Record => v && typeof v === 'object' ? v as Record : {}; +const savedMessage = (history: CloudHistory, message: CloudLiveMessage) => history.messages.some(saved => + saved.role === 'assistant' && saved.run_id === message.run_id && (!saved.message_id || saved.message_id === message.message_id)); export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, attachmentHeader, previewReady = true, assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: { @@ -75,8 +76,10 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread // Replace the preview only after authoritative assistant history arrives. // Failed/cancelled Runs without a saved answer retain received fragments. const preview = streamReply.current; - if (preview?.threadId === id && page.run?.agent_run_id === preview.runId && !active(page.run.status) - && page.messages.some(item => item.role === 'assistant' && item.run_id === preview.runId)) updateLiveReply(null); + if (preview?.threadId === id && page.run?.agent_run_id === preview.runId && !active(page.run.status)) { + const remaining = preview.messages.filter(item => !savedMessage(page, item)); + if (remaining.length !== preview.messages.length) updateLiveReply(remaining.length ? { ...preview, messages: remaining } : null); + } setQueued(page.queued_requests ?? []); setRequest(active(page.run?.status) ? null : page.queued_requests?.[0] ?? null); if (page.run) setThreads(current => current.map(item => item.thread_id === id @@ -167,6 +170,9 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id; const running = active(run?.status) || active(request?.status); + const liveHistoryReady = history?.run && history.run.agent_run_id === liveReply?.runId + ? !active(history.run.status) + : Boolean(history?.messages.some(item => item.run_id === liveReply?.runId)); useEffect(() => { if (!runId || !running) return; let live = true; @@ -184,7 +190,8 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread if (envelope.run_id !== runId || envelope.thread_id !== targetThread || targetRequest && envelope.request_id && envelope.request_id !== targetRequest) return; const chunk = object(object(envelope.payload).chunk); - if (next?.toolName) setProgress('正在使用 ' + next.toolName); + const latestTool = next?.messages.at(-1)?.tool_calls.at(-1); + if (latestTool && ['pending', 'running'].includes(latestTool.status)) setProgress('正在使用 ' + (latestTool.display_name || latestTool.name)); if (event.type === 'interrupt') setInterrupt(chunk as CloudInterrupt); if (event.type === 'end') { source?.close(); @@ -326,7 +333,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
{ const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}>
{loading ?
- : !history?.messages.length && !liveReply?.content && !liveReply?.reasoning &&
+ : !history?.messages.length && !liveReply?.messages.length &&

{assistantName}

{mode === 'preview' ? '问它一个小问题' : 'Hi,今天想聊些什么?'}

@@ -341,13 +348,17 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread

这是修改前的回答。用同一个问题再问一次,更容易看出变化。

} - {history && } - {liveReply && (liveReply.content || liveReply.reasoning) &&
+ {history && + !(!liveHistoryReady && liveReply?.messages.some(item => saved.run_id === item.run_id && saved.message_id === item.message_id)), + ) }} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />} + {liveReply?.messages.filter(item => !liveHistoryReady || !history || !savedMessage(history, item)).map(item =>

{assistantName}

- {liveReply.reasoning && } - {liveReply.content &&
{liveReply.content}
}
-
} +
{ + const id = thread?.thread_id ?? request?.thread_id; + if (id) void cloudAgentsApi.download(id, path).catch(e => setError(message(e))); + }} />
+ )} {onSchedule && history?.schedule_proposals?.map(proposal =>

自动任务建议 · {proposal.name}

{proposal.prompt}

{proposal.cron_expression} · {proposal.timezone} · 尚未启用

@@ -419,26 +430,13 @@ function TrialMessages({ history, onError, assistantName = '助手', assistantAv return history.messages.map(item =>
{item.role !== 'user' && }
{item.role !== 'user' &&

{assistantName}

} - {item.role === 'assistant' && typeof item.reasoning_content === 'string' && item.reasoning_content - && } -
{item.role === 'user' ?

{item.content}

: url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{ - a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href) - ? - : {children}, - }}>{item.content}}
+
{item.role === 'user' ?

{item.content}

: + void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} />}
); } -function CloudReasoning({ content, running }: { content: string; running: boolean }) { - const [expanded, setExpanded] = useState(running); - useEffect(() => { setExpanded(running); }, [running]); - return
setExpanded(event.currentTarget.open)} - className="agent-chat-reasoning" data-testid="cloud-chat-reasoning"> - 思考过程 -
{content}
-
; -} - export function CloudApproval({ interrupt, runId, onResumed, submitDecision }: { interrupt: CloudInterrupt; runId: string; onResumed?: (run: CloudRun) => void; submitDecision?: (input: { operation_id: string; decision: Record }) => Promise; diff --git a/src/pages/CloudAgents/CloudKnowledge.tsx b/src/pages/CloudAgents/CloudKnowledge.tsx index 2e95e870..a54ee14b 100644 --- a/src/pages/CloudAgents/CloudKnowledge.tsx +++ b/src/pages/CloudAgents/CloudKnowledge.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; +import { Upload } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -27,6 +28,7 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const [refresh, setRefresh] = useState(0); const [creating, setCreating] = useState(false); const [documentPending, setDocumentPending] = useState(false); + const targetId = useId(); const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null); usePendingCloudInput(busy || documentPending || Boolean(createIntent.current) || (creating && Boolean(name.trim()))); const operationInFlight = useRef(false); @@ -62,15 +64,15 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const selectedKnowledge = manageable.find(kb => kb.kb_id === kbId); const managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError); const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id)); - return
- 上传自己的文件 -
-

选择或新建自己的知识库,再上传文件。选用只会加入当前配置,保存并应用后生效。

-

上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。

+ return
+
+
+

选择或新建自己的知识库,再从电脑或聊天中添加资料。选用会加入右侧配置,保存并应用后生效。

+ {error &&

{error}

} {catalogError &&

知识库与模型目录加载失败:{catalogError}。请刷新重试。

}
- setThreadQuery(event.target.value)} />
+
+ {threadsLoading ?

正在读取对话…

: threadError ?

对话读取失败:{threadError}

+ : !threads.length ?

还没有个人智能体对话。

: !visibleThreads.length ?

没有匹配的对话。

: visibleThreads.map(thread => )} +
+
+
+

{currentThread?.title || '选择文件'}

+
+
+ {!threadId ?

先选择一段对话,再挑选其中的文件。

: filesLoading ?

正在读取文件…

+ : fileError ?

文件读取失败:{fileError}

+ : !files.items.length ?

这段对话还没有上传文件。

: !visibleFiles.length ?

没有匹配的文件。

: visibleFiles.map(file => { + const details = fileDetails(file), key = JSON.stringify([threadId, file.file_id]); + return ; + })} +
+
+
} + {selected.length > 0 &&

已选 {selected.length} 个文件{locked ? '。' : ',可继续切换对话选择。'}

+
    {selected.map(item =>
  • {item.file.file_name}{item.threadTitle} +
  • )}
+
} + {jobs.length > 0 &&
    {jobs.map(job =>
  • + {job.file.file_name}{job.status === 'done' ? <>已导入,处理状态见知识库 : job.status === 'running' ? '正在导入…' : job.status === 'saved' ? '已保存,等待继续处理' : job.status === 'failed' ? '等待重试' : '等待导入'} + {job.error &&

    {job.error}

    } +
  • )}
} + {abandoning &&
+

这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。

+
+
} +
+
+

最大 5 MB;支持文本、Word、Excel、文本 PDF。导入后自动处理,可用于回答后才能检索。

+ {onRefreshKnowledge && } + {unresolved && !running && !abandoning && } + +
+ {completed > 0 &&

已导入 {completed} 个文件,处理状态见知识库。

} + ; +} diff --git a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx index 14e0ac64..1a778f84 100644 --- a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx @@ -3,7 +3,8 @@ import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { usePendingCloudInput } from './CloudPending'; -import type { CloudKnowledgeFile, CloudThread, CloudAttachment } from '../../../shared/cloud-agents'; +import { CloudKnowledgeAttachmentPicker } from './CloudKnowledgeAttachmentPicker'; +import type { CloudKnowledgeFile } from '../../../shared/cloud-agents'; function documentState(file: CloudKnowledgeFile) { const task = file.processing_task; @@ -47,11 +48,11 @@ function documentState(file: CloudKnowledgeFile) { return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 }; } -export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange }: { - slug: string; kbId: string; busy: boolean; +export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库', busy, act, onDeleteKnowledge, canRead = true, onPendingChange, onRefreshKnowledge }: { + slug: string; kbId: string; kbName?: string; busy: boolean; act: (operation: () => Promise) => Promise; onDeleteKnowledge: () => void; - canRead?: boolean; onPendingChange?: (pending: boolean) => void; + canRead?: boolean; onPendingChange?: (pending: boolean) => void; onRefreshKnowledge?: () => void; }) { const [files, setFiles] = useState([]); const [pageCount, setPageCount] = useState(1); @@ -154,14 +155,14 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled const active = states.some(state => state.active) || submitting !== null; return
- - + +

上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。

{uploading &&

{uploading}

} - {importing && { setImporting(false); await acceptFile(file); }} />} + {importing && setImporting(false)} onRefreshKnowledge={onRefreshKnowledge} />}
{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'} {active ? '处理中 · 状态自动更新' : '状态自动更新'} @@ -215,52 +216,11 @@ export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowled {deleting &&

{deleting === 'database' ? '删除整个知识库及其文档、索引?所有使用此库的智能体将无法继续检索这些内容。' : '删除文档“' + deleting.name + '”及其索引?'}

}
; } - -function AttachmentImport({ slug, kbId, busy, act, onImported, onPendingChange }: { - slug: string; kbId: string; busy: boolean; act: (operation: () => Promise) => Promise; - onImported: (file: CloudKnowledgeFile) => Promise; - onPendingChange: (pending: boolean) => void; -}) { - const [threads, setThreads] = useState([]); - const [thread, setThread] = useState(''); - const [files, setFiles] = useState([]); - const [file, setFile] = useState(''); - const [error, setError] = useState(''); - const operation = useRef(null); - const unresolved = Boolean(operation.current); - useEffect(() => { onPendingChange(unresolved); return () => onPendingChange(false); }, [onPendingChange, unresolved]); - usePendingCloudInput(busy || Boolean(operation.current)); - useEffect(() => { - let live = true; - const load = async () => { - let page = await cloudAgentsApi.call('threads', {}); - while (page.next_offset !== null) { const next = await cloudAgentsApi.call('threads', { offset: page.next_offset }); page = { ...next, threads: [...page.threads, ...next.threads] }; } - if (live) setThreads(page.threads); - }; - void load().catch(e => { if (live) setError(e instanceof Error ? e.message : '对话读取失败'); }); - return () => { live = false; }; - }, []); - useEffect(() => { - let live = true; setFiles([]); setFile(''); - if (thread) cloudAgentsApi.call('attachments', { thread_id: thread }).then(page => { if (live) setFiles(page.attachments); }).catch(e => { if (live) setError(e instanceof Error ? e.message : '附件读取失败'); }); - return () => { live = false; }; - }, [thread]); - return
-

只显示你自己的对话附件。导入后自动处理,变为“可用于回答”后才能检索。

- - - - {error &&

{error}

} -
; -} diff --git a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx index 65ce7dd9..9888fb2e 100644 --- a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { X } from 'lucide-react'; +import { Library, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { CloudKnowledgePanel, type CloudOwnedKnowledgeCatalog } from './CloudKnowledge'; import type { CloudResource } from '../../../shared/cloud-agents'; @@ -16,7 +16,15 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load const unknown = ownedIds === null ? available : []; const selectionFull = selected.length >= 100; const add = (id: string) => { if (!selectionFull && !selected.includes(id)) onChange([...selected, id]); }; - return
+ return
+ option.key)} + onSelected={kb => { if (!loading && !error && options.some(option => option.key === kb.kb_id)) { add(kb.kb_id); onRefresh(); } }} + onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }} + onCreated={kb => { add(kb.kb_id); onRefresh(); }} /> +
+
+

从 MakeLore知识和我的知识库中选择,可同时使用多个。

change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始运行时预加载已选做事手册} + onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始对话前,先读好已选做事手册的使用方法} ; })}
; diff --git a/src/pages/CloudAgents/agent-config.css b/src/pages/CloudAgents/agent-config.css index 7798a365..4995f0b9 100644 --- a/src/pages/CloudAgents/agent-config.css +++ b/src/pages/CloudAgents/agent-config.css @@ -41,6 +41,66 @@ .agent-resource-preload { display: flex; min-height: 44px; align-items: center; gap: 9px; margin-top: 12px; cursor: pointer; font-size: 13px; } .agent-config-resource-manager { margin-top: 18px; } .agent-config-resource-manager > details { border-radius: 14px; background: #ffffffa8; } +.agent-resource-card:has(.agent-knowledge-workspace) { container-type: inline-size; } +.agent-knowledge-workspace { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); align-items: start; gap: 18px; min-width: 0; } +.agent-knowledge-upload, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; } +.agent-knowledge-upload { border-color: #dce5f3; } +.agent-knowledge-panel-heading { display: flex; align-items: center; gap: 9px; color: #33485c; } +.agent-knowledge-panel-heading h3 { font-size: 16px; font-weight: 650; line-height: 1.5; } +.agent-knowledge-panel-heading svg { flex-shrink: 0; color: #607d72; } +.agent-knowledge-upload .agent-knowledge-panel-heading svg { color: #4a6e9c; } +.agent-knowledge-field-label { display: block; font-size: 13px; font-weight: 600; color: #4c586b; } +.agent-knowledge-help { border-top: 1px solid #e8e6e1; padding-top: 12px; } +.agent-knowledge-help summary { min-height: 28px; width: fit-content; cursor: pointer; color: #606c7e; font-size: 12px; line-height: 1.7; } +.agent-knowledge-help summary:focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; border-radius: 4px; } +.agent-knowledge-selection ul { max-height: 480px; overflow: auto; overscroll-behavior: contain; } +.agent-knowledge-selection button { flex-shrink: 0; } +.agent-knowledge-import.agent-knowledge-import-modal { display: flex; flex-direction: column; width: calc(100vw - 32px); max-width: 860px; max-height: calc(100dvh - 32px); gap: 18px; border-radius: 22px; padding: 24px; background: #fffdf9; color: #273246; } +.agent-knowledge-import-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-shrink: 0; min-width: 0; } +.agent-knowledge-import-header > div { min-width: 0; overflow-wrap: anywhere; } +.agent-knowledge-import-header p { margin-top: 6px; line-height: 1.7; color: #59657a; } +.agent-knowledge-import-content { display: flex; flex-direction: column; gap: 16px; min-height: 0; min-width: 0; overflow: auto; overscroll-behavior: contain; } +.agent-knowledge-import-help { color: #606c7e; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } +.agent-knowledge-import-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 16px; min-height: 0; min-width: 0; overflow: auto; } +.agent-knowledge-import-content > .agent-knowledge-import-body { flex-shrink: 0; } +.agent-knowledge-import-chats, .agent-knowledge-import-files { display: flex; flex-direction: column; min-width: 0; min-height: 0; gap: 10px; } +.agent-knowledge-import-chats h3, .agent-knowledge-import-files h3 { font-size: 14px; font-weight: 600; } +.agent-knowledge-import-search { display: flex; align-items: center; gap: 8px; min-width: 0; color: #606c7e; } +.agent-knowledge-import-search svg, .agent-knowledge-import-file > svg { flex-shrink: 0; } +.agent-knowledge-import :is(input:not([type="checkbox"]), select) { width: 100%; min-width: 0; min-height: 42px; border: 1px solid #cdd5e3; border-radius: 11px; background: #fff; padding: 10px 12px; font-size: 14px; } +.agent-knowledge-import :is(input, button):focus-visible { outline: 2px solid #9a7bb5; outline-offset: 2px; } +.agent-knowledge-import-chat-list, .agent-knowledge-import-file-list { min-width: 0; overflow: auto; max-height: 330px; overscroll-behavior: contain; border: 1px solid #dce2ec; border-radius: 13px; background: #fff; } +.agent-knowledge-import-chat-list > :is(p, div), .agent-knowledge-import-file-list > :is(p, div) { padding: 14px; color: #606c7e; font-size: 13px; line-height: 1.7; } +.agent-knowledge-import-chat { display: flex; flex-direction: column; gap: 5px; width: 100%; min-height: 68px; padding: 12px; text-align: left; font-size: 14px; line-height: 1.5; cursor: pointer; overflow-wrap: anywhere; transition: background-color 160ms ease; } +.agent-knowledge-import-chat + .agent-knowledge-import-chat { border-top: 1px solid #edf0f5; } +.agent-knowledge-import-chat:hover { background: #f6f5fa; } +.agent-knowledge-import-chat[aria-pressed="true"] { background: #f1f6ff; box-shadow: inset 3px 0 #6b87b3; } +.agent-knowledge-import-chat:disabled { cursor: not-allowed; } +.agent-knowledge-import-chat small { color: #606c7e; font-size: 12px; } +.agent-knowledge-import-file { display: flex; align-items: flex-start; gap: 10px; min-width: 0; min-height: 64px; padding: 12px; font-size: 14px; line-height: 1.6; cursor: pointer; overflow-wrap: anywhere; } +.agent-knowledge-import-file + .agent-knowledge-import-file { border-top: 1px solid #edf0f5; } +.agent-knowledge-import-file:has(input:checked) { background: #f6f2ff; } +.agent-knowledge-import-file:has(input:disabled) { cursor: not-allowed; } +.agent-knowledge-import-file input[type="checkbox"] { width: 17px; height: 17px; margin-top: 3px; accent-color: #79629d; flex-shrink: 0; } +.agent-knowledge-import-file > span { min-width: 0; } +.agent-knowledge-import-file small { display: block; font-size: 12px; color: #606c7e; } +.agent-knowledge-import-selection { flex-shrink: 0; min-width: 0; border-radius: 12px; background: #f3f0f8; padding: 12px; font-size: 13px; overflow-wrap: anywhere; } +.agent-knowledge-import-selection ul { max-height: 130px; overflow: auto; } +.agent-knowledge-import-selection li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 8px; } +.agent-knowledge-import-selection li > span { min-width: 0; } +.agent-knowledge-import-selection li button { flex-shrink: 0; } +.agent-knowledge-import-selection small { display: block; color: #606c7e; font-size: 12px; } +.agent-knowledge-import-results { max-height: 170px; min-height: 0; flex-shrink: 0; overflow: auto; border: 1px solid #dce5e0; border-radius: 12px; padding: 12px; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } +.agent-knowledge-import-results li + li { border-top: 1px solid #e8e6e1; margin-top: 10px; padding-top: 10px; } +.agent-knowledge-import-results li > span { display: flex; align-items: center; gap: 6px; } +.agent-knowledge-import-results li > span + span { color: #526c5f; } +.agent-knowledge-import-results [role="alert"] { color: #b54532; } +.agent-knowledge-import-abandon { flex-shrink: 0; padding: 12px; border: 1px solid #efd9ba; border-radius: 12px; background: #fff5e9; font-size: 13px; line-height: 1.7; } +.agent-knowledge-import-abandon p { margin-bottom: 10px; } +.agent-knowledge-import-abandon button { margin: 0 8px 6px 0; } +.agent-knowledge-import-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 9px; flex-shrink: 0; } +.agent-knowledge-import-footer p { flex: 1 1 280px; min-width: 0; font-size: 12px; line-height: 1.7; color: #606c7e; } +@container (max-width: 620px) { .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 640px) { .agent-editor-tabs { padding: 10px 12px; } .agent-editor-tabs [role="tab"] { gap: 6px; padding-inline: 8px; font-size: 14px; } @@ -52,5 +112,12 @@ .agent-config-heading-title { gap: 6px; } .agent-config-icon { width: 38px; height: 38px; border-radius: 13px; } .agent-config-section-top > button { margin-left: 48px; } + .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); gap: 14px; } + .agent-knowledge-upload, .agent-knowledge-selection { padding: 14px; border-radius: 14px; } + .agent-knowledge-import.agent-knowledge-import-modal { padding: 18px; border-radius: 18px; gap: 14px; } + .agent-knowledge-import-body { grid-template-columns: minmax(0, 1fr); } + .agent-knowledge-import-chat-list { max-height: 160px; } + .agent-knowledge-import-file-list { max-height: 240px; } + .agent-knowledge-import-footer button { width: 100%; } } -@media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } } +@media (prefers-reduced-motion: reduce) { .agent-resource-option, .agent-knowledge-import-chat { transition: none; } } diff --git a/src/pages/CloudAgents/cloud-chat-stream.ts b/src/pages/CloudAgents/cloud-chat-stream.ts index 79b6f79d..d37cdaa7 100644 --- a/src/pages/CloudAgents/cloud-chat-stream.ts +++ b/src/pages/CloudAgents/cloud-chat-stream.ts @@ -1,58 +1,103 @@ -export interface CloudChatStream { +import type { CloudToolCall } from '../../../shared/cloud-agents'; + +export interface CloudLiveMessage { + message_id: string; + run_id: string; + content: string; + reasoning_content: string; + tool_calls: (CloudToolCall & { index?: number })[]; +} +export interface CloudStream { runId: string; + cursor: string; + messages: CloudLiveMessage[]; + finished?: boolean; +} +export const object = (value: unknown): Record => + value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + +/** Redis SSE cursors order replayed batches without treating subthread output as an answer. */ +export function reduceCloudStream(state: CloudStream, data: unknown, threadId: string, cursor: string): CloudStream { + if (cursor && /^\d+-\d+$/.test(cursor) && state.cursor) { + const [time, sequence] = cursor.split('-').map(BigInt); + const [previousTime, previousSequence] = state.cursor.split('-').map(BigInt); + if (time < previousTime || (time === previousTime && sequence <= previousSequence)) return state; + } + const envelope = object(data); + if (envelope.run_id && envelope.run_id !== state.runId) return state; + const next = { ...state, cursor: /^\d+-\d+$/.test(cursor) ? cursor : state.cursor }; + if (envelope.thread_id && envelope.thread_id !== threadId) return next; + const payload = object(envelope.payload); + const chunks = Array.isArray(payload.items) ? payload.items : [payload.chunk]; + let messages = state.messages; + for (const value of chunks) { + const chunk = object(value); + const event = object(chunk.stream_event); + if ((chunk.thread_id && chunk.thread_id !== threadId) || (event.thread_id && event.thread_id !== threadId)) continue; + if (['message_delta', 'tool_call', 'tool_call_delta'].includes(String(event.type))) { + const id = typeof event.message_id === 'string' ? event.message_id + : messages.filter(message => message.run_id === state.runId).at(-1)?.message_id ?? state.runId + ':message'; + const current = messages.find(message => message.message_id === id && message.run_id === state.runId) + ?? { message_id: id, run_id: state.runId, content: '', reasoning_content: '', tool_calls: [] }; + const updated = { ...current }; + if (event.type === 'message_delta') { + updated.content += typeof event.content === 'string' ? event.content : ''; + updated.reasoning_content += typeof event.reasoning_content === 'string' ? event.reasoning_content + : typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : ''; + } else { + const index = typeof event.index === 'number' ? event.index : 0; + const existing = current.tool_calls.find(tool => (event.tool_call_id && tool.id === event.tool_call_id) || tool.index === index); + const tool = { ...existing, id: typeof event.tool_call_id === 'string' ? event.tool_call_id : existing?.id ?? id + ':' + index, + name: typeof event.name === 'string' ? event.name : existing?.name ?? '工具', + display_name: typeof event.display_name === 'string' ? event.display_name : existing?.display_name, + display_description: typeof event.display_description === 'string' ? event.display_description : existing?.display_description, + args: existing && existing.status !== 'pending' ? existing.args + : event.type === 'tool_call' ? event.args ?? {} : String(existing?.args ?? '') + String(event.args_delta ?? ''), + status: existing?.status ?? 'pending', index }; + updated.tool_calls = existing ? current.tool_calls.map(item => item === existing ? tool : item) : [...current.tool_calls, tool]; + } + messages = messages.includes(current) + ? messages.map(message => message === current ? updated : message) : [...messages, updated]; + } + const toolEvent = object(chunk.event); + const result = object(toolEvent.data); + if (toolEvent.method === 'tools' && typeof result.tool_call_id === 'string') { + messages = messages.map(message => ({ ...message, tool_calls: message.tool_calls.map(tool => { + if (message.run_id !== state.runId || tool.id !== result.tool_call_id) return tool; + tool = { ...tool, + display_name: typeof result.display_name === 'string' ? result.display_name : tool.display_name, + display_description: typeof result.display_description === 'string' ? result.display_description : tool.display_description }; + if (result.event === 'tool-started') return { ...tool, args: result.input ?? tool.args, status: 'running' }; + if (result.event !== 'tool-finished') return tool; + const output = object(result.output); + const error = result.error || (output.status === 'error' ? output.content : null); + return { ...tool, status: error ? 'error' : 'success', + error_message: error ? (typeof error === 'string' ? error : JSON.stringify(error)) : null, + tool_call_result: { content: output.content ?? result.output ?? '' } }; + }) })); + } + } + return { ...next, messages }; +} + +export interface CloudChatStream extends CloudStream { threadId: string; requestId?: string; - cursor: string; - content: string; - reasoning: string; - messageId: string; - reasoningMessageId: string; ended: boolean; - toolName: string; } -const object = (value: unknown): Record => value && typeof value === 'object' && !Array.isArray(value) - ? value as Record : {}; -const validCursor = (value: string) => /^\d+-\d+$/.test(value); -function newerCursor(next: string, previous: string) { - const [nextTime, nextIndex] = next.split('-').map(BigInt); - const [previousTime, previousIndex] = previous.split('-').map(BigInt); - return nextTime > previousTime || nextTime === previousTime && nextIndex > previousIndex; -} - -/** Project only the subscribed Run's main-thread messages; Redis IDs survive reconnects. */ +/** Keep transport identity and continuation state around the message-level projection. */ export function consumeCloudChatEvent(previous: CloudChatStream | null, value: unknown, type: string, eventId: string, runId: string, threadId: string, requestId?: string): CloudChatStream | null { const envelope = object(value); if (envelope.run_id !== runId || requestId && envelope.request_id && envelope.request_id !== requestId) return previous; const continuation = previous?.threadId === threadId && requestId && previous.requestId === requestId; const current: CloudChatStream = previous?.runId === runId && previous.threadId === threadId ? previous : { - runId, threadId, requestId, cursor: '0-0', content: '', reasoning: continuation ? previous.reasoning : '', - messageId: '', reasoningMessageId: continuation ? previous.reasoningMessageId : '', ended: false, toolName: '', + runId, threadId, requestId, cursor: '0-0', messages: continuation ? previous.messages : [], ended: false, }; - const fresh = validCursor(eventId) && newerCursor(eventId, current.cursor); - // A synthesized terminal event can reuse the last message's Redis ID. - if (type === 'end' && envelope.thread_id === threadId) return { ...current, ended: true, cursor: fresh ? eventId : current.cursor }; - if (validCursor(eventId) && !fresh) return previous; - const next = { ...current, cursor: fresh ? eventId : current.cursor }; - // Child events still advance the replay cursor, but never become visible text. - if (envelope.thread_id !== threadId) return next; - const payload = object(envelope.payload); - for (const item of Array.isArray(payload.items) ? payload.items : payload.chunk ? [payload.chunk] : []) { - const event = object(object(item).stream_event); - if (event.type === 'tool_call' && typeof event.name === 'string') next.toolName = event.name; - if (event.type !== 'message_delta') continue; - const id = typeof event.message_id === 'string' ? event.message_id : next.messageId; - const reasoning = typeof event.reasoning_content === 'string' ? event.reasoning_content - : typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : ''; - if (reasoning) { - next.reasoning += (next.reasoning && next.reasoningMessageId && id !== next.reasoningMessageId ? '\n\n' : '') + reasoning; - next.reasoningMessageId = id; - } - if (typeof event.content === 'string' && event.content) { - next.content += (next.content && next.messageId && id !== next.messageId ? '\n\n' : '') + event.content; - next.messageId = id; - } - } - return next; + // Child events advance the cursor without becoming visible messages. + const projected = reduceCloudStream(current, envelope.thread_id === threadId ? envelope : { ...envelope, payload: {} }, threadId, eventId); + // Terminal events may reuse the last message cursor. + if (type === 'end' && envelope.thread_id === threadId) return { ...current, ...projected, ended: true }; + return projected === current ? previous : { ...current, ...projected }; } diff --git a/src/pages/CloudAgents/index.tsx b/src/pages/CloudAgents/index.tsx index 8bc3a94d..72b217bd 100644 --- a/src/pages/CloudAgents/index.tsx +++ b/src/pages/CloudAgents/index.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; +import { AppError } from '@/lib/error-model'; import { useAuthStore } from '@/stores/auth'; import { type CloudAgentAvatar, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents'; import { DraftEditor } from './DraftEditor'; @@ -164,7 +165,7 @@ function AgentWorkspace() { if (shared || activity) return
{recovery} { setShared(null); setActivity(null); clearRecent(); }} />
; if (creating) return
{recovery} -
{ setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} +
{ setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} onSettled={refreshRecovery} onCancel={() => { setCreating(false); void load(); }} />
; @@ -242,7 +243,9 @@ function AgentWorkspace() { ); } -function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void }) { +function CreateAgentForm({ onCreated, onCancel, onSettled }: { + onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void; onSettled: () => void; +}) { const [name, setName] = useState(''); const [avatar, setAvatar] = useState({ icon: null, icon_url: null }); const [uploading, setUploading] = useState(false); @@ -250,6 +253,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const alive = useRef(true); + const uncertainOperation = useRef(null); const hasInput = Boolean(name || avatar.icon); const blocker = useBlocker(busy || uploading || intent !== null || hasInput); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); @@ -271,9 +275,19 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen const agent = await cloudAgentsApi.create(input); if (alive.current) onCreated(agent); } catch (failure) { - if (alive.current) setError(errorMessage(failure)); + if (alive.current) { + if (failure instanceof AppError && failure.details?.commandOutcome === 'definitive_failure' + && uncertainOperation.current !== input.operation_id) { + setIntent(null); + setError('创建失败:' + errorMessage(failure)); + } else { + // Discarding the local reminder cannot rule out acceptance of an earlier attempt. + uncertainOperation.current = input.operation_id; + setError('创建结果暂未确认,可重试确认结果。' + errorMessage(failure)); + } + } } finally { - if (alive.current) setBusy(false); + if (alive.current) { setBusy(false); onSettled(); } } }; @@ -285,7 +299,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen - {error &&

{error}。可重试确认本次创建结果。

} + {error &&

{error}

}
diff --git a/tests/e2e/cloud-agent-chat-layout.spec.ts b/tests/e2e/cloud-agent-chat-layout.spec.ts index 08973620..c55bc298 100644 --- a/tests/e2e/cloud-agent-chat-layout.spec.ts +++ b/tests/e2e/cloud-agent-chat-layout.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; +import { createServer, normalizePath, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; import type { CloudAttachment, CloudHistory, CloudInterrupt, CloudRun, CloudThread } from '../../shared/cloud-agents'; @@ -11,7 +11,7 @@ test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, // the event stream are isolated fixtures; this suite never starts a native app // or contacts an account, cloud service or model. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-chat-layout-harness.tsx'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-chat-layout-harness.tsx')); server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-chat-layout', @@ -422,7 +422,7 @@ for (const viewport of [{ width: 375, height: 540 }, { width: 1440, height: 900 test(`reasoning streams separately and becomes collapsed durable history at ${viewport.width}×${viewport.height}`, async ({ page }) => { const errors = await openChat(page, 'thinking', viewport.width, viewport.height); const transcript = page.getByTestId('cloud-chat-messages'); - const thinking = page.getByTestId('cloud-chat-reasoning'); + const thinking = page.getByTestId('cloud-message-process'); const envelope = { thread_id: 'story-first', run_id: 'run-story-first' }; const prefix = '先观察故事里的小发现。\n'; const reasoning = prefix + Array.from({ length: 40 }, (_, index) => diff --git a/tests/e2e/cloud-agent-home.spec.ts b/tests/e2e/cloud-agent-home.spec.ts index 2aa2cc94..b0db44d6 100644 --- a/tests/e2e/cloud-agent-home.spec.ts +++ b/tests/e2e/cloud-agent-home.spec.ts @@ -151,7 +151,7 @@ test('Agent cards open conversation, adjustment and unfinished creation explicit await ready.getByRole('button', { name: '和它聊聊', exact: true }).click(); await expect(page.getByRole('region', { name: '智能体对话', exact: true })).toBeVisible(); await expect(page.getByTestId('cloud-agent-editor').getByRole('button', { name: '聊天', exact: true })).toHaveCount(0); - await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '我的智能体', exact: true }).click(); + await page.getByTestId('cloud-agent-editor').getByRole('button', { name: '返回智能体列表', exact: true }).click(); await expect(ready).toBeVisible(); await ready.getByRole('button', { name: '调整', exact: true }).click(); await expect(page.getByRole('tab', { name: '介绍与提示词', exact: true })).toHaveAttribute('aria-selected', 'true'); diff --git a/tests/e2e/cloud-agent-knowledge.spec.ts b/tests/e2e/cloud-agent-knowledge.spec.ts index 9389f2b0..31eee198 100644 --- a/tests/e2e/cloud-agent-knowledge.spec.ts +++ b/tests/e2e/cloud-agent-knowledge.spec.ts @@ -1,20 +1,36 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; -import { resolve } from 'node:path'; -import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile } from '../../shared/cloud-agents'; +import { build, normalizePath } from 'vite'; +import { createServer, type Server } from 'node:http'; +import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { createRequire } from 'node:module'; +import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudKnowledge, type CloudKnowledgeFile, type CloudThread, type CloudAttachment } from '../../shared/cloud-agents'; -let server: ViteDevServer; +let server: Server; let baseUrl: string; +let fixtureDir: string; +const fixtureRequire = createRequire(resolve('tests/e2e/cloud-agent-knowledge.spec.ts')); +const viteDependencies = createRequire(fixtureRequire.resolve('vite/package.json')); test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' }); // Render the real editor while replacing only the Main IPC boundary. No Electron, // native picker, real upload or cloud request is started by this suite. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-knowledge-harness.tsx'); - server = await createServer({ - configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-knowledge', + test.setTimeout(90_000); + fixtureDir = await realpath(await mkdtemp(join(tmpdir(), 'makelore-knowledge-fixture-'))); + await symlink(resolve('node_modules'), join(fixtureDir, 'node_modules'), 'dir'); + const html = join(fixtureDir, 'index.html'); + const output = join(fixtureDir, 'dist'); + await writeFile(html, '
'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-knowledge-harness.tsx')); + // Build the virtual entry once. A static fixture needs no Vite dev server, + // background dependency discovery, file watcher, or HMR connection. + await build({ + configFile: false, root: fixtureDir, publicDir: false, logLevel: 'warn', resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, - server: { host: '127.0.0.1', port: 0 }, + css: { postcss: resolve('.') }, + build: { outDir: output, emptyOutDir: true, rollupOptions: { input: html } }, plugins: [{ name: 'isolated-cloud-agent-knowledge', enforce: 'pre', resolveId(id) { if (id === '/__cloud-agent-knowledge-harness.tsx') return entry; }, @@ -22,25 +38,37 @@ test.beforeAll(async () => { if (id === entry) return ` import { createRoot } from 'react-dom/client'; import { createMemoryRouter, RouterProvider } from 'react-router-dom'; - import CloudAgents from '/src/pages/CloudAgents/index.tsx'; - import '/src/styles/globals.css'; + import CloudAgents from ${JSON.stringify(normalizePath(resolve('src/pages/CloudAgents/index.tsx')))}; + import ${JSON.stringify(normalizePath(resolve('src/styles/globals.css')))}; const router = createMemoryRouter([{ path: '/cloud-agents', element: }], { initialEntries: ['/cloud-agents?view=mine'] }); createRoot(document.getElementById('root')).render(
); `; if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "knowledge-fixture" } }); }'; }, - 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]; + const mime: Record = { html: 'text/html', js: 'text/javascript', css: 'text/css', + svg: 'image/svg+xml', png: 'image/png', woff2: 'font/woff2', woff: 'font/woff' }; + server = createServer(async (req, res) => { + const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://127.0.0.1').pathname); + const file = resolve(output, '.' + (pathname === '/' ? '/index.html' : pathname)); + if (!file.startsWith(output + sep)) { res.writeHead(403); res.end(); return; } + try { const bytes = await readFile(file); res.setHeader('Content-Type', mime[file.split('.').pop()!] ?? 'application/octet-stream'); res.end(bytes); } + catch { res.writeHead(404); res.end(); } + }); + await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', done); }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture HTTP address is unavailable'); + baseUrl = `http://127.0.0.1:${address.port}`; +}); +test.afterAll(async () => { + try { + if (server?.listening) { server.closeAllConnections(); await new Promise((done, reject) => server.close(error => error ? reject(error) : done())); } + } finally { + try { if (fixtureDir) await rm(fixtureDir, { recursive: true, force: true }); } + finally { (viteDependencies('esbuild') as { stop(): void }).stop(); } + } }); -test.afterAll(async () => { await server?.close(); }); const agent: CloudAgentDraft = { slug: 'ml-' + 'a'.repeat(32), name: '资料小助手', purpose: '用我的资料回答问题。', @@ -54,20 +82,37 @@ const libraries: CloudKnowledge[] = [ { kb_id: 'kb_fixturereading', name: '阅读知识', description: '平台开放的阅读资料', embedding_model: 'fixture-embedding' }, { kb_id: 'kb_fixturepersonal', name: '我的观察记录', description: '自己的资料', embedding_model: 'fixture-embedding' }, ]; +const sourceThreads: CloudThread[] = [ + { thread_id: 'thread-spring', client_thread_id: 'client-spring', agent_slug: agent.slug, title: '春天的观察讨论', mode: 'preview', + updated_at: '2026-10-13T01:00:00Z', run_id: null, status: 'completed', unread: false }, + { thread_id: 'thread-reading', client_thread_id: 'client-reading', agent_slug: agent.slug, title: '图书阅读记录', mode: 'preview', + updated_at: '2026-10-13T00:00:00Z', run_id: null, status: 'completed', unread: false }, +]; +const sourceAttachments: Record = { + 'thread-spring': [ + { file_id: 'attachment-observations', file_name: '春天观察报告.md', file_size: 1536, path: 'attachments/spring/report.md', original_path: 'attachments/spring/report.md' }, + { file_id: 'attachment-plants', file_name: '植物清单.csv', file_size: 2048, path: 'attachments/spring/plants.csv', original_path: 'attachments/spring/plants.csv' }, + ], + 'thread-reading': [ + { file_id: 'attachment-reading', file_name: '阅读笔记.pdf', file_size: 2048, path: 'attachments/reading/notes.pdf', original_path: 'attachments/reading/notes.pdf' }, + ], +}; type FixtureCall = { path: string; method: string; input: Record | null }; type KnowledgeFixture = { agent: CloudAgentDraft; libraries: CloudKnowledge[]; files: Record; calls: FixtureCall[]; ownedIds: string[]; upload: 'success' | 'cancel' | 'fail'; processFailure: boolean; + threads: CloudThread[]; attachments: Record; importFailures: string[]; }; type FixtureWindow = Window & { __knowledgeFixture: KnowledgeFixture }; async function installFixture(page: Page) { page.on('pageerror', error => { throw error; }); - await page.addInitScript(({ agent, libraries }) => { + await page.addInitScript(({ agent, libraries, sourceThreads, sourceAttachments }) => { const scope = window as unknown as FixtureWindow; const stored = sessionStorage.getItem('knowledge-fixture'); const state: KnowledgeFixture = stored ? JSON.parse(stored) : { agent, libraries, files: {}, calls: [], ownedIds: ['kb_fixturepersonal'], upload: 'success', processFailure: false, + threads: sourceThreads, attachments: sourceAttachments, importFailures: [], }; scope.__knowledgeFixture = state; const persist = () => sessionStorage.setItem('knowledge-fixture', JSON.stringify(state)); @@ -113,6 +158,19 @@ async function installFixture(page: Page) { case 'knowledgeFiles': if (!managed(args?.kb_id)) throw new Error('Document management reached a read-only library'); return result({ files: state.files[String(args?.kb_id)] ?? [], next_offset: null }); + case 'threads': return result({ threads: state.threads, next_offset: null }); + case 'received': return result({ agents: [] }); + case 'attachments': return result({ attachments: state.attachments[String(args?.thread_id)] ?? [] }); + case 'importKnowledgeAttachment': { + if (!managed(args?.kb_id)) throw new Error('Attachment import reached a read-only library'); + const attachment = state.attachments[String(args?.thread_id)]?.find(file => file.file_id === args?.attachment_id); + if (!attachment) throw new Error('Unknown fixture source attachment'); + if (state.importFailures.includes(attachment.file_id)) return failure('植物清单导入暂时失败'); + const file: CloudKnowledgeFile = { file_id: 'imported-' + attachment.file_id, name: attachment.file_name, + size: attachment.file_size, status: 'uploaded', error: null, chunk_count: 0, available: false }; + state.files[String(args?.kb_id)] = [file, ...(state.files[String(args?.kb_id)] ?? []).filter(item => item.file_id !== file.file_id)]; + return result(file); + } case 'processKnowledge': { if (!managed(args?.kb_id)) throw new Error('Processing reached a read-only library'); if (state.processFailure) return failure('处理请求暂不可用'); @@ -125,7 +183,7 @@ async function installFixture(page: Page) { } }, } } }); - }, { agent, libraries }); + }, { agent, libraries, sourceThreads, sourceAttachments }); } async function openKnowledge(page: Page) { @@ -150,6 +208,36 @@ async function expectNoClipping(page: Page) { }); expect(clipped).toEqual([]); } +async function expectKnowledgeLayout(page: Page, width: number) { + const layout = page.locator('.agent-knowledge-workspace'); + const upload = await layout.locator('.agent-knowledge-upload').boundingBox(); + const selection = await layout.locator('.agent-knowledge-selection').boundingBox(); + expect(upload).not.toBeNull(); expect(selection).not.toBeNull(); + if (width >= 1024) { + expect(upload!.x + upload!.width).toBeLessThanOrEqual(selection!.x + 1); + expect(Math.abs(upload!.y - selection!.y)).toBeLessThanOrEqual(1); + } else { + expect(upload!.y + upload!.height).toBeLessThanOrEqual(selection!.y + 1); + expect(Math.abs(upload!.x - selection!.x)).toBeLessThanOrEqual(1); + } + await expectNoClipping(page); +} +async function expectPickerBounds(page: Page) { + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + const outside = await page.getByRole('dialog', { name: '从聊天选择文件', exact: true }).evaluate(dialog => { + const bounds = dialog.getBoundingClientRect(); + return { + bounds: { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom }, + viewport: { width: innerWidth, height: innerHeight }, + dialogFits: bounds.left >= 0 && bounds.top >= 0 && bounds.right <= innerWidth && bounds.bottom <= innerHeight, + clipped: [...dialog.querySelectorAll('button, input, label, h2, h4')].filter(element => element.getClientRects().length > 0) + .filter(element => { const box = element.getBoundingClientRect(); return box.left < bounds.left - 1 || box.right > bounds.right + 1; }) + .map(element => element.getAttribute('aria-label') ?? element.textContent?.trim()), + }; + }); + expect(outside.dialogFits, JSON.stringify(outside)).toBe(true); expect(outside.clipped).toEqual([]); + if (outside.viewport.width >= 1024) expect(outside.bounds.right - outside.bounds.left).toBeGreaterThanOrEqual(800); +} for (const width of [375, 1440]) { test(`multiple shared/personal choices stay draft-only and survive save/reopen at ${width}px`, async ({ page }) => { @@ -168,11 +256,11 @@ for (const width of [375, 1440]) { await expect(selected.getByRole('button', { name: '移除知识库 科学知识', exact: true })).toBeVisible(); await expect(selected.getByRole('button', { name: /上传|删除|处理|替换/ })).toHaveCount(0); expect((await calls(page)).filter(call => call.method === 'PATCH' || call.path.endsWith('/knowledge/pick'))).toEqual([]); - await knowledge.getByText('上传自己的文件', { exact: true }).click(); + await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); const management = knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }); await expect(management.locator('option[value="kb_fixturescience"], option[value="kb_fixturereading"]')).toHaveCount(0); - await expect(knowledge.getByRole('button', { name: '上传文档', exact: true })).toHaveCount(0); - await expectNoClipping(page); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' }); + await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toHaveCount(0); + await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-shared-${width}.png`), animations: 'disabled' }); await editor.getByRole('button', { name: '保存草稿', exact: true }).click(); await expect(editor.getByRole('button', { name: '已保存', exact: true })).toBeVisible(); expect((await calls(page)).filter(call => call.method === 'PATCH').map(call => (call.input?.configuration as { knowledges: string[] }).knowledges)) @@ -186,7 +274,7 @@ for (const width of [375, 1440]) { await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl); const { editor, knowledge } = await openKnowledge(page); await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); - await knowledge.getByText('上传自己的文件', { exact: true }).click(); + await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || ['createKnowledge', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]); await knowledge.getByRole('button', { name: '新建知识库', exact: true }).click(); await knowledge.getByRole('textbox', { name: '知识库名称', exact: true }).fill('我的春天资料'); @@ -195,7 +283,7 @@ for (const width of [375, 1440]) { await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturecreated'); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('我的春天资料'); expect((await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge')).toEqual([]); - await knowledge.getByRole('button', { name: '上传文档', exact: true }).click(); + await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); const document = knowledge.getByRole('article', { name: '春天观察记录.md', exact: true }); await expect(document.getByText('等待处理', { exact: true })).toBeVisible(); const mutations = (await calls(page)).filter(call => call.path.endsWith('/knowledge/pick') || call.input?.operation === 'processKnowledge'); @@ -210,7 +298,7 @@ for (const width of [375, 1440]) { await expect(document.getByText('可用于回答', { exact: true })).toBeVisible({ timeout: 12_000 }); await expect(document.getByText('2 KB · 6 段可检索内容', { exact: true })).toBeVisible(); await document.scrollIntoViewIfNeeded(); - await expectNoClipping(page); await page.screenshot({ path: test.info().outputPath(`knowledge-upload-ready-${width}.png`), animations: 'disabled' }); + await expectKnowledgeLayout(page, width); await page.screenshot({ path: test.info().outputPath(`knowledge-upload-ready-${width}.png`), animations: 'disabled' }); // Document mutations never implicitly save the Agent draft. expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); await editor.getByRole('button', { name: '保存草稿', exact: true }).click(); @@ -218,6 +306,52 @@ for (const width of [375, 1440]) { expect(await page.evaluate(() => (window as unknown as FixtureWindow).__knowledgeFixture.agent.configuration.knowledges)) .toEqual(['kb_fixturescience', 'kb_fixturecreated']); }); + + test(`chat files show their source and metadata and import multiple choices into the named own library at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 850 }); await installFixture(page); await page.goto(baseUrl); + const { knowledge } = await openKnowledge(page); + await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); + await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); + await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); + const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); + await expect(picker).toContainText('导入到「我的观察记录」'); + await picker.getByRole('textbox', { name: '搜索对话', exact: true }).fill('春天'); + await expect(picker.getByRole('button', { name: '选择对话 图书阅读记录', exact: true })).toHaveCount(0); + await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); + await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).toBeVisible(); + await expect(picker.locator('.agent-knowledge-import-file').filter({ has: page.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }) })) + .toContainText(/(?:MD|Markdown)\s*·\s*(?:1\.5|2)\s*KB/i); + await picker.getByRole('textbox', { name: '搜索文件', exact: true }).fill('植物'); + await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).toHaveCount(0); + await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); + await expect(picker.locator('.agent-knowledge-import-file').filter({ has: page.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }) })) + .toContainText(/CSV\s*·\s*2\s*KB/); + await picker.getByRole('textbox', { name: '搜索文件', exact: true }).fill(''); + await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); + await picker.getByRole('textbox', { name: '搜索对话', exact: true }).fill('图书'); + await picker.getByRole('button', { name: '选择对话 图书阅读记录', exact: true }).click(); + await picker.getByRole('checkbox', { name: '选择文件 阅读笔记.pdf', exact: true }).check(); + const confirm = picker.getByRole('button', { name: '导入 3 个文件', exact: true }); + await expect(confirm).toBeEnabled(); + await expect(confirm).toBeInViewport(); + expect((await calls(page)).filter(call => ['importKnowledgeAttachment', 'processKnowledge'].includes(String(call.input?.operation)))).toEqual([]); + await expectPickerBounds(page); + await page.screenshot({ path: test.info().outputPath(`knowledge-chat-picker-${width}.png`), animations: 'disabled' }); + await confirm.click(); + await expect(picker).toHaveCount(0); + for (const name of ['春天观察报告.md', '植物清单.csv', '阅读笔记.pdf']) { + await expect(knowledge.getByRole('article', { name, exact: true })).toBeVisible(); + } + const imports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); + expect(imports).toHaveLength(3); + expect(imports.map(call => (call.input?.input as { attachment_id: string }).attachment_id).sort()) + .toEqual(['attachment-observations', 'attachment-plants', 'attachment-reading']); + expect(imports.every(call => (call.input?.input as { kb_id: string }).kb_id === 'kb_fixturepersonal')).toBe(true); + expect((await calls(page)).filter(call => call.input?.operation === 'processKnowledge')).toHaveLength(3); + expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); + await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); + await expectKnowledgeLayout(page, width); + }); } test('cancelled and failed uploads preserve shared choices and the personal draft', async ({ page }) => { @@ -227,15 +361,15 @@ test('cancelled and failed uploads preserve shared choices and the personal draf await editor.getByRole('textbox', { name: '提示词', exact: true }).fill('这是我还没有保存的提示词。'); await editor.getByRole('tab', { name: '高级设置', exact: true }).click(); await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); - await knowledge.getByText('上传自己的文件', { exact: true }).click(); + await expect(knowledge.getByRole('heading', { name: '上传自己的文件', exact: true })).toBeVisible(); await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'cancel'; }); - await knowledge.getByRole('button', { name: '上传文档', exact: true }).click(); - await expect(knowledge.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled(); + await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); + await expect(knowledge.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled(); await expect(knowledge.getByRole('article')).toHaveCount(0); expect((await calls(page)).filter(call => call.input?.operation === 'processKnowledge')).toEqual([]); await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.upload = 'fail'; }); - await knowledge.getByRole('button', { name: '上传文档', exact: true }).click(); + await knowledge.getByRole('button', { name: '从电脑上传', exact: true }).click(); await expect(knowledge.getByRole('alert')).toContainText('上传服务暂不可用'); await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toHaveValue('kb_fixturepersonal'); await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); @@ -255,3 +389,93 @@ test('cancelled and failed uploads preserve shared choices and the personal draf await editor.getByRole('tab', { name: '介绍与提示词', exact: true }).click(); await expect(editor.getByRole('textbox', { name: '提示词', exact: true })).toHaveValue('这是我还没有保存的提示词。'); }); + +test('a partial chat-file import retries only the failed file with the same operation identity', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); + const { knowledge } = await openKnowledge(page); + await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); + await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); + await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = ['attachment-plants']; }); + await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); + const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); + await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); + await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); + await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); + const confirm = picker.getByRole('button', { name: '导入 2 个文件', exact: true }); + await expect(confirm).toBeInViewport(); + await confirm.click({ timeout: 15_000 }); + await expect(picker).toContainText('植物清单导入暂时失败'); + const retry = picker.getByRole('button', { name: '重试失败的 1 个文件', exact: true }); + await expect(retry).toBeEnabled(); + await expect(retry).toBeInViewport(); + await expect(picker.getByRole('button', { name: '关闭文件选择', exact: true })).toBeDisabled(); + await page.keyboard.press('Escape'); + await expect(picker).toBeVisible(); + const originalImports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); + expect(originalImports).toHaveLength(2); + await expect(picker.getByRole('list', { name: '文件导入结果', exact: true }).getByRole('listitem').filter({ hasText: '春天观察报告.md' })) + .toContainText('已导入,处理状态见知识库'); + await expectPickerBounds(page); + await page.screenshot({ path: test.info().outputPath('knowledge-chat-partial-failure-375.png'), animations: 'disabled' }); + await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = []; }); + await retry.click(); await expect(picker).toHaveCount(0); + await expect(knowledge.getByRole('article', { name: '春天观察报告.md', exact: true })).toBeVisible(); + await expect(knowledge.getByRole('article', { name: '植物清单.csv', exact: true })).toBeVisible(); + const imported = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment') + .map(call => call.input?.input as { attachment_id: string; operation_id: string; kb_id: string }); + expect(imported.filter(input => input.attachment_id === 'attachment-observations')).toHaveLength(1); + const retried = imported.filter(input => input.attachment_id === 'attachment-plants'); + expect(retried).toHaveLength(2); expect(new Set(retried.map(input => input.operation_id)).size).toBe(1); + expect(imported.every(input => input.kb_id === 'kb_fixturepersonal')).toBe(true); + const processed = (await calls(page)).filter(call => call.input?.operation === 'processKnowledge') + .map(call => (call.input?.input as { file_id: string }).file_id); + expect(processed.sort()).toEqual(['imported-attachment-observations', 'imported-attachment-plants']); + expect((await calls(page)).filter(call => call.method === 'PATCH')).toEqual([]); + await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); + await expectNoClipping(page); +}); + +test('abandoning a failed batch keeps saved files and allows a fresh picker operation without replaying successes', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); + const { knowledge } = await openKnowledge(page); + await knowledge.getByRole('combobox', { name: '选择知识库', exact: true }).selectOption('kb_fixturescience'); + await knowledge.getByRole('combobox', { name: '管理的知识库', exact: true }).selectOption('kb_fixturepersonal'); + await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = ['attachment-plants']; }); + await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); + const picker = page.getByRole('dialog', { name: '从聊天选择文件', exact: true }); + await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); + await picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true }).check(); + await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); + await expect(picker.getByRole('button', { name: '导入 2 个文件', exact: true })).toBeInViewport(); + await picker.getByRole('button', { name: '导入 2 个文件', exact: true }).click({ timeout: 15_000 }); + await expect(picker.getByRole('button', { name: '重试失败的 1 个文件', exact: true })).toBeEnabled(); + const importsBefore = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment'); + await picker.getByRole('button', { name: '放弃重试', exact: true }).click(); + await expect(picker).toContainText('已保存的文件会保留'); + const abandon = picker.getByRole('button', { name: '确认放弃重试', exact: true }); + await abandon.scrollIntoViewIfNeeded(); await expect(abandon).toBeInViewport(); + await expectPickerBounds(page); + await page.screenshot({ path: test.info().outputPath('knowledge-chat-abandon-confirm-375.png'), animations: 'disabled' }); + await abandon.click(); await expect(picker).toHaveCount(0); + expect((await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment')).toEqual(importsBefore); + expect((await calls(page)).filter(call => /delete/i.test(String(call.input?.operation)) || call.method === 'DELETE')).toEqual([]); + await expect(knowledge.getByRole('article', { name: '春天观察报告.md', exact: true })).toBeVisible(); + await expect(knowledge.getByRole('combobox', { name: '管理的知识库', exact: true })).toBeEnabled(); + await page.evaluate(() => { (window as unknown as FixtureWindow).__knowledgeFixture.importFailures = []; }); + await knowledge.getByRole('button', { name: '从聊天选择文件', exact: true }).click(); + await picker.getByRole('button', { name: '选择对话 春天的观察讨论', exact: true }).click(); + await expect(picker.getByRole('checkbox', { name: '选择文件 春天观察报告.md', exact: true })).not.toBeChecked(); + await picker.getByRole('checkbox', { name: '选择文件 植物清单.csv', exact: true }).check(); + await picker.getByRole('button', { name: '导入 1 个文件', exact: true }).click(); + await expect(picker).toHaveCount(0); + const imports = (await calls(page)).filter(call => call.input?.operation === 'importKnowledgeAttachment') + .map(call => call.input?.input as { attachment_id: string; operation_id: string }); + expect(imports.filter(input => input.attachment_id === 'attachment-observations')).toHaveLength(1); + const plants = imports.filter(input => input.attachment_id === 'attachment-plants'); + expect(plants).toHaveLength(2); expect(new Set(plants.map(input => input.operation_id)).size).toBe(2); + const processed = (await calls(page)).filter(call => call.input?.operation === 'processKnowledge') + .map(call => (call.input?.input as { file_id: string }).file_id); + expect(processed.sort()).toEqual(['imported-attachment-observations', 'imported-attachment-plants']); + await expect(knowledge.getByRole('list', { name: '已选知识库', exact: true })).toContainText('科学知识'); + await expectNoClipping(page); +}); diff --git a/tests/e2e/cloud-agent-navigation-layout.spec.ts b/tests/e2e/cloud-agent-navigation-layout.spec.ts index 733c74b9..4d138f87 100644 --- a/tests/e2e/cloud-agent-navigation-layout.spec.ts +++ b/tests/e2e/cloud-agent-navigation-layout.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; +import { createServer, normalizePath, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; let server: ViteDevServer; @@ -9,7 +9,7 @@ test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, // Exercise actual navigation controls and persisted layout state without // starting Electron or connecting to an account or any cloud service. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-navigation-layout-harness.tsx'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-navigation-layout-harness.tsx')); server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-navigation-layout', diff --git a/tests/e2e/cloud-agent-platform-avatars.spec.ts b/tests/e2e/cloud-agent-platform-avatars.spec.ts index 144fd2f7..54865fb6 100644 --- a/tests/e2e/cloud-agent-platform-avatars.spec.ts +++ b/tests/e2e/cloud-agent-platform-avatars.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { createServer, type ViteDevServer } from 'vite'; +import { createServer, normalizePath, type ViteDevServer } from 'vite'; import { resolve } from 'node:path'; import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft } from '../../shared/cloud-agents'; @@ -10,7 +10,7 @@ test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, // Render the real workspace and keep all service calls at the Main IPC boundary. // This suite never starts Electron, opens a native file picker or calls a live service. test.beforeAll(async () => { - const entry = resolve('tests/e2e/__cloud-agent-platform-avatars-harness.tsx'); + const entry = normalizePath(resolve('tests/e2e/__cloud-agent-platform-avatars-harness.tsx')); server = await createServer({ configFile: false, cacheDir: 'node_modules/.vite-cloud-agent-platform-avatars', diff --git a/tests/e2e/cloud-agents.spec.ts b/tests/e2e/cloud-agents.spec.ts index bb52dbe4..5b622356 100644 --- a/tests/e2e/cloud-agents.spec.ts +++ b/tests/e2e/cloud-agents.spec.ts @@ -18,8 +18,10 @@ test('personal cloud Agent identity, configuration and separate published chat i let draft: Record | null = null; let recent: unknown = null; let createInput: Record | null = null; + const creationInputs: Record[] = []; + let pending: Record[] = []; const operations: string[] = []; - Reflect.set(globalThis, '__cloudAgentUiFixture', { snapshot: () => ({ draft, createInput, operations }) }); + Reflect.set(globalThis, '__cloudAgentUiFixture', { snapshot: () => ({ draft, createInput, creationInputs, operations }) }); const versions: { version: number; draft_revision: number; created_at: string }[] = []; let knowledgeCatalogLoads = 0; let knowledgeCreated = false; @@ -51,14 +53,15 @@ test('personal cloud Agent identity, configuration and separate published chat i knowledgeUploaded = true; return result({ ...knowledgeFile, status: 'uploaded', chunk_count: 0, available: false, processing_task: null }); } - if (path.endsWith('/recovery')) return result({ recent, pending: [] }); + if (path.endsWith('/recovery')) return result({ recent, pending }); + if (path.endsWith('/recovery/pending') && body.discard === true) { pending = []; return result({ discarded: true }); } if (path.endsWith('/recovery/recent')) { recent = body; return result({ saved: true }); } if (path.endsWith('/actions')) { operations.push(body.operation); const input = body.input ?? {}; if (body.operation === 'catalog') return result({ models: [{ id: 'test-model', name: '创作模型', description: '模型说明不应显示在配置页' }, { id: 'reasoning-model', name: '思考模型' }], - resources: { tools: [{ key: 'fixture-tool', name: '资料整理工具' }], skills: [{ key: 'fixture-skill', name: '文章整理 Skill' }] }, + resources: { tools: [{ key: 'fixture-tool', name: '资料整理工具' }, { key: 'web_search', name: '上网查资料', description: '在网上查找相关信息。' }], skills: [{ key: 'fixture-skill', name: '文章整理 Skill' }, { key: 'deep-research', name: '查资料,做研究', description: '比较不同说法,再整理出结论和出处。' } ] }, pricing: { unit: '词元点数', usage_unit: '词元', unit_size: 1000, points_per_unit: '9.99', version: 1 }, }); if (body.operation === 'knowledge') return result({ databases: knowledgeCreated ? [{ kb_id: 'kb', name: '我的创作资料' }] : [], models: ++knowledgeCatalogLoads === 1 ? [] : [{ id: 'fixture:test-embedding', name: '资料向量模型', dimension: 1024 }] }); @@ -98,7 +101,19 @@ test('personal cloud Agent identity, configuration and separate published chat i if (body.operation === 'files') return result({ files: [] }); throw new Error('Unexpected cloud fixture operation ' + body.operation); } - if (method === 'POST') { createInput = body; draft = { + if (method === 'POST') { + creationInputs.push(body); + if (creationInputs.length === 1) return { ok: true, data: { status: 422, ok: false, + json: { error: '名称没有被接受,请修改后重试', code: 'invalid_name', commandOutcome: 'definitive_failure' } } }; + if (creationInputs.length === 2) { + pending = [{ id: `createAgent::${body.operation_id}`, operation: 'createAgent', input: body, created_at: new Date().toISOString() }]; + return { ok: true, data: { status: 502, ok: false, + json: { error: '暂时无法确认创建结果', code: 'cloud_service_unavailable', commandOutcome: 'unknown' } } }; + } + if (creationInputs.length === 3) return { ok: true, data: { status: 422, ok: false, + json: { error: '名称没有被接受,请稍后重试', code: 'invalid_name', commandOutcome: 'definitive_failure' } } }; + pending = []; + createInput = body; draft = { slug: 'ml-' + 'a'.repeat(32), name: body.name, purpose: body.purpose, system_prompt: body.purpose, icon: body.icon ?? null, icon_url: body.icon === uploadedAvatar.icon ? uploadedAvatar.icon_url : null, draft_revision: 1, updated_at: '2026-09-10T00:00:00Z', @@ -161,11 +176,26 @@ test('personal cloud Agent identity, configuration and separate published chat i await expect(page.getByRole('button', { name: '上一步', exact: true })).toHaveCount(0); await expect(page.getByRole('button', { name: '下一步', exact: true })).toBeDisabled(); await page.screenshot({ path: testInfo.outputPath('cloud-agent-create-empty-form.png') }); - await page.getByLabel('名称', { exact: true }).fill('我的写作搭档'); + await page.getByLabel('名称', { exact: true }).fill('待修改的名字'); await page.getByRole('button', { name: '上传头像', exact: true }).click(); - await expect(page.getByRole('img', { name: '我的写作搭档头像', exact: true })).toHaveAttribute('src', avatar.icon_url); + await expect(page.getByRole('img', { name: '待修改的名字头像', exact: true })).toHaveAttribute('src', avatar.icon_url); await page.screenshot({ path: testInfo.outputPath('cloud-agent-create-identity.png') }); await page.getByRole('button', { name: '下一步', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('创建失败'); + await expect(page.getByLabel('名称', { exact: true })).toBeEnabled(); + await expect(page.getByRole('button', { name: '更换头像', exact: true })).toBeEnabled(); + await expect(page.getByRole('complementary', { name: '未确认的云端操作' })).toHaveCount(0); + await page.getByLabel('名称', { exact: true }).fill('我的写作搭档'); + await page.getByRole('button', { name: '下一步', exact: true }).click(); + await expect(page.getByLabel('名称', { exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: '更换头像', exact: true })).toBeDisabled(); + await expect(page.getByRole('complementary', { name: '未确认的云端操作' })).toBeVisible(); + await page.getByRole('button', { name: '移除提醒', exact: true }).click(); + await expect(page.getByRole('complementary', { name: '未确认的云端操作' })).toHaveCount(0); + await page.getByRole('button', { name: '重试创建', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('创建结果暂未确认'); + await expect(page.getByLabel('名称', { exact: true })).toBeDisabled(); + await page.getByRole('button', { name: '重试创建', exact: true }).click(); const editor = page.getByTestId('cloud-agent-editor'); const instructionsTab = page.getByRole('tab', { name: '介绍与提示词', exact: true }); @@ -189,6 +219,10 @@ test('personal cloud Agent identity, configuration and separate published chat i const created = await fixtureState(); expect(created.createInput).toMatchObject({ name: '我的写作搭档', icon: avatar.icon, purpose: '' }); expect(created.createInput).not.toHaveProperty('icon_url'); + expect(created.creationInputs).toHaveLength(4); + expect(created.creationInputs[0].operation_id).not.toBe(created.creationInputs[1].operation_id); + expect(created.creationInputs[1]).toEqual(created.creationInputs[2]); + expect(created.creationInputs[1]).toEqual(created.creationInputs[3]); expect(await messageOperations()).toEqual([]); await page.getByRole('button', { name: '修改头像', exact: true }).click(); @@ -214,7 +248,11 @@ test('personal cloud Agent identity, configuration and separate published chat i await expect(page.getByRole('heading', { name: '费用上限', exact: true })).toHaveCount(0); await expect(page.getByRole('heading', { name: '运行限制', exact: true })).toHaveCount(0); await page.getByRole('button', { name: '选择工具箱', exact: true }).click(); + await expect(page.getByRole('checkbox', { name: /上网查资料.*在网上查找相关信息/ })).toBeVisible(); await page.getByRole('checkbox', { name: '资料整理工具', exact: true }).check(); + await page.getByRole('button', { name: '选择做事手册', exact: true }).click(); + await page.getByLabel('搜索做事手册', { exact: true }).fill('出处'); + await expect(page.getByRole('checkbox', { name: /查资料,做研究.*出处/ })).toBeVisible(); await instructionsTab.click(); await expect(introduction).toHaveValue('一起梳理故事和文章'); await expect(prompt).toHaveValue(initialPrompt); diff --git a/tests/e2e/cloud-chat-rendering.spec.ts b/tests/e2e/cloud-chat-rendering.spec.ts new file mode 100644 index 00000000..38f2151a --- /dev/null +++ b/tests/e2e/cloud-chat-rendering.spec.ts @@ -0,0 +1,101 @@ +import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; + +test('personal chat displays thinking, tools and Markdown, then restores them after reload', async ({ launchElectronApp }, testInfo) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + const applicationUrl = page.url(); + await page.goto('about:blank'); + // Cloud provider is deterministic here; the real history HTTP/PG boundary is tested in Yuxi. + await app.evaluate(({ ipcMain, BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].setSize(1180, 820); + let complete = false; + Object.assign(globalThis, { finishRenderRun: () => { complete = true; } }); + const slug = 'ml-' + 'a'.repeat(32); + const configuration = { model: 'test', tools: [], knowledges: [], mcps: [], skills: [], preload_skills: [], subagents: [], tool_approval_mode: 'always_trust', max_execution_steps: 300, max_output_tokens: 4096, max_run_seconds: 600 }; + const draft = { slug, name: '渲染测试助手', purpose: '整理资料', system_prompt: '', configuration, + draft_revision: 1, published_version: 1, enabled: true, updated_at: '' }; + const run = { agent_run_id: 'render-run', request_id: 'render-request', thread_id: 'render-thread', agent_slug: slug, status: 'running', output: '', version: '1' }; + const thread = { thread_id: 'render-thread', client_thread_id: 'client', agent_slug: slug, title: '整理资料', mode: 'published', updated_at: '', run_id: run.agent_run_id, unread: false, status: 'running' }; + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; body?: string }) => { + const path = request.path ?? ''; + const body = request.body ? JSON.parse(request.body) : {}; + if (path === '/api/auth/session/sync') return result({ success: true, session: { accessToken: 'fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } }); + if (path === '/api/auth/me') return result({ success: true, user: { username: 'creator', userId: 'creator', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '创作者', completed: true, version: 1 } }); + if (path.endsWith('/recovery')) return result({ recent: null, pending: [] }); + if (path.endsWith('/recovery/recent')) return result({}); + if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [draft], next_cursor: null }); + if (path.endsWith('/actions')) { + if (body.operation === 'threads') return result({ threads: [thread], next_offset: null }); + if (body.operation === 'history') return result({ thread_id: thread.thread_id, run: { ...run, status: complete ? 'completed' : 'running' }, next_offset: null, messages: complete ? [{ + id: 1, message_id: 'answer', role: 'assistant', run_id: run.agent_run_id, request_id: run.request_id, created_at: '', + content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**', + reasoning_content: '先核对来源,再整理结果。', + tool_calls: [{ id: 'lookup', name: 'web_search', display_name: '上网查资料', display_description: '在网上查找相关信息。', args: { query: '实际检索资料' }, status: 'success', tool_call_result: { content: '找到两条资料' } }], + }] : [] }); + if (body.operation === 'run') return result({ ...run, status: complete ? 'completed' : 'running' }); + if (body.operation === 'viewed') return result({}); + if (body.operation === 'files') return result({ files: [] }); + if (body.operation === 'attachments') return result({ attachments: [] }); + if (body.operation === 'catalog') return result({ models: [{ id: 'test', name: '测试模型' }], resources: {}, pricing: null }); + if (body.operation === 'access') return result({ published_version: 1, enabled: true, versions: [], grants: [], applications: [], share_url: '', api_url: '' }); + if (body.operation === 'channelBindings') return result({ items: [], available_accounts: [] }); + return result({}); + } + if (path.startsWith('/api/cloud-agents/agents/')) return result(draft); + return result({ success: true }); + }); + }); + await page.addInitScript(() => { + const sources: EventTarget[] = []; + Object.assign(window, { renderSources: sources }); + class FixtureSource extends EventTarget { + onopen = null; onerror = null; close() {} + constructor(url: string) { super(); if (url.includes('/cloud-agents/runs/')) sources.push(this); } + } + Object.defineProperty(window, 'EventSource', { value: FixtureSource }); + }); + await page.goto(applicationUrl); + await page.getByTestId('ai-module-option-cloud_agents').click(); + await page.getByRole('article', { name: '渲染测试助手', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click(); + const emit = async (items: unknown[], cursor: string) => page.evaluate(({ items, cursor }) => { + const sources = (window as unknown as { renderSources: EventTarget[] }).renderSources; + sources.at(-1)!.dispatchEvent(new MessageEvent('messages', { lastEventId: cursor, data: JSON.stringify({ run_id: 'render-run', thread_id: 'render-thread', payload: { items } }) })); + }, { items, cursor }); + await expect.poll(() => page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.length)).toBeGreaterThan(0); + await emit([{ stream_event: { type: 'message_delta', message_id: 'answer', reasoning_content: '先核对来源,再整理结果。' } }], '100-1'); + const process = page.getByTestId('cloud-message-process'); + await expect(page.getByLabel('智能体思考过程')).toBeVisible(); + await emit([ + { stream_event: { type: 'tool_call', message_id: 'answer', tool_call_id: 'lookup', name: 'web_search', display_name: '上网查资料', display_description: '在网上查找相关信息。', args: { query: '测试资料' } } }, + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'lookup', input: { query: '实际检索资料' } } } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'lookup', output: { content: '找到两条资料' } } } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '## 整理结果\n\n| 项目 | 状态 |\n| --- | --- |\n| 资料 | 已核对 |\n\n**可以使用**' } }, + ], '100-2'); + await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible(); + await expect(page.getByRole('table')).toBeVisible(); + await process.locator(':scope > summary').click(); + await process.getByText('上网查资料 · 已完成', { exact: true }).click(); + await expect(process.getByText('找到两条资料', { exact: true })).toBeVisible(); + await expect(process.getByText('在网上查找相关信息。', { exact: true })).toBeVisible(); + await expect(process.getByText('工具标识:web_search', { exact: true })).toBeVisible(); + await expect(process.getByText('"实际检索资料"', { exact: false })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('personal-chat-process.png') }); + await app.evaluate(() => (globalThis as unknown as { finishRenderRun: () => void }).finishRenderRun()); + await page.evaluate(() => (window as unknown as { renderSources: EventTarget[] }).renderSources.at(-1)!.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'render-run', thread_id: 'render-thread', payload: { status: 'completed' } }) }))); + await expect(page.getByRole('heading', { name: '整理结果' })).toHaveCount(1); + await page.reload(); + await page.getByRole('article', { name: '渲染测试助手', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click(); + await expect(page.getByRole('heading', { name: '整理结果' })).toBeVisible(); + await page.getByTestId('cloud-message-process').locator(':scope > summary').click(); + await expect(page.getByLabel('智能体思考过程')).toHaveText('先核对来源,再整理结果。'); + await page.getByText('上网查资料 · 已完成', { exact: true }).click(); + await expect(page.getByText('在网上查找相关信息。', { exact: true })).toBeVisible(); + await expect(page.getByText('工具标识:web_search', { exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('personal-chat-reopened.png') }); + } finally { await closeElectronApp(app); } +}); diff --git a/tests/unit/cloud-agent-avatar-routes.test.ts b/tests/unit/cloud-agent-avatar-routes.test.ts index 7657c94f..3c4cb571 100644 --- a/tests/unit/cloud-agent-avatar-routes.test.ts +++ b/tests/unit/cloud-agent-avatar-routes.test.ts @@ -2,11 +2,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; import { beforeEach, expect, it, vi } from 'vitest'; import { handleCloudAgentsRoutes } from '@electron/api/routes/cloud-agents'; +import { CloudAgentsError } from '@electron/services/cloud-agents'; -const service = vi.hoisted(() => ({ useAvatarPreset: vi.fn(), uploadAvatar: vi.fn() })); +const service = vi.hoisted(() => ({ useAvatarPreset: vi.fn(), uploadAvatar: vi.fn(), create: vi.fn() })); vi.mock('@electron/services/cloud-agents', () => ({ - CloudAgentsModule: class { useAvatarPreset = service.useAvatarPreset; uploadAvatar = service.uploadAvatar; }, - CloudAgentsError: class extends Error { constructor(readonly status: number, readonly code: string) { super(code); } }, + CloudAgentsModule: class { useAvatarPreset = service.useAvatarPreset; uploadAvatar = service.uploadAvatar; create = service.create; }, + CloudAgentsError: class extends Error { constructor(readonly status: number, readonly code: string, + readonly commandOutcome = 'unknown') { super(code); } }, })); beforeEach(() => vi.resetAllMocks()); @@ -35,3 +37,20 @@ it('keeps the existing file picker route and rejects malformed preset JSON', asy expect(service.useAvatarPreset).not.toHaveBeenCalled(); expect((await call('/avatars/preset', undefined, 'GET')).status).toBe(404); }); + +it.each(['definitive_failure', 'unknown'] as const)('preserves creation outcome %s across the Host API', async outcome => { + service.create.mockRejectedValue(new CloudAgentsError(422, 'invalid_input', outcome)); + expect(await call('/agents', JSON.stringify({ name: 'test' }))).toEqual({ + handled: true, status: 422, + body: { success: false, error: 'invalid_input', code: 'invalid_input', commandOutcome: outcome }, + }); +}); + +it('marks malformed creation JSON as rejected and unexpected failures as uncertain', async () => { + expect((await call('/agents', '{')).body.commandOutcome).toBe('definitive_failure'); + expect(service.create).not.toHaveBeenCalled(); + service.create.mockRejectedValue(new Error('private internal error')); + const result = await call('/agents', '{}'); + expect(result).toMatchObject({ status: 502, body: { code: 'cloud_service_unavailable', commandOutcome: 'unknown' } }); + expect(result.body.error).not.toContain('private'); +}); diff --git a/tests/unit/cloud-agents-main.test.ts b/tests/unit/cloud-agents-main.test.ts index df61c18c..dc1b8139 100644 --- a/tests/unit/cloud-agents-main.test.ts +++ b/tests/unit/cloud-agents-main.test.ts @@ -44,6 +44,141 @@ function moduleFor(fetchImpl: typeof fetch) { beforeEach(() => { vi.clearAllMocks(); journalRecords.clear(); clearWorksSquareSession(); login(); }); afterEach(() => { instances.splice(0).forEach((module) => module.dispose()); clearWorksSquareSession(); vi.useRealTimers(); }); +describe('creation failure recovery', () => { + const validation = (field = 'purpose', type = 'string_too_short') => ({ detail: [{ + loc: ['body', field], type, msg: 'private upstream text', input: 'private input', ctx: { secret: 'private context' }, + }] }); + + it('clears a first rejected creation and explains the empty-introduction service mismatch safely', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422)); + const module = moduleFor(transport); + await expect(module.create({ ...input, purpose: '' })).rejects.toMatchObject({ + status: 422, code: 'introduction_required', commandOutcome: 'definitive_failure', + message: '当前智能体服务尚不支持一句话介绍留空,请等待服务更新后重试', + }); + expect((await module.recovery()).pending).toEqual([]); + expect(JSON.parse(transport.mock.calls[1][1].body).purpose).toBe(''); + }); + + it('does not diagnose an empty introduction when a nonempty introduction is rejected as too short', async () => { + const module = moduleFor(vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422))); + await expect(module.create(input)).rejects.toMatchObject({ code: 'introduction_too_short', commandOutcome: 'definitive_failure' }); + expect((await module.recovery()).pending).toEqual([]); + }); + + it.each([ + ['name', 'string_too_long', 'invalid_name'], ['purpose', 'string_too_long', 'invalid_introduction'], + ['icon', 'string_type', 'invalid_icon'], ['private-field', 'missing', 'invalid_input'], + ])('maps request validation for %s to a fixed product message', async (field, type, code) => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(field, type), 422)); + const module = moduleFor(transport); + const error = await module.create(input).catch(failure => failure); + expect(error).toMatchObject({ code, commandOutcome: 'definitive_failure' }); + expect(error.message).not.toMatch(/private|secret|upstream/u); + expect((await module.recovery()).pending).toEqual([]); + }); + + it.each([ + [400, 'invalid_input'], [422, 'invalid_input'], [401, 'session_expired'], + [403, 'module_unavailable'], [423, 'account_locked'], + ])('clears a fresh creation only on a recognized rejection (%s %s)', async (status, code) => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: { code } }, status)); + const module = moduleFor(transport); + await expect(module.create(input)).rejects.toMatchObject({ code, commandOutcome: 'definitive_failure' }); + expect((await module.recovery()).pending).toEqual([]); + }); + + it.each([408, 409, 429, 500, 502])('keeps ambiguous creation status %s recoverable with its original input', async status => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ detail: { code: 'invalid_input' } }, status)); + const module = moduleFor(transport); + await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toMatchObject([{ operation: 'createAgent', input }]); + }); + + it.each([ + new Response('not JSON', { status: 422 }), + json({ detail: [{ loc: ['response', 'purpose'], type: 'string_too_short', msg: 'private' }] }, 422), + json({ detail: [{ loc: ['body', 'purpose'], type: 'unrecognized-validation' }] }, 422), + json({ detail: { code: 'unknown-error', message: 'private' } }, 422), + json({ ...draft, slug: 'invalid-response-slug' }), + ])('keeps malformed errors or successful responses pending rather than allowing another creation', async response => { + const module = moduleFor(vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(response)); + await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toHaveLength(1); + }); + + it('preserves an older uncertain creation after a later validation rejection, then recovers success with the same ID', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')) + .mockResolvedValueOnce(json(validation(), 422)).mockResolvedValueOnce(json({ ...draft, purpose: '' })); + const module = moduleFor(transport); + const original = { ...input, purpose: '' }; + await expect(module.create(original)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + const pending = (await module.recovery()).pending[0]; + await expect(module.resolvePending({ id: pending.id })).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toEqual([pending]); + expect(await module.resolvePending({ id: pending.id })).toMatchObject({ result: { slug: draft.slug } }); + expect((await module.recovery()).pending).toEqual([]); + expect(transport.mock.calls.slice(1).map(call => JSON.parse(call[1].body))).toEqual([original, original, original]); + }); + + it('does not treat local validation as proof an older operation failed', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')); + const module = moduleFor(transport); + await expect(module.create(input)).rejects.toMatchObject({ commandOutcome: 'unknown' }); + await expect(module.create({ ...input, name: '' })).rejects.toMatchObject({ commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toHaveLength(1); + expect(transport).toHaveBeenCalledTimes(2); + }); + + it('shares simultaneous identical creates and rejects changed input without a second POST', async () => { + let finish!: (response: Response) => void; + const transport = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const module = moduleFor(transport); + const first = module.create(input); + const second = module.create(input); + const firstRejected = expect(first).rejects.toMatchObject({ commandOutcome: 'definitive_failure' }); + const secondRejected = expect(second).rejects.toMatchObject({ commandOutcome: 'definitive_failure' }); + await expect(module.create({ ...input, name: 'changed input' })).rejects.toMatchObject({ code: 'operation_conflict', commandOutcome: 'unknown' }); + await vi.waitFor(() => expect(finish).toBeDefined()); + finish(json(validation(), 422)); + await Promise.all([firstRejected, secondRejected]); + expect(transport).toHaveBeenCalledTimes(2); + expect((await module.recovery()).pending).toEqual([]); + }); + + it('keeps the original account record when a rejection arrives after an account switch', async () => { + let finish!: (response: Response) => void; + const transport = vi.fn().mockResolvedValueOnce(session()).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const module = moduleFor(transport); + const rejected = expect(module.create(input)).rejects.toMatchObject({ code: 'account_changed', commandOutcome: 'unknown' }); + await vi.waitFor(() => expect(finish).toBeDefined()); + const pending = (await module.recovery()).pending[0]; + login('b'.repeat(64)); + finish(json(validation(), 422)); + await rejected; + expect((await module.recovery()).pending).toEqual([]); + expect(Array.from(journalRecords.values()).some(state => state.pending.some(item => item.id === pending.id))).toBe(true); + }); + + it('preserves recovery behavior for other operations after a validation rejection', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(validation(), 422)); + const module = moduleFor(transport); + await expect(module.execute({ operation: 'createSchedule', input: { slug: draft.slug, operation_id: input.operation_id } })).rejects.toMatchObject({ status: 422, commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toHaveLength(1); + }); + + it('does not mark a rejected retry of another operation as a confirmed failure', async () => { + const transport = vi.fn().mockResolvedValueOnce(session()).mockRejectedValueOnce(new Error('lost response')) + .mockResolvedValueOnce(json(validation(), 422)); + const module = moduleFor(transport); + await expect(module.execute({ operation: 'createSchedule', input: { slug: draft.slug, operation_id: input.operation_id } })) + .rejects.toMatchObject({ commandOutcome: 'unknown' }); + const pending = (await module.recovery()).pending[0]; + await expect(module.resolvePending({ id: pending.id })).rejects.toMatchObject({ status: 422, commandOutcome: 'unknown' }); + expect((await module.recovery()).pending).toEqual([pending]); + }); +}); + it.each(['', ' '])('preserves an empty introduction through create, save and list (%j)', async purpose => { const emptyDraft = { ...draft, purpose: '' }; const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json(emptyDraft)) @@ -65,6 +200,24 @@ it.each([null, 42, 'x'.repeat(2001)])('rejects malformed or overlong introductio }); describe('Main cloud Agents boundary', () => { + it('projects saved reasoning and tool results for both personal and channel history without raw metadata', () => { + const message = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1', + extra_metadata: { internal: true }, tool_calls: [{ id: 'call-1', name: 'search', args: { query: 'topic' }, + display_name: '上网查资料', display_description: '在网上查找相关信息。', + status: 'success', internal: 'hidden', tool_call_result: { content: 'result', secret: 'hidden' } }] }; + const expected = { id: 1, role: 'assistant', content: 'answer', reasoning_content: 'thought', message_id: 'model-1', + tool_calls: [{ id: 'call-1', name: 'search', display_name: '上网查资料', display_description: '在网上查找相关信息。', + args: { query: 'topic' }, status: 'success', tool_call_result: { content: 'result' } }] }; + for (const input of [ + { operation: 'history', input: { thread_id: 'thread-1' } }, + { operation: 'channelConversation', input: { session_id: 'session-1' } }, + ]) { + expect(operationPlan(input).project({ messages: [message], run: null, next_offset: null })).toMatchObject({ messages: [expected] }); + expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('hidden'); + expect(JSON.stringify(operationPlan(input).project({ messages: [message] }))).not.toContain('extra_metadata'); + } + }); + it.each(Array.from({ length: 16 }, (_, index) => `avatar-${String(index + 1).padStart(2, '0')}`))('uploads the bundled platform portrait %s through scoped Main transport without a dialog', async id => { const icon = '/api/storage/public/images/creator/preset.png'; const transport = vi.fn().mockResolvedValueOnce(session()).mockResolvedValueOnce(json({ icon, private: 'hidden' })); diff --git a/tests/unit/cloud-agents-page.test.tsx b/tests/unit/cloud-agents-page.test.tsx index fab92a07..bd0289b7 100644 --- a/tests/unit/cloud-agents-page.test.tsx +++ b/tests/unit/cloud-agents-page.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { createMemoryRouter, RouterProvider } from 'react-router-dom'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import CloudAgents from '@/pages/CloudAgents'; +import { AppError } from '@/lib/error-model'; import { EMPTY_CLOUD_CONFIGURATION } from '../../shared/cloud-agents'; const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), uploadAvatar: vi.fn(), get: vi.fn(), save: vi.fn(), call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), resolvePending: vi.fn() })); @@ -610,6 +611,37 @@ it('guides an unconfirmed WeChat verification back to its code entry without cla expect(api.resolvePending).toHaveBeenCalledWith('wechat-code', false); }); +it('refreshes recovery after a definitive rejection without presenting a confirmed result', async () => { + const pending = { id: 'create:pending', operation: 'createAgent', input: { operation_id: 'same-operation', name: '旧搭档', purpose: '' }, created_at: '2026-09-13T01:00:00Z' }; + api.recovery.mockResolvedValueOnce({ recent: null, pending: [pending] }).mockResolvedValue({ recent: null, pending: [] }); + api.resolvePending.mockRejectedValue(new AppError('CONFIG', '名称不可用', undefined, { commandOutcome: 'definitive_failure' })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建智能体失败:名称不可用'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(screen.queryByRole('button', { name: '重试并确认结果' })).not.toBeInTheDocument(); + expect(screen.queryByText('创建智能体的结果已确认。')).not.toBeInTheDocument(); + expect(screen.queryByText('查看结果')).not.toBeInTheDocument(); + expect(api.resolvePending).toHaveBeenCalledWith(pending.id, false); + expect(api.create).not.toHaveBeenCalled(); +}); + +it('keeps an old recovery record retryable when a parameter response cannot determine the original outcome', async () => { + const pending = { id: 'create:old', operation: 'createAgent', input: { operation_id: 'original-operation', name: '旧搭档', purpose: '' }, created_at: '2026-09-13T01:00:00Z' }; + api.recovery.mockResolvedValue({ recent: null, pending: [pending] }); + api.resolvePending.mockRejectedValue(new AppError('CONFIG', '请检查名称', undefined, { commandOutcome: 'unknown', status: 422, code: 'invalid_input' })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + await screen.findByRole('alert'); + expect(screen.getByRole('button', { name: '重试并确认结果' })).toBeEnabled(); + expect(screen.queryByText('创建智能体的结果已确认。')).not.toBeInTheDocument(); + expect(api.recovery).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' })); + await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2)); + expect(api.resolvePending.mock.calls).toEqual([[pending.id, false], [pending.id, false]]); + expect(api.create).not.toHaveBeenCalled(); +}); + it('offers only explicit discard for a recovery record whose channel operation was removed', async () => { api.recovery.mockResolvedValueOnce({ recent: null, pending: [{ id: 'channel-policy-old', operation: 'channelPolicy', input: { slug: draft.slug, channel_account_id: 'wechat-1' }, created_at: '2026-09-13T01:00:00Z' }] }) @@ -648,6 +680,108 @@ it('freezes the uploaded avatar and creation identity when retrying a lost respo expect(screen.getByRole('img', { name: '写作搭档头像' })).toHaveAttribute('src', avatar.icon_url); }); +it('unlocks rejected creation input and uses a new operation identity after editing', async () => { + const avatar = { icon: '/api/storage/public/images/rejected.png', icon_url: 'https://images.test/rejected.png' }; + api.uploadAvatar.mockResolvedValue(avatar); + api.create.mockRejectedValueOnce(new AppError('CONFIG', '名称不可用', undefined, { commandOutcome: 'definitive_failure' })) + .mockImplementationOnce(async input => ({ ...draft, ...input })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true })); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '原名称' } }); + fireEvent.click(screen.getByRole('button', { name: '上传头像' })); + await screen.findByRole('button', { name: '更换头像' }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建失败:名称不可用'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(screen.getByLabelText('名称')).toBeEnabled(); + expect(screen.getByLabelText('名称')).toHaveValue('原名称'); + expect(screen.getByRole('button', { name: '更换头像' })).toBeEnabled(); + expect(screen.getByRole('button', { name: '移除头像' })).toBeEnabled(); + expect(screen.queryByText(/创建结果暂未确认/)).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的名称' } }); + fireEvent.click(screen.getByRole('button', { name: '移除头像' })); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(api.create).toHaveBeenCalledTimes(2); + const original = api.create.mock.calls[0][0]; + const corrected = api.create.mock.calls[1][0]; + expect(original).toMatchObject({ name: '原名称', purpose: '', icon: avatar.icon }); + expect(corrected).toMatchObject({ name: '新的名称', purpose: '', icon: null }); + expect(corrected.operation_id).not.toEqual(original.operation_id); + expect(api.recovery).toHaveBeenCalledTimes(3); +}); + +it('preserves the creation identity for unknown command outcomes even when the error says invalid input', async () => { + const avatar = { icon: '/api/storage/public/images/unknown.png', icon_url: 'https://images.test/unknown.png' }; + let retainedPending: unknown[] = []; + api.recovery.mockImplementation(async () => ({ recent: null, pending: retainedPending })); + api.uploadAvatar.mockResolvedValue(avatar); + api.create.mockImplementationOnce(async input => { + retainedPending = [{ id: 'create:unknown', operation: 'createAgent', input, created_at: '2026-09-13T01:00:00Z' }]; + throw new AppError('CONFIG', '请检查名称', undefined, { commandOutcome: 'unknown', status: 422, code: 'invalid_input' }); + }).mockImplementationOnce(async input => { + retainedPending = []; + return { ...draft, ...input, icon_url: avatar.icon_url }; + }); + open(); + fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true })); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '原搭档' } }); + fireEvent.click(screen.getByRole('button', { name: '上传头像' })); + await screen.findByRole('button', { name: '更换头像' }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果'); + await screen.findByText('有 1 个操作的结果尚未确认'); + expect(screen.getByLabelText('名称')).toBeDisabled(); + expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '移除头像' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: '重试创建' })); + await screen.findByTestId('cloud-agent-editor'); + expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); + await waitFor(() => expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument()); + expect(api.recovery).toHaveBeenCalledTimes(3); + expect(api.uploadAvatar).toHaveBeenCalledTimes(1); +}); + +it('keeps an uncertain creation identity after its reminder is removed and a retry is rejected', async () => { + const avatar = { icon: '/api/storage/public/images/discarded.png', icon_url: 'https://images.test/discarded.png' }; + let retainedPending: unknown[] = []; + api.recovery.mockImplementation(async () => ({ recent: null, pending: retainedPending })); + api.uploadAvatar.mockResolvedValue(avatar); + api.resolvePending.mockImplementation(async (_id, discard) => { + expect(discard).toBe(true); + retainedPending = []; + return { discarded: true }; + }); + api.create.mockImplementationOnce(async input => { + retainedPending = [{ id: 'create:discarded', operation: 'createAgent', input, created_at: '2026-09-13T01:00:00Z' }]; + throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' }); + }).mockRejectedValueOnce(new AppError('CONFIG', '参数被拒绝', undefined, { commandOutcome: 'definitive_failure' })) + .mockImplementationOnce(async input => ({ ...draft, ...input, icon_url: avatar.icon_url })); + open(); + fireEvent.click(await screen.findByRole('button', { name: '创建智能体', exact: true })); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '原搭档' } }); + fireEvent.click(screen.getByRole('button', { name: '上传头像' })); + await screen.findByRole('button', { name: '更换头像' }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + await screen.findByText('有 1 个操作的结果尚未确认'); + fireEvent.click(screen.getByRole('button', { name: '移除提醒', exact: true })); + await waitFor(() => expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument()); + expect(api.resolvePending).toHaveBeenCalledWith('create:discarded', true); + fireEvent.click(screen.getByRole('button', { name: '重试创建', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果。参数被拒绝'); + expect(screen.getByLabelText('名称')).toBeDisabled(); + expect(screen.getByLabelText('名称')).toHaveValue('原搭档'); + expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '移除头像' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: '下一步', exact: true })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '重试创建', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(api.create).toHaveBeenCalledTimes(3); + expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); + expect(api.create.mock.calls[2][0]).toEqual(api.create.mock.calls[0][0]); + expect(api.uploadAvatar).toHaveBeenCalledTimes(1); +}); + it('preserves local input on conflicts and requires an explicit choice before retry', async () => { api.save.mockRejectedValueOnce(new Error('草稿已更新')); api.get.mockResolvedValue({ ...draft, draft_revision: 2, system_prompt: '其他设备内容' }); @@ -829,7 +963,10 @@ it('searches capabilities while retaining existing selections outside the result api.list.mockResolvedValue({ agents: [configured], next_cursor: null }); const base = api.call.getMockImplementation()!; api.call.mockImplementation(async (operation, input) => operation === 'catalog' ? { - models: [{ id: 'test-model', name: '写作模型' }], resources: { tools: [{ key: 'search', name: '资料搜索', description: '搜索素材' }, { key: 'clock', name: '当前时间' }] }, pricing: null, + models: [{ id: 'test-model', name: '写作模型' }], resources: { + tools: [{ key: 'web_search', name: '上网查资料', description: '在网上查找相关信息。' }, { key: 'clock', name: '当前时间' }], + skills: [{ key: 'deep-research', name: '查资料,做研究', description: '比较不同说法,再整理出结论和出处。' }], + }, pricing: null, } : base(operation, input)); api.save.mockImplementation(async (_slug, input) => ({ ...configured, ...input, draft_revision: 2 })); open(); @@ -842,9 +979,15 @@ it('searches capabilities while retaining existing selections outside the result fireEvent.mouseDown(screen.getByRole('tab', { name: '高级设置' }), { button: 0, ctrlKey: false }); expect(screen.getByLabelText('搜索工具箱')).toHaveValue('资料'); expect(screen.queryByRole('checkbox', { name: '当前时间' })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('checkbox', { name: /资料搜索/ })); + fireEvent.click(screen.getByRole('checkbox', { name: /上网查资料/ })); fireEvent.click(screen.getByRole('button', { name: '完成选择' })); expect(screen.getByRole('checkbox', { name: /missing-tool/ })).toBeChecked(); + fireEvent.click(screen.getByRole('button', { name: '选择做事手册', exact: true })); + fireEvent.change(screen.getByLabelText('搜索做事手册'), { target: { value: '出处' } }); + fireEvent.click(screen.getByRole('checkbox', { name: /查资料,做研究/ })); + fireEvent.click(screen.getByRole('checkbox', { name: '开始对话前,先读好已选做事手册的使用方法' })); fireEvent.click(screen.getByRole('button', { name: '保存草稿' })); - await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ configuration: expect.objectContaining({ tools: ['missing-tool', 'search'] }) }))); + await waitFor(() => expect(api.save).toHaveBeenCalledWith(draft.slug, expect.objectContaining({ + configuration: expect.objectContaining({ tools: ['missing-tool', 'web_search'], skills: ['deep-research'], preload_skills: ['deep-research'] }), + }))); }); diff --git a/tests/unit/cloud-agents-workflows.test.tsx b/tests/unit/cloud-agents-workflows.test.tsx index ff938e83..9c04b5a7 100644 --- a/tests/unit/cloud-agents-workflows.test.tsx +++ b/tests/unit/cloud-agents-workflows.test.tsx @@ -164,8 +164,8 @@ it('retries the same request after a lost response and renders batched native SS expect(submissions[0][1]).toEqual(submissions[1][1]); await act(async () => stream.dispatchEvent(new MessageEvent('messages', { data: JSON.stringify({ run_id: 'run-1', thread_id: 'thread-1', payload: { items: [ - { stream_event: { type: 'message_delta', content: '第一段' } }, - { stream_event: { type: 'message_delta', content: '文字' } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '第一段' } }, + { stream_event: { type: 'message_delta', message_id: 'answer', content: '文字' } }, ] } }), }))); expect(screen.getByText('第一段文字')).toBeVisible(); diff --git a/tests/unit/cloud-chat-reasoning.test.tsx b/tests/unit/cloud-chat-reasoning.test.tsx index 66421c66..97e84376 100644 --- a/tests/unit/cloud-chat-reasoning.test.tsx +++ b/tests/unit/cloud-chat-reasoning.test.tsx @@ -41,7 +41,7 @@ async function open(preview = false) { it('shows reasoning without an answer, preserves manual folding during deltas and folds final history once', async () => { await open(); await emit({ reasoning_content: '流式思考' }); - const details = screen.getByTestId('cloud-chat-reasoning'); + const details = screen.getByTestId('cloud-message-process'); expect(details).toHaveAttribute('open'); expect(screen.queryByRole('heading', { name: 'Hi,今天想聊些什么?' })).not.toBeInTheDocument(); act(() => { details.removeAttribute('open'); fireEvent(details, new Event('toggle')); }); await emit({ reasoning_content: '第二段', content: '流式回答' }, '2-0'); @@ -51,8 +51,8 @@ it('shows reasoning without an answer, preserves manual folding during deltas an history = { ...history, run: { ...run, status: 'completed' }, messages: [savedAssistant()] }; await emit({}, '2-0', 'end'); await screen.findByText('完整回答'); - expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getAllByTestId('cloud-message-process')).toHaveLength(1); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('完整思考'); expect(screen.queryByText('流式回答')).not.toBeInTheDocument(); }); @@ -65,7 +65,7 @@ it('keeps partial thought and answer when terminal history fails and excludes la await screen.findByText('历史暂时离线'); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已收思考'); expect(screen.getByText('已收片段')).toBeInTheDocument(); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); await emit({ reasoning_content: '另一线程' }, '2-0', 'messages', { thread_id: 'other-thread' }); expect(screen.getByLabelText('智能体思考过程')).not.toHaveTextContent('另一线程'); }); @@ -78,8 +78,8 @@ it('restores only assistant reasoning from completed history and shows no block ] }; render(); await screen.findByText('完整回答'); - expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getAllByTestId('cloud-message-process')).toHaveLength(1); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); expect(screen.queryByText('不属于用户显示的思考')).not.toBeInTheDocument(); expect(screen.queryByText('系统字段')).not.toBeInTheDocument(); expect(api.events).not.toHaveBeenCalled(); @@ -88,9 +88,9 @@ it('restores only assistant reasoning from completed history and shows no block it('clears live reasoning on a new conversation and ignores its obsolete stream', async () => { await open(true); await emit({ reasoning_content: '旧对话思考' }); fireEvent.click(screen.getByRole('button', { name: '新对话' })); - await waitFor(() => expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument()); await emit({ reasoning_content: '旧事件迟到' }, '2-0'); - expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); expect(api.call.mock.calls.some(call => ['submit', 'preview'].includes(call[0]))).toBe(false); }); @@ -103,7 +103,7 @@ it('hydrates a final message that is saved after its terminal Run stops the runn history = { ...history, messages: [savedAssistant('迟到保存的思考')] }; await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(screen.getByText('完整回答')).toBeInTheDocument(); - expect(screen.getAllByTestId('cloud-chat-reasoning')).toHaveLength(1); + expect(screen.getAllByTestId('cloud-message-process')).toHaveLength(1); expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('迟到保存的思考'); expect(screen.queryByText('还没落盘的回答')).not.toBeInTheDocument(); expect(api.call.mock.calls.some(call => ['submit', 'preview'].includes(call[0]))).toBe(false); @@ -157,8 +157,8 @@ it('ignores a pending history read when approval resumes the same request in a n run_id: 'run-2', thread_id: 'thread-1', request_id: 'request-1', payload: { chunk: { stream_event: { type: 'message_delta', message_id: 'message-2', reasoning_content: '操作后的思考' } } }, }) }))); await act(async () => { resolveOld({ ...history, run: { ...run, status: 'interrupted' } }); }); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作前的思考'); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作后的思考'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作前的思考'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作后的思考'); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '停止回答', exact: true })).toBeInTheDocument(); }); @@ -176,7 +176,7 @@ it('reopens a resumed same-Run stream and ignores its obsolete interrupted histo return base(operation, input); }); await emit({}, '2-0', 'end'); - expect(screen.getByTestId('cloud-chat-reasoning')).not.toHaveAttribute('open'); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); const resumedStream = new Stream(); api.events.mockResolvedValue(resumedStream); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '允许执行', exact: true })); }); await waitFor(() => expect(api.events).toHaveBeenLastCalledWith('run-1', '2-0')); @@ -186,13 +186,13 @@ it('reopens a resumed same-Run stream and ignores its obsolete interrupted histo type: 'message_delta', message_id: 'message-2', reasoning_content: '恢复后的思考', content: '恢复后的回答', } } }, }) }))); - expect(screen.getByTestId('cloud-chat-reasoning')).toHaveAttribute('open'); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('操作前的思考'); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('恢复后的思考'); + expect(screen.getAllByTestId('cloud-message-process').every(item => !item.hasAttribute('open'))).toBe(true); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('操作前的思考'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('恢复后的思考'); expect(screen.getByText(/恢复后的回答/)).toBeInTheDocument(); await act(async () => { resolveOld({ ...history, run: { ...run, status: 'interrupted' }, messages: [savedAssistant()] }); }); - expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('恢复后的思考'); - expect(screen.getByTestId('cloud-chat-reasoning')).toHaveAttribute('open'); + expect(screen.getAllByLabelText('智能体思考过程').map(item => item.textContent).join('')).toContain('恢复后的思考'); + expect(screen.getAllByTestId('cloud-message-process').every(item => !item.hasAttribute('open'))).toBe(true); expect(screen.getByRole('button', { name: '停止回答', exact: true })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); expect(screen.queryByText('完整回答')).not.toBeInTheDocument(); @@ -215,12 +215,12 @@ it('ignores a late approval receipt after the user discards input and starts a n await waitFor(() => expect(api.call.mock.calls.some(([operation]) => operation === 'resume')).toBe(true)); fireEvent.click(screen.getByRole('button', { name: '新对话', exact: true })); fireEvent.click(await screen.findByRole('button', { name: '放弃内容', exact: true })); - expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: '问它一个小问题' })).toBeInTheDocument(); await act(async () => { resolveResume({ run_id: 'resumed-old-run', status: 'running' }); }); expect(screen.queryByRole('button', { name: '停止回答', exact: true })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '允许执行', exact: true })).not.toBeInTheDocument(); - expect(screen.queryByTestId('cloud-chat-reasoning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); expect(screen.getByRole('heading', { name: '问它一个小问题' })).toBeInTheDocument(); expect(api.events).not.toHaveBeenCalledWith('resumed-old-run'); expect(api.events).toHaveBeenCalledTimes(1); diff --git a/tests/unit/cloud-chat-rendering.test.tsx b/tests/unit/cloud-chat-rendering.test.tsx new file mode 100644 index 00000000..68b1c60d --- /dev/null +++ b/tests/unit/cloud-chat-rendering.test.tsx @@ -0,0 +1,185 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CloudChat } from '@/pages/CloudAgents/CloudChat'; +import { reduceCloudStream, type CloudStream } from '@/pages/CloudAgents/cloud-chat-stream'; +import type { CloudHistory } from '../../shared/cloud-agents'; + +const api = vi.hoisted(() => ({ call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), download: vi.fn() })); +vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); +class Stream extends EventTarget { close = vi.fn(); onopen = null; onerror = null; } +let stream: Stream; +let history: CloudHistory; +const run = { agent_run_id: 'run', request_id: 'request', thread_id: 'thread', agent_slug: 'agent', status: 'running', output: '', version: '1' }; +const thread = { thread_id: 'thread', client_thread_id: 'client', agent_slug: 'agent', mode: 'published', status: 'running', title: '对话', updated_at: '', unread: false, run_id: 'run' }; +beforeEach(() => { + vi.resetAllMocks(); + stream = new Stream(); + history = { thread_id: 'thread', messages: [], run, next_offset: null }; + api.recovery.mockResolvedValue({ recent: null, pending: [] }); + api.remember.mockResolvedValue({}); + api.download.mockResolvedValue({ saved: true }); + api.events.mockResolvedValue(stream); + api.call.mockImplementation(async (operation: string) => { + if (operation === 'threads') return { threads: [thread], next_offset: null }; + if (operation === 'history') return history; + if (operation === 'run') return history.run; + if (operation === 'files') return { files: [] }; + if (operation === 'attachments') return { attachments: [] }; + if (operation === 'viewed') return {}; + throw new Error(operation); + }); +}); +afterEach(cleanup); +async function emit(items: unknown[], cursor: string, threadId = 'thread') { + await act(async () => stream.dispatchEvent(new MessageEvent('messages', { + lastEventId: cursor, data: JSON.stringify({ run_id: 'run', thread_id: threadId, payload: { items } }), + }))); +} +const delta = (message_id: string, fields: Record) => ({ stream_event: { type: 'message_delta', message_id, ...fields } }); + +it('renders separate thinking and Markdown messages, ignores replay/subthreads, and restores durable history on reopen', async () => { + const view = render(); + await waitFor(() => expect(api.events).toHaveBeenCalledWith('run')); + await emit([delta('first', { reasoning_content: '先查资料' })], '100-1'); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('先查资料'); + expect(screen.getByTestId('cloud-message-process')).toHaveAttribute('open'); + await emit([delta('first', { reasoning_content: '先查资料' })], '100-1'); + await emit([delta('child', { content: '不应出现在主回答', reasoning_content: '子线程思考' })], '100-2', 'child'); + await emit([delta('first', { content: '**核对完成**' }), delta('second', { additional_reasoning_content: '再整理结论' })], '100-3'); + expect(screen.getByText('核对完成').tagName).toBe('STRONG'); + expect(screen.queryByText('不应出现在主回答')).not.toBeInTheDocument(); + expect(screen.getAllByLabelText('智能体思考过程')[0]).toHaveTextContent(/^先查资料$/); + expect(screen.getAllByTestId('cloud-message-process')[0]).not.toHaveAttribute('open'); + await emit([delta('second', { content: '| 项目 | 结果 |\n| --- | --- |\n| 校验 | 通过 |\n\n[报告](sandbox:/report.md)' })], '100-4'); + expect(screen.getByRole('table')).toHaveTextContent('校验通过'); + fireEvent.click(screen.getByRole('button', { name: '报告' })); + expect(api.download).toHaveBeenCalledWith('thread', '/report.md'); + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, role: 'assistant', message_id: 'first', run_id: 'run', request_id: 'request', created_at: '', content: '**核对完成**', reasoning_content: '先查资料' }, + { id: 2, role: 'assistant', message_id: 'second', run_id: 'run', request_id: 'request', created_at: '', content: '最终结论', reasoning_content: '再整理结论' }, + ] }; + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'completed' } }) }))); + await screen.findByText('最终结论'); + expect(screen.getAllByText('核对完成')).toHaveLength(1); + expect(screen.getAllByLabelText('智能体思考过程')).toHaveLength(2); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + view.unmount(); + render(); + await screen.findByText('最终结论'); + expect(screen.getAllByLabelText('智能体思考过程')[1]).toHaveTextContent('再整理结论'); + expect(screen.getAllByTestId('cloud-message-process').every(element => !element.hasAttribute('open'))).toBe(true); +}); + +it('associates tool arguments and results by call identity, keeps failures visible and clears a switched thread', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([ + { stream_event: { type: 'tool_call_delta', message_id: 'm', tool_call_id: 'search', name: 'web_search', + display_name: '上网查资料', display_description: '在网上查找相关信息。', args_delta: '{"query":', index: 0 } }, + { stream_event: { type: 'tool_call_delta', message_id: 'm', args_delta: '"天气"}', index: 0 } }, + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'write', name: '写文件', args: { path: '/a' }, index: 1 } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'write', output: { content: '磁盘不足', status: 'error' } } } }, + { event: { method: 'tools', data: { event: 'tool-finished', tool_call_id: 'search', output: { content: '晴天', status: 'success' } } } }, + ], '101-0'); + const process = screen.getByTestId('cloud-message-process'); + expect(process).toHaveTextContent('上网查资料 · 已完成'); + expect(process).toHaveTextContent('在网上查找相关信息。'); + expect(process).toHaveTextContent('工具标识:web_search'); + expect(process).toHaveTextContent('写文件 · 失败'); + expect(within(process).getByText('{"query":"天气"}')).toBeInTheDocument(); + expect(within(process).getByText('晴天')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '新对话' })); + await waitFor(() => expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument()); + await emit([delta('m', { reasoning_content: '旧连接的迟到内容' })], '102-0'); + expect(screen.queryByText('旧连接的迟到内容')).not.toBeInTheDocument(); +}); + +it('retains partial thinking and answer if final history cannot be read', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('m', { reasoning_content: '已完成分析', content: '已生成部分结果' })], '1-0'); + const implementation = api.call.getMockImplementation()!; + api.call.mockImplementation(async (operation, ...args) => { + if (operation === 'history') throw new Error('历史暂不可用'); + return implementation(operation, ...args); + }); + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'failed' } }) }))); + await screen.findByText('历史暂不可用'); + expect(screen.getByText('已生成部分结果')).toBeVisible(); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('已完成分析'); +}); + +it('replaces only persisted messages when terminal history is still incomplete', async () => { + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('first', { content: '第一条实时消息' }), delta('second', { content: '第二条尚未保存', reasoning_content: '第二条思考' })], '1-0'); + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, message_id: 'first', role: 'assistant', run_id: 'run', request_id: 'request', created_at: '', content: '第一条持久消息' }, + ] }; + await act(async () => stream.dispatchEvent(new MessageEvent('end', { + lastEventId: '1-0', data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'completed' } }), + }))); + await screen.findByText('第一条持久消息'); + expect(screen.queryByText('第一条实时消息')).not.toBeInTheDocument(); + expect(screen.getByText('第二条尚未保存')).toBeVisible(); + expect(screen.getByLabelText('智能体思考过程')).toHaveTextContent('第二条思考'); + expect(screen.getByTestId('cloud-message-process')).not.toHaveAttribute('open'); +}); + +it('leaves ordinary answers without an empty thinking panel', async () => { + history = { ...history, run: { ...run, status: 'completed' }, messages: [ + { id: 1, role: 'assistant', content: '普通回答', request_id: 'request', run_id: 'run', created_at: '' }, + ] }; + render(); + await screen.findByText('普通回答'); + expect(screen.queryByTestId('cloud-message-process')).not.toBeInTheDocument(); +}); + +it('keeps the current full stream while a stale partial history is waiting for terminal refresh', async () => { + history = { ...history, messages: [{ id: 1, message_id: 'm', role: 'assistant', content: '旧片段', + request_id: 'request', run_id: 'run', created_at: '' }] }; + render(); + await waitFor(() => expect(api.events).toHaveBeenCalled()); + await emit([delta('m', { content: '完整的新答案', reasoning_content: '已完成的思考' })], '200-1'); + expect(screen.queryByText('旧片段')).not.toBeInTheDocument(); + const implementation = api.call.getMockImplementation()!; + let finish!: (value: CloudHistory) => void; + api.call.mockImplementation((operation, ...args) => operation === 'history' + ? new Promise(resolve => { finish = resolve; }) : implementation(operation, ...args)); + await act(async () => stream.dispatchEvent(new MessageEvent('end', { data: JSON.stringify({ run_id: 'run', thread_id: 'thread', payload: { status: 'completed' } }) }))); + expect(screen.getByText('完整的新答案')).toBeVisible(); + expect(screen.queryByText('旧片段')).not.toBeInTheDocument(); + await act(async () => finish({ ...history, run: { ...run, status: 'completed' }, messages: [{ + ...history.messages[0], content: '完整的新答案', reasoning_content: '已完成的思考', + }] })); + expect(screen.getAllByText('完整的新答案')).toHaveLength(1); +}); + +it('uses effective execution input once each tool starts, including empty arguments', () => { + let state: CloudStream = { runId: 'run', cursor: '', messages: [] }; + state = reduceCloudStream(state, { payload: { items: [ + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', name: '查资料', args: { query: '模型参数' }, index: 0 } }, + { stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'other', name: '其他工具', args: { unused: true }, index: 1 } }, + ] } }, 'thread', '1-0'); + state = reduceCloudStream(state, { payload: { items: [ + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'search', input: { query: '实际执行参数' } } } }, + { event: { method: 'tools', data: { event: 'tool-started', tool_call_id: 'other', input: {} } } }, + ] } }, 'thread', '2-0'); + expect(state.messages[0].tool_calls).toMatchObject([ + { id: 'search', args: { query: '实际执行参数' }, status: 'running' }, + { id: 'other', args: {}, status: 'running' }, + ]); + expect(state.messages[0].tool_calls[1].args).toEqual({}); + state = reduceCloudStream(state, { payload: { chunk: { + stream_event: { type: 'tool_call', message_id: 'm', tool_call_id: 'search', args: { query: '模型参数' }, index: 0 }, + } } }, 'thread', '3-0'); + expect(state.messages[0].tool_calls[0].args).toEqual({ query: '实际执行参数' }); +}); + +it('keeps Redis cursor precision while ignoring other runs', () => { + let state: CloudStream = { runId: 'run', cursor: '', messages: [] }; + const event = { run_id: 'run', thread_id: 'thread', payload: { chunk: delta('m', { content: 'A' }) } }; + state = reduceCloudStream(state, event, 'thread', '9007199254740993-0'); + expect(reduceCloudStream(state, event, 'thread', '9007199254740992-1')).toBe(state); + expect(reduceCloudStream(state, { ...event, run_id: 'other' }, 'thread', '9007199254740994-0')).toBe(state); +}); diff --git a/tests/unit/cloud-chat-stream.test.ts b/tests/unit/cloud-chat-stream.test.ts index e482aa31..afc6f3bc 100644 --- a/tests/unit/cloud-chat-stream.test.ts +++ b/tests/unit/cloud-chat-stream.test.ts @@ -10,7 +10,7 @@ describe('personal Agent main-thread stream projection', () => { it('separates reasoning and answers in batched and single chunks with legacy fallback', () => { let state = consume(null, envelope([delta({ reasoning_content: '思考一', additional_reasoning_content: '不应重复', content: '回答一' })])); state = consume(state, { run_id: 'run-1', thread_id: 'thread-1', payload: { chunk: delta({ additional_reasoning_content: '思考二', content: '回答二' }) } }, '2-0'); - expect(state).toMatchObject({ reasoning: '思考一思考二', content: '回答一回答二', cursor: '2-0' }); + expect(state).toMatchObject({ messages: [{ reasoning_content: '思考一思考二', content: '回答一回答二' }], cursor: '2-0' }); expect(JSON.stringify(state)).not.toContain('不应重复'); }); @@ -20,7 +20,7 @@ describe('personal Agent main-thread stream projection', () => { { stream_event: { type: 'tool_call', name: 'search', arguments: 'private tool args' } }, delta({ message_id: 'message-2', reasoning_content: '核对资料', content: '第二段' }), ])); - expect(state).toMatchObject({ reasoning: '先看资料\n\n核对资料', content: '第一段\n\n第二段', toolName: 'search' }); + expect(state).toMatchObject({ messages: [{ reasoning_content: '先看资料', content: '第一段', tool_calls: [{ name: 'search' }] }, { reasoning_content: '核对资料', content: '第二段' }] }); expect(JSON.stringify(state)).not.toContain('private tool args'); }); @@ -30,7 +30,7 @@ describe('personal Agent main-thread stream projection', () => { expect(consume(state, envelope([delta({ reasoning_content: '另一个请求' })], { request_id: 'request-2' }), '2-0')).toBe(state); const child = consume(state, envelope([delta({ reasoning_content: '子线程' })], { thread_id: 'child-thread' }), '2-0'); const missing = consume(child, envelope([delta({ content: '缺身份' })], { thread_id: undefined }), '3-0'); - expect(missing).toMatchObject({ reasoning: '父线程', content: '', cursor: '3-0', ended: false }); + expect(missing).toMatchObject({ messages: [{ reasoning_content: '父线程', content: '' }], cursor: '3-0', ended: false }); expect(consume(missing, envelope([], { thread_id: 'child-thread' }), '4-0', 'end')).toMatchObject({ ended: false }); }); @@ -39,16 +39,16 @@ describe('personal Agent main-thread stream projection', () => { expect(consume(state, envelope([delta({ reasoning_content: '重放' })]), '99999999999999999999-1')).toBe(state); expect(consume(state, envelope([delta({ reasoning_content: '迟到' })]), '99999999999999999998-99')).toBe(state); const next = consume(state, envelope([delta({ reasoning_content: '二次' })]), '99999999999999999999-2'); - expect(consume(next, envelope([]), '99999999999999999999-2', 'end')).toMatchObject({ reasoning: '一次二次', ended: true }); + expect(consume(next, envelope([]), '99999999999999999999-2', 'end')).toMatchObject({ messages: [{ reasoning_content: '一次二次' }], ended: true }); }); it('keeps missing-ID compatibility and retains same-request reasoning over a resumed Run only', () => { const first = consume(null, envelope([delta({ reasoning_content: '之前', content: '旧预览' })]), ''); - const next = consumeCloudChatEvent(first, envelope([delta({ message_id: 'message-2', reasoning_content: '继续', content: '新预览' })], { run_id: 'run-2' }), + const next = consumeCloudChatEvent(first, envelope([delta({ reasoning_content: '继续', content: '新预览' })], { run_id: 'run-2' }), 'messages', '1-0', 'run-2', 'thread-1', 'request-1'); - expect(next).toMatchObject({ cursor: '1-0', reasoning: '之前\n\n继续', content: '新预览', ended: false }); + expect(next).toMatchObject({ cursor: '1-0', messages: [{ run_id: 'run-1', reasoning_content: '之前', content: '旧预览' }, { run_id: 'run-2', reasoning_content: '继续', content: '新预览' }], ended: false }); const other = consumeCloudChatEvent(next, envelope([delta({ reasoning_content: '新问题' })], { run_id: 'run-3', request_id: 'request-3' }), 'messages', '1-0', 'run-3', 'thread-1', 'request-3'); - expect(other).toMatchObject({ reasoning: '新问题', content: '' }); + expect(other).toMatchObject({ messages: [{ reasoning_content: '新问题', content: '' }] }); }); }); diff --git a/tests/unit/cloud-knowledge-import.test.tsx b/tests/unit/cloud-knowledge-import.test.tsx new file mode 100644 index 00000000..8b93b62a --- /dev/null +++ b/tests/unit/cloud-knowledge-import.test.tsx @@ -0,0 +1,345 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CloudKnowledgeAttachmentPicker } from '@/pages/CloudAgents/CloudKnowledgeAttachmentPicker'; +import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge'; +import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../shared/cloud-agents'; + +const api = vi.hoisted(() => ({ call: vi.fn(), list: vi.fn() })); +vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); +const slug = 'ml-' + 'a'.repeat(32), otherSlug = 'ml-' + 'b'.repeat(32); +const threads: CloudThread[] = [ + { thread_id: 'spring', title: '春天的观察讨论', agent_slug: slug, client_thread_id: null, updated_at: '2026-10-13T00:00:00Z', mode: 'published', run_id: null, status: 'completed', unread: false }, + { thread_id: 'reading', title: '图书阅读记录', agent_slug: otherSlug, client_thread_id: null, updated_at: '2026-10-12T00:00:00Z', mode: 'preview', run_id: null, status: 'completed', unread: false }, +]; +function attachment(id: string, name: string, size = 1536): CloudAttachment { + return { file_id: id, file_name: name, file_size: size, path: '/attachments/' + name, original_path: '/attachments/' + name }; +} +function knowledgeFile(id: string): CloudKnowledgeFile { + return { file_id: 'knowledge-' + id, name: id + '.txt', size: 1536, status: 'uploaded', available: false, chunk_count: 0, error: null }; +} +let sourceFiles: Record; +const imported = vi.fn<(file: CloudKnowledgeFile) => Promise>(); +const pending = vi.fn(); +const close = vi.fn(); +const props = { slug, kbId: 'kb_own', kbName: '我的观察记录', busy: false, act: async (task: () => Promise) => task(), onImported: imported, onPendingChange: pending, onClose: close }; + +beforeEach(() => { + vi.resetAllMocks(); imported.mockResolvedValue(); + sourceFiles = { spring: [attachment('a', '观察报告.md'), attachment('b', '植物资料.pdf')], reading: [attachment('a', '阅读笔记.txt')] }; + api.list.mockResolvedValue({ agents: [{ slug, name: '资料小助手' }], next_cursor: null }); + api.call.mockImplementation(async (operation: string, input: { thread_id?: string; attachment_id?: string }) => { + if (operation === 'threads') return { threads, next_offset: null }; + if (operation === 'received') return { agents: [{ slug: otherSlug, name: '阅读搭档' }] }; + if (operation === 'attachments') return { attachments: sourceFiles[input.thread_id!] }; + if (operation === 'importKnowledgeAttachment') return knowledgeFile(input.attachment_id!); + throw new Error('Unexpected operation ' + operation); + }); +}); +afterEach(cleanup); +async function chooseChat(title = threads[0].title) { + fireEvent.click(await screen.findByRole('button', { name: '选择对话 ' + title })); +} +async function chooseFile(name: string) { fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 ' + name })); } +function imports() { return api.call.mock.calls.filter(([operation]) => operation === 'importKnowledgeAttachment').map(([, input]) => input); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +it('searches named and dated chats, keeps selections across chats, and imports only explicitly selected attachments', async () => { + const original = api.call.getMockImplementation()!; + api.call.mockImplementation((operation, input) => operation === 'threads' + ? Promise.resolve(input.offset ? { threads: [threads[1]], next_offset: null } : { threads: [threads[0]], next_offset: 50 }) : original(operation, input)); + render(); + expect(screen.getByRole('dialog', { name: '从聊天选择文件' })).toHaveTextContent('导入到「我的观察记录」'); + await screen.findByText(/阅读搭档 · 2026/); + fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '资料小助手' } }); + expect(screen.queryByRole('button', { name: '选择对话 图书阅读记录' })).not.toBeInTheDocument(); + await chooseChat(); + expect(await screen.findByText('MD · 2 KB')).toBeVisible(); + fireEvent.change(screen.getByRole('textbox', { name: '搜索文件' }), { target: { value: '观察' } }); + expect(screen.queryByRole('checkbox', { name: '选择文件 植物资料.pdf' })).not.toBeInTheDocument(); + await chooseFile('观察报告.md'); + fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '阅读' } }); + await chooseChat(threads[1].title); await chooseFile('阅读笔记.txt'); + expect(within(screen.getByRole('list', { name: '已选聊天文件' })).getAllByRole('listitem')).toHaveLength(2); + expect(imports()).toHaveLength(0); + fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); + await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); + expect(imports()).toEqual([ + expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'spring', attachment_id: 'a' }), + expect.objectContaining({ slug, kb_id: 'kb_own', thread_id: 'reading', attachment_id: 'a' }), + ]); + expect(new Set(imports().map(input => input.operation_id)).size).toBe(2); + expect(imported).toHaveBeenCalledTimes(2); + expect(api.call.mock.calls.some(([operation]) => ['history', 'files', 'deleteAttachment'].includes(operation))).toBe(false); + expect(pending).toHaveBeenLastCalledWith(false); +}); + +it('retries only failed files with their original identity, retaining successful files and processing callbacks', async () => { + const original = api.call.getMockImplementation()!; + let failure = true; + api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b' && failure + ? Promise.reject(new Error('连接超时')) : original(operation, input)); + render(); + await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); + fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); + await screen.findByText(/导入结果尚未确认:连接超时/); + expect(close).not.toHaveBeenCalled(); expect(imported).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '选择对话 图书阅读记录' })).toBeDisabled(); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(close).not.toHaveBeenCalled(); + const first = imports(); + failure = false; + fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' })); + await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); + expect(imports()).toHaveLength(3); expect(imports()[2]).toEqual(first[1]); + expect(imported).toHaveBeenCalledTimes(2); +}); + +it('never reimports a confirmed file after a processing callback failure and permits a later independent batch', async () => { + imported.mockRejectedValueOnce(new Error('处理失败')); + render(); + await chooseChat(); await chooseFile('观察报告.md'); + fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); + await screen.findByText(/文件已导入,处理未全部启动/); + expect(imports()).toHaveLength(1); expect(close).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled(); + await chooseFile('植物资料.pdf'); + fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); + await waitFor(() => expect(close).toHaveBeenCalledTimes(1)); + expect(imports().map(input => input.attachment_id)).toEqual(['a', 'b']); +}); + +it('shows file types and sizes, disables unsupported legacy formats and oversize files, and accepts supported files at the size boundary', async () => { + sourceFiles.spring = [attachment('legacy-doc', '旧文档.doc'), attachment('legacy-xls', '旧表格.xls'), attachment('markdown', '资料.markdown'), + attachment('image', '图片.png'), attachment('large', '过大的资料.pdf', 5 * 1024 * 1024 + 1), attachment('boundary', '可导入.DOCX', 5 * 1024 * 1024)]; + render(); await chooseChat(); + for (const name of ['旧文档.doc', '旧表格.xls', '资料.markdown', '图片.png', '过大的资料.pdf']) expect(await screen.findByRole('checkbox', { name: '选择文件 ' + name })).toBeDisabled(); + expect(screen.getAllByText('此格式暂不支持知识库导入')).toHaveLength(4); + expect(screen.getByText('超过 5 MB,暂不能导入')).toBeVisible(); + expect(screen.getByText('DOCX · 5.0 MB')).toBeVisible(); + await chooseFile('可导入.DOCX'); + fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(imports()).toEqual([expect.objectContaining({ attachment_id: 'boundary' })]); +}); + +it('distinguishes loading, empty chats, chat read failures and a successful retry', async () => { + const loading = deferred<{ threads: CloudThread[]; next_offset: null }>(); + const original = api.call.getMockImplementation()!; + let mode = 'loading'; + api.call.mockImplementation((operation, input) => { + if (operation !== 'threads') return original(operation, input); + return mode === 'loading' ? loading.promise : mode === 'error' ? Promise.reject(new Error('目录离线')) : Promise.resolve({ threads, next_offset: null }); + }); + const view = render(); + expect(screen.getByText('正在读取对话…')).toBeVisible(); + expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument(); + await act(async () => loading.resolve({ threads: [], next_offset: null })); + expect(screen.getByText('还没有个人智能体对话。')).toBeVisible(); + view.unmount(); mode = 'error'; render(); + await screen.findByText('对话读取失败:目录离线'); + expect(screen.queryByText('还没有个人智能体对话。')).not.toBeInTheDocument(); + mode = 'ready'; fireEvent.click(screen.getByRole('button', { name: '重试读取对话' })); + await screen.findByRole('button', { name: '选择对话 春天的观察讨论' }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +it('distinguishes file loading, failures, empty files and search results while ignoring a late response from a previous chat', async () => { + const late = deferred<{ attachments: CloudAttachment[] }>(); + const original = api.call.getMockImplementation()!; + let readingFails = true; + api.call.mockImplementation((operation, input) => operation === 'attachments' + ? input.thread_id === 'spring' ? late.promise : readingFails ? Promise.reject(new Error('文件离线')) : Promise.resolve({ attachments: [] }) + : original(operation, input)); + render(); await chooseChat(); + expect(screen.getByText('正在读取文件…')).toBeVisible(); + expect(screen.queryByText('这段对话还没有上传文件。')).not.toBeInTheDocument(); + await chooseChat(threads[1].title); await screen.findByText('文件读取失败:文件离线'); + await act(async () => late.resolve({ attachments: sourceFiles.spring })); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + readingFails = false; fireEvent.click(screen.getByRole('button', { name: '重试读取文件' })); + await screen.findByText('这段对话还没有上传文件。'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + fireEvent.change(screen.getByRole('textbox', { name: '搜索对话' }), { target: { value: '不存在' } }); + expect(screen.getByText('没有匹配的对话。')).toBeVisible(); +}); + +it('guards X, escape and selection during an in-flight import, then closes only after all files finish', async () => { + const first = deferred(); + const original = api.call.getMockImplementation()!; + api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'a' ? first.promise : original(operation, input)); + render(); await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); + fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); + expect(imports()).toHaveLength(1); + expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); + expect(screen.getByRole('checkbox', { name: '选择文件 观察报告.md' })).toBeDisabled(); + expect(screen.queryByRole('button', { name: '放弃重试', exact: true })).not.toBeInTheDocument(); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + expect(close).not.toHaveBeenCalled(); + await act(async () => first.resolve(knowledgeFile('a'))); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(imports()).toHaveLength(2); expect(imported).toHaveBeenCalledTimes(2); +}); + +it('does not read or import without management permission and resumes a confirmed receipt without importing it twice', async () => { + const receipt = deferred(); + const original = api.call.getMockImplementation()!; + api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? receipt.promise : original(operation, input)); + const view = render(); + expect(api.call).not.toHaveBeenCalled(); expect(api.list).not.toHaveBeenCalled(); + view.rerender(); await chooseChat(); await chooseFile('观察报告.md'); + fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); + view.rerender(); + await act(async () => receipt.resolve(knowledgeFile('a'))); + expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(pending).toHaveBeenLastCalledWith(true); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '继续导入 1 个文件' })); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(imports()).toHaveLength(1); expect(imported).toHaveBeenCalledOnce(); +}); + +it('ignores an old-target response and stops the old batch after a knowledge-base scope change', async () => { + const response = deferred(); + const original = api.call.getMockImplementation()!; + api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input)); + const view = render(); + await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); + fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); + view.rerender(); + await act(async () => response.resolve(knowledgeFile('a'))); + expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog')).toHaveTextContent('导入到「其他资料」'); + expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument(); +}); + +it('does not continue a batch or notify the old owner after unmount', async () => { + const response = deferred(); + const original = api.call.getMockImplementation()!; + api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' ? response.promise : original(operation, input)); + const view = render(); + await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); + fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); view.unmount(); + await act(async () => response.resolve(knowledgeFile('a'))); + expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); + expect(pending).toHaveBeenLastCalledWith(false); +}); + +it('hides the portal after a confirmed loss of ownership so the parent can discard pending requests without importing again', async () => { + const response = deferred(); + const original = api.call.getMockImplementation()!; + let owned = true; + api.call.mockImplementation((operation, input) => { + if (operation === 'knowledge') return Promise.resolve({ databases: owned ? [{ kb_id: 'kb_own', name: '我的观察记录' }] : [], models: [] }); + if (operation === 'knowledgeFiles') return Promise.resolve({ files: [], next_offset: null }); + if (operation === 'importKnowledgeAttachment') return response.promise; + return original(operation, input); + }); + const created = vi.fn(); + const view = render(); + await screen.findByRole('option', { name: '我的观察记录' }); + fireEvent.change(screen.getByRole('combobox', { name: '管理的知识库' }), { target: { value: 'kb_own' } }); + fireEvent.click(await screen.findByRole('button', { name: '从聊天选择文件' })); + await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); + fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); + owned = false; + view.rerender(); + await waitFor(() => expect(screen.queryByRole('dialog', { name: '从聊天选择文件' })).not.toBeInTheDocument()); + await act(async () => response.resolve(knowledgeFile('a'))); + const discard = await screen.findByRole('button', { name: '放弃未确认操作并重新选择' }); + await waitFor(() => expect(discard).toBeEnabled()); + expect(imports()).toHaveLength(1); + expect(api.call.mock.calls.some(([operation]) => operation === 'processKnowledge')).toBe(false); + fireEvent.click(discard); + expect(screen.queryByRole('button', { name: '放弃未确认操作并重新选择' })).not.toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: '管理的知识库' })).toHaveValue(''); +}); + +it('allows cancelling unsubmitted selections and does not mutate knowledge merely by choosing files', async () => { + render(); await chooseChat(); await chooseFile('观察报告.md'); + expect(pending).toHaveBeenLastCalledWith(true); + fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' })); + expect(close).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(0); expect(imported).not.toHaveBeenCalled(); +}); + +it.each(['button', 'escape'])('allows %s dismissal during a catalog failure before any import has been sent', async control => { + const view = render(); + await chooseChat(); await chooseFile('观察报告.md'); + view.rerender(); + expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeEnabled(); + if (control === 'button') fireEvent.click(screen.getByRole('button', { name: '关闭文件选择' })); + else fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(imports()).toHaveLength(0); +}); + +it('offers a read-only catalog refresh while an uncertain import is blocked, then retries only the original operation', async () => { + const original = api.call.getMockImplementation()!; + let fails = true; + api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && fails + ? Promise.reject(new Error('结果未确认')) : original(operation, input)); + const refresh = vi.fn(); + const view = render(); + await chooseChat(); await chooseFile('观察报告.md'); + fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); + await screen.findByText(/导入结果尚未确认:结果未确认/); + const request = imports()[0]; + view.rerender(); + expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true })); + expect(refresh).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(1); + fails = false; + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '重试失败的 1 个文件' })); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(imports()).toEqual([request, request]); expect(imported).toHaveBeenCalledOnce(); +}); + +it('requires explicit confirmation to abandon a retry during a catalog failure, while keeping the request through cancelling that confirmation', async () => { + const original = api.call.getMockImplementation()!; + api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' + ? Promise.reject(new Error('附件已无法读取')) : original(operation, input)); + const view = render(); + await chooseChat(); await chooseFile('观察报告.md'); + fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); + await screen.findByText(/导入结果尚未确认:附件已无法读取/); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); + expect(screen.getByText('这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。')).toBeVisible(); + expect(close).not.toHaveBeenCalled(); expect(pending).toHaveBeenLastCalledWith(true); + fireEvent.click(screen.getByRole('button', { name: '继续保留' })); + expect(screen.getByRole('button', { name: '重试失败的 1 个文件' })).toBeDisabled(); + expect(screen.getByRole('list', { name: '文件导入结果' })).toHaveTextContent('观察报告.md'); + fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' })); + expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false); + expect(imports()).toHaveLength(1); expect(imported).not.toHaveBeenCalled(); + expect(screen.queryByRole('list', { name: '文件导入结果' })).not.toBeInTheDocument(); + expect(screen.queryByRole('list', { name: '已选聊天文件' })).not.toBeInTheDocument(); +}); + +it('exits a partly successful batch with a permanent source error without deleting or replaying successful imports', async () => { + const original = api.call.getMockImplementation()!; + api.call.mockImplementation((operation, input) => operation === 'importKnowledgeAttachment' && input.attachment_id === 'b' + ? Promise.reject(new Error('原附件已经删除')) : original(operation, input)); + render(); + await chooseChat(); await chooseFile('观察报告.md'); await chooseFile('植物资料.pdf'); + fireEvent.click(screen.getByRole('button', { name: '导入 2 个文件' })); + await screen.findByText(/导入结果尚未确认:原附件已经删除/); + expect(imported).toHaveBeenCalledOnce(); + expect(screen.getByRole('button', { name: '关闭文件选择' })).toBeDisabled(); + expect(screen.queryByText(/可继续切换对话选择/)).not.toBeInTheDocument(); + const before = api.call.mock.calls.length; + fireEvent.click(screen.getByRole('button', { name: '放弃重试', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '确认放弃重试' })); + expect(close).toHaveBeenCalledOnce(); expect(pending).toHaveBeenLastCalledWith(false); + expect(imported).toHaveBeenCalledOnce(); expect(imports()).toHaveLength(2); + expect(api.call).toHaveBeenCalledTimes(before); +}); diff --git a/tests/unit/cloud-knowledge-picker.test.tsx b/tests/unit/cloud-knowledge-picker.test.tsx index 66b880a7..e83b2689 100644 --- a/tests/unit/cloud-knowledge-picker.test.tsx +++ b/tests/unit/cloud-knowledge-picker.test.tsx @@ -63,7 +63,7 @@ it('never offers shared libraries to document management or mounts their mutatin await screen.findByText('MakeLore知识 · 只读'); openUpload(); const manager = screen.getByRole('combobox', { name: '管理的知识库' }); expect(within(manager).queryByRole('option', { name: '创作指南' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '上传文档', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '删除知识库', exact: true })).not.toBeInTheDocument(); expect(api.call.mock.calls.every(([operation]) => operation !== 'knowledgeFiles')).toBe(true); }); @@ -78,7 +78,7 @@ it('allows readable binding after an owned-list failure while withholding docume expect(screen.queryByRole('group', { name: 'MakeLore知识' })).not.toBeInTheDocument(); choose('shared'); openUpload(); expect(screen.getByRole('combobox', { name: '管理的知识库' })).toBeDisabled(); - expect(screen.queryByRole('button', { name: '上传文档', exact: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '从电脑上传', exact: true })).not.toBeInTheDocument(); failed = false; fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true })); await screen.findByText('MakeLore知识 · 只读'); @@ -116,9 +116,9 @@ it('selects an existing owned library for uploads and cancelling a file picker d await openOwn(); expect(changes).toHaveBeenLastCalledWith(expect.objectContaining({ knowledges: ['shared', own.kb_id] })); api.uploadKnowledge.mockResolvedValue(null); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); await waitFor(() => expect(api.uploadKnowledge).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(screen.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled()); + await waitFor(() => expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled()); expect(api.call.mock.calls.every(([operation]) => operation !== 'processKnowledge')).toBe(true); expect(screen.getByRole('button', { name: '移除知识库 创作指南' })).toBeVisible(); }); @@ -180,7 +180,7 @@ it('keeps an uncertain upload scoped to its library through retry cancellation a const pending: Record = {}; render( { pending[id] = value; }}>); await openOwn(); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); await screen.findByText('上传结果未确认'); expect(Object.values(pending).some(Boolean)).toBe(true); const manager = screen.getByRole('combobox', { name: '管理的知识库' }); @@ -224,7 +224,7 @@ it('preserves uncertain processing while refreshing and releases it when the ser it('retains unconfirmed operations in a disabled manager if its owned library disappears and requires explicit discard', async () => { api.uploadKnowledge.mockRejectedValue(new Error('上传结果未确认')); render(); await openOwn(); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); await screen.findByText('上传结果未确认'); const before = api.call.mock.calls.filter(([operation]) => operation === 'knowledgeFiles').length; databases = []; fireEvent.click(screen.getByRole('button', { name: '刷新知识库与模型' })); @@ -249,7 +249,7 @@ it('automatically processes a confirmed upload that finishes while both knowledg }); api.uploadKnowledge.mockImplementation(() => new Promise(value => { finishUpload = value; })); render(); await openOwn(); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); holdRefresh = true; fireEvent.click(screen.getByRole('button', { name: '刷新目录' })); await screen.findByRole('option', { name: '正在读取我的知识库…' }); await act(async () => finishUpload({ file_id: 'confirmed', name: '资料.txt', size: 10, status: 'uploaded', chunk_count: 0, available: false, error: null })); @@ -270,7 +270,7 @@ it('finishes an authorized upload through a failed ownership refresh and does no }); api.uploadKnowledge.mockImplementation(() => new Promise(value => { finishUpload = value; })); render(); await openOwn(); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); holdRefresh = true; fireEvent.click(screen.getByRole('button', { name: '刷新知识库', exact: true })); await screen.findByRole('option', { name: '正在读取我的知识库…' }); await act(async () => rejectRefresh(new Error('目录连接失败'))); @@ -316,6 +316,6 @@ it('keeps an owned but unusable library manageable without adding an invalid bin await screen.findByText('上传第一份文档,让智能体用你的资料回答。'); expect(screen.getByText('这个知识库当前暂不可选用。你仍可管理自己的文件,刷新目录后再试。')).toBeVisible(); expect(changes).toHaveBeenCalledTimes(before); - expect(screen.getByRole('button', { name: '上传文档', exact: true })).toBeEnabled(); + expect(screen.getByRole('button', { name: '从电脑上传', exact: true })).toBeEnabled(); expect(changes.mock.calls.at(-1)?.[0].knowledges).toEqual(['shared']); }); diff --git a/tests/unit/cloud-knowledge-status.test.tsx b/tests/unit/cloud-knowledge-status.test.tsx index e6814b60..2b029716 100644 --- a/tests/unit/cloud-knowledge-status.test.tsx +++ b/tests/unit/cloud-knowledge-status.test.tsx @@ -3,13 +3,15 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { CloudKnowledgePanel } from '@/pages/CloudAgents/CloudKnowledge'; import type { CloudKnowledgeFile } from '../../shared/cloud-agents'; -const api = vi.hoisted(() => ({ call: vi.fn(), uploadKnowledge: vi.fn() })); +const api = vi.hoisted(() => ({ call: vi.fn(), uploadKnowledge: vi.fn(), list: vi.fn() })); vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); const file: CloudKnowledgeFile = { file_id: 'file', name: '学习路线图.pdf', size: 3000, status: 'uploaded', chunk_count: 0, error: null, available: false }; let items: CloudKnowledgeFile[]; beforeEach(() => { vi.resetAllMocks(); items = []; + api.list.mockResolvedValue({ agents: [], next_cursor: null }); api.call.mockImplementation(async operation => { + if (operation === 'received') return { agents: [] }; if (operation === 'knowledge') return { databases: [{ kb_id: 'kb', name: '资料' }, { kb_id: 'other', name: '其他库' }], models: [] }; if (operation === 'knowledgeFiles') return { files: items, next_offset: null }; if (operation === 'processKnowledge') { @@ -33,7 +35,7 @@ it('uploads then processes automatically and refreshes through queue, parse, ind await screen.findByText('上传第一份文档,让智能体用你的资料回答。'); vi.useFakeTimers(); api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; }); - await act(async () => { fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); }); expect(screen.getByText('等待处理')).toBeVisible(); expect(screen.queryByText(/0 个分块/)).not.toBeInTheDocument(); expect(api.call).toHaveBeenCalledWith('processKnowledge', expect.objectContaining({ kb_id: 'kb', file_id: 'file' })); @@ -74,7 +76,7 @@ it('retains the saved upload and processing identity after an uncertain submissi }); api.uploadKnowledge.mockImplementation(async () => { items = [file]; return file; }); await open(); - fireEvent.click(screen.getByRole('button', { name: '上传文档', exact: true })); + fireEvent.click(screen.getByRole('button', { name: '从电脑上传', exact: true })); expect(await screen.findByRole('alert')).toHaveTextContent('文件已保存,处理请求尚未确认'); expect(screen.getByRole('article', { name: file.name })).toBeVisible(); fireEvent.click(screen.getByRole('button', { name: '重试处理' })); @@ -169,12 +171,10 @@ it('imports an attachment and processes it without claiming immediate readiness' return original(operation, input); }); await open(); - fireEvent.click(screen.getByRole('button', { name: '从对话附件导入' })); - await screen.findByRole('option', { name: '我的对话' }); - fireEvent.change(screen.getByLabelText('附件来源对话'), { target: { value: 'thread' } }); - await screen.findByRole('option', { name: '附件.pdf' }); - fireEvent.change(screen.getByLabelText('入库附件'), { target: { value: 'attachment' } }); - fireEvent.click(screen.getByRole('button', { name: '确认导入知识库' })); + fireEvent.click(screen.getByRole('button', { name: '从聊天选择文件' })); + fireEvent.click(await screen.findByRole('button', { name: '选择对话 我的对话' })); + fireEvent.click(await screen.findByRole('checkbox', { name: '选择文件 附件.pdf' })); + fireEvent.click(screen.getByRole('button', { name: '导入 1 个文件' })); await screen.findByText('等待处理'); expect(screen.queryByText('可用于回答')).not.toBeInTheDocument(); }); From 2b6ee37bdd15fa4d9dc4e0e09eac1b7136dd6563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 12:28:38 +0800 Subject: [PATCH 04/15] docs(trial): record verified complete application update --- .../20261013-combined-trial-update-c42d19a7.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md index 0c825756..4eb84fd6 100644 --- a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-combined-trial-update-c42d19a7 - Base commit: 8289f27f3e53ef455b656f1c43d2620d235d5014 - Owner: codex -- Status: Planning +- Status: Ready for Integration ## Scope @@ -44,8 +44,12 @@ ## Outcome -- Product assembly complete: retained base homepage/channel changes, applied bf39f40 product delta, knowledge c634425 and creation 21852fb without modifying their source task/canonical records. Runtime deployment pending. +- Product assembly and temporary-application update complete: retained base homepage/channel changes, applied bf39f40 product delta, knowledge c634425 and creation 21852fb without modifying their source task/canonical records. - 22 changed product files match exact accepted source commit blobs; README combines all current behaviors. The only test conflict combined the knowledge static harness with main's normalized cross-platform import paths. +- Built product commit: 7c51bb4a008e388071ec37156adeae80eff24167. Runtime is a durable snapshot under `/Users/chillishark/Makelore 麦洛/.trial-runtime/combined-update-20261013-c42d19a7`; current.json points to its Makelore Combined Trial.app, Main PID 9968 / Host API 58003. Both Main and Renderer were replaced; no claim of preserving the former Main process. +- Original profile, stable file URL and route were retained. Standard application session sync restored the same account, proven by authenticated read-only account lookup and anonymous digest comparison; no credential extraction/transfer. No visible login form or alert. Final external foreground PID remained 83608. +- First replacement failed strict window-size restoration and automatically rolled back. Audit retained in stage `attempt-1`. Final replacement accepts only exact bounds or a proven macOS work-area constraint: original requested 1470×923 is retained; actual 1470×842 equals the work area and reports maximized. Visibility/route/profile and logical focus continuity are separately verified. +- No primary checkout changes, main merge/push, backend deployment, real creation/import/chat or paid requests. Old snapshots and rollback evidence retained. ## Verification @@ -53,13 +57,17 @@ - Scoped ESLint 35 TypeScript files passed with 0 errors; 2 existing CloudChat warnings retained. - Combined UI suite: 50 passed, 1 historical Code navigation assertion failed. Targeted retry reproduces only the same mismatch (brand 46px vs inner tablist 32px). Independent review verified d787267 introduced the inner-tab layout on Sept 30 without updating this assertion; Code CSS/page/Sidebar/test blobs are identical to base. All updated Agent and homepage scenarios passed. Do not weaken the test or change unrelated product code in this task. - Source composition and `git diff --check` passed; task documentation ownership drift passed. -- Runtime snapshot, hidden probe and actual application switch remain pending. +- 199 snapshot artifact SHA values, native signature, updated bootstrap/helper SHA and independent-profile hidden probes passed. Actual Main/Renderer source, asset references, profile/URL/route, same-account login and focus continuity passed; normal app.quit needed no force termination. +- Root independently reran `python3 -B runtime.py verify` successfully; port 58003 listener is PID 9968. Protected local health returns the expected unauthenticated 401; normal authenticated account read returned 200. Inspector 9229 is closed. +- Durable runtime evidence: stage `build-manifest.json`, `verification-receipt.json`, `live-verification.json`, `probe-evidence.json`; `.trial-runtime/current.json` is the current pointer. Current metadata accurately records full restart and fresh UI/login proofs. ## Follow-ups - Historical Code navigation E2E compares brand against inner tablist height; review the assertion against the existing toolbar contract in a separate scoped task. - A client update does not deploy Yuxi history/capability/empty-introduction support; preserve these known backend limits. +- Combined source is on this feature branch; primary main remains untouched. Integrate the retained feature sources and canonical promotions separately when requested. This handoff's final documentation commit changes no built product bytes from 7c51bb4. ## Promotion Candidates -- None recorded. +- Current-state / product snapshot: approved Robot hiding, channel prerequisite hint, knowledge two-pane management/chat-file batch picker, and definitive-create rejection versus uncertain-result recovery. Evidence is accepted source commits, exact product-blob comparison, combined tests and verified runtime receipts. Future impact: next application build and source integration must preserve all five source groups; no conflicting product decision or additional human confirmation required within the authorized update scope. +- Runtime evidence index: current temporary application uses full Main+Renderer snapshot 7c51bb4 and standard same-account session restoration, independent of its source checkout; first-attempt rollback and OS-constrained bounds must remain distinguished from exact window restoration. Promote only during canonical integration; do not treat this as production backend/paid-model acceptance. From 5f7c851022bf3be36000c4729e04dbd52704a482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 12:47:49 +0800 Subject: [PATCH 05/15] fix(home): restore account wallet and settings access --- ...20261013-combined-trial-update-c42d19a7.md | 14 +- README.md | 2 +- src/components/account/HomeAccountMenu.tsx | 100 +++++++++++++ src/pages/ModuleSelection/index.tsx | 13 +- tests/e2e/main-navigation.spec.ts | 114 +++++++++++++++ tests/unit/home-account-menu.test.tsx | 134 ++++++++++++++++++ 6 files changed, 373 insertions(+), 4 deletions(-) create mode 100644 src/components/account/HomeAccountMenu.tsx create mode 100644 tests/unit/home-account-menu.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md index 4eb84fd6..4161d70c 100644 --- a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -8,12 +8,24 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-combined-trial-update-c42d19a7 - Base commit: 8289f27f3e53ef455b656f1c43d2620d235d5014 - Owner: codex -- Status: Ready for Integration +- Status: Planning ## Scope - Assemble and validate completed homepage and Agent changes, then update the existing local temporary application with both Main and Renderer while retaining its profile and login. - Keep the current temporary application as the target; no production backend deployment, repository push, primary checkout change or worktree cleanup. +- Follow-up: restore access to the existing account controls on the standalone module homepage and update the current trial. + +## Homepage Account Follow-up · 2026-10-13 + +- Concurrent / Planning Gates: Passed on same-task resume. Successful `start` and exact registry task/worktree/branch/feature/base match verified; reread active record, retained unchanged startup/domain/accepted decision context and 31-peer assessment. Fetch confirms origin/main still bf39f40; no new peer semantic dependency. Current verified runtime is product 7c51bb4, Main PID 9968 / port 58003. +- User reports the homepage lacks the pictured balance/recharge/profile/settings/logout controls. Source review confirms /module-select is outside MainLayout, and these controls only exist in Sidebar. Old be53e44, primary10da and latest main also lack a homepage menu; this assembly did not remove the Sidebar implementation. +- Plan: add a compact homepage account button/menu using existing UserAvatar, PointWallet and the page's existing profile hook/dialog; settings and logout use existing routes/auth action. Keep wallet mounted through menu closure and remount on account change. Preserve three-module layout, module permissions, drag region and mandatory-profile behavior. +- Verify real wallet fixtures and menu interactions in unit/Electron tests, plus small/desktop layouts; rebuild and update Renderer only if complete Main/preload/utility byte comparison permits, keeping the verified session/process and current focus. +- Do not open a real recharge order, modify profile, logout the real user or introduce another billing/auth authority during validation. +- Implementation verified: homepage account footer/menu, real PointWallet balance/preoccupation/recharge UI, existing profile dialog, settings route and logout action. Wallet remains mounted across menu closure; account identity keys isolate balances. Homepage uses a scrolling viewport and correct responsive padding so its footer remains reachable. +- Verification: 25 focused unit tests (homepage 5 / module navigation 9 / wallet 11), typecheck, scoped lint and full build passed. New fixture Electron account scenario and the three-card responsive scenario passed; screenshots at 1440 and 375 visually inspected. Initial account fixture failure was an absent fake session, corrected using the real versioned storage contract, without changing the product's authentication guard. +- Entire dist-electron and resources independently match the running 7c51bb4 snapshot byte-for-byte. No Main/shared/resource/package source changes. Renderer-only application update pending; source commit below will identify the build. ## Project Context Loaded diff --git a/README.md b/README.md index cf0dfc14..ac645270 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。三个入口在桌面等宽并排、插画在上文字在下,小窗口改为纵向排列的横向插画卡片;Agents 标语为“打造你想象中的AI助手”。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。三个入口在桌面等宽并排、插画在上文字在下,小窗口改为纵向排列的横向插画卡片;Agents 标语为“打造你想象中的AI助手”。首页左下角保留账户入口,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 diff --git a/src/components/account/HomeAccountMenu.tsx b/src/components/account/HomeAccountMenu.tsx new file mode 100644 index 00000000..ddcfda3f --- /dev/null +++ b/src/components/account/HomeAccountMenu.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChevronUp, LogOut, Settings, UserCircle2 } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { UserAvatar } from '@/components/profile/UserAvatar'; +import { getAccountInitial } from '@/components/profile/user-avatar-utils'; +import { DisclosureContent } from '@/components/ui/disclosure'; +import { useAuthStore } from '@/stores/auth'; +import { PointWallet } from './PointWallet'; + +export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { + accountName: string; + avatarUrl?: string | null; + onOpenProfile: () => void; +}) { + const user = useAuthStore(state => state.user); + const logout = useAuthStore(state => state.logout); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); + const [error, setError] = useState(''); + const trigger = useRef(null); + const menu = useRef(null); + const initial = getAccountInitial(accountName); + + useEffect(() => { + if (!open) return; + const outside = (event: MouseEvent) => { + if (event.target instanceof Node && !trigger.current?.contains(event.target) && !menu.current?.contains(event.target)) setOpen(false); + }; + const escape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + setOpen(false); + trigger.current?.focus(); + }; + document.addEventListener('mousedown', outside); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('mousedown', outside); + document.removeEventListener('keydown', escape); + }; + }, [open]); + + const focusItem = (last = false) => requestAnimationFrame(() => { + if (trigger.current?.getAttribute('aria-expanded') !== 'true') return; + const items = menu.current?.querySelectorAll('[role="menuitem"]:not(:disabled)'); + if (items?.length) items[last ? items.length - 1 : 0].focus(); + }); + + if (!user) return null; + + return
+ + { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const items = [...(menu.current?.querySelectorAll('[role="menuitem"]:not(:disabled)') ?? [])]; + const current = items.indexOf(document.activeElement as HTMLButtonElement); + const index = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 + : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length; + items[index]?.focus(); + }}> +
+ +

{accountName}

词元点数账户

+
+ +
+ + + +
+ {error &&

{error}

} +
+
; +} diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 80fe3c57..a342ce41 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -6,6 +6,7 @@ import moduleGameImage from '@/assets/module-game.webp'; import moduleRobotImage from '@/assets/module-robot.webp'; import logoWordmarkSource from '@/assets/makelore-wordmark-source.png'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; +import { HomeAccountMenu } from '@/components/account/HomeAccountMenu'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import { aiModules, isAiModuleAllowed, type AiModuleId } from '@/lib/ai-modules'; import { getAuthUserDisplayName } from '@/lib/auth-user-display'; @@ -29,6 +30,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea userProfile, profileRequired, profileSyncError, + syncProfileNow, } = useCurrentUserProfile(authRequired); const [profileDialogOpen, setProfileDialogOpen] = useState(false); const userDisplayName = getUserProfileDisplayName(userProfile) || getAuthUserDisplayName(authUser); @@ -48,8 +50,8 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea }; return ( -
-
+
+
+ {authenticated && authUser &&
+ { + setDismissedSyncError(null); setProfileDialogOpen(true); + void syncProfileNow().catch(() => undefined); + }} /> +
}
{ + test('keeps the home account wallet, profile, settings and logout available without creating a payment', async ({ launchElectronApp }, testInfo) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + const applicationUrl = page.url(); + await page.goto('about:blank'); + await app.evaluate(({ ipcMain, BrowserWindow }, summary) => { + BrowserWindow.getAllWindows()[0].setSize(1440, 900); + const state = { loggedIn: true, requests: [] as { method: string; path: string }[] }; + Object.assign(globalThis, { homeAccountFixture: state }); + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => { + const path = request.path ?? ''; + const method = (request.method ?? 'GET').toUpperCase(); + state.requests.push({ method, path }); + if (path === '/api/auth/session/sync') return result({ success: true, session: state.loggedIn ? { accessToken: 'home-account-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false } : null }); + if (path === '/api/auth/me') return result({ success: true, user: state.loggedIn ? { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] } : null, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + if (path === '/api/auth/logout' && method === 'POST') { state.loggedIn = false; return result({ success: true }); } + if (path === '/api/works/user/agent-profile' && method === 'GET') return result({ success: true, profile: { display_name: '小麦', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }); + if (path === '/api/works/billing/me' && method === 'GET') return result({ success: true, data: summary }); + if (path.startsWith('/api/works/billing/recharge/orders?') && method === 'GET') return result({ success: true, data: { items: [] } }); + if (path.startsWith('/api/works/billing/') || path === '/api/works/user/agent-profile') throw new Error('Unexpected home account write ' + method + ' ' + path); + return result({ success: true }); + }); + }, billingSummary()); + // The setup-skipping fixture starts as a guest. Hydrate only this isolated + // Renderer with a fake persisted session before the auth store is created. + await page.addInitScript(() => { + const now = Date.now(); + localStorage.setItem('niancode-auth', JSON.stringify({ version: 4, state: { + authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', + accessToken: 'home-account-fixture', tokenType: 'Bearer', + expiresAt: now + 3600000, lastActiveAt: now, canRefresh: false, legacyRefreshToken: null, + user: { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true }, + } })); + // Setup-skipping mode also suppresses the home profile sync. Restore the + // isolated account's cached profile in the store's persisted shape. + localStorage.setItem('niancode-user-agent-profiles', JSON.stringify({ version: 1, state: { + profilesByUserId: { 'home-user': { displayName: '小麦', age: null, gender: 'undisclosed', avatarUrl: null, + shareAgeWithAgents: false, shareGenderWithAgents: false, analysisEnabled: true, + pendingSync: false, version: 1, updatedAt: '2026-10-13T00:00:00Z' } }, + } })); + }); + await page.goto(applicationUrl); + const trigger = page.getByTestId('home-account-menu-trigger'); + const menu = page.getByTestId('home-account-menu'); + await expect(trigger).toContainText('小麦'); + await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); + const footer = await trigger.evaluate(element => element.closest('footer')!.getBoundingClientRect().top); + const cards = await page.getByTestId('ai-module-options').boundingBox(); + expect(footer).toBeGreaterThanOrEqual(cards!.y + cards!.height); + await trigger.click(); + await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点'); + await expect(menu.getByText('永久有效 · 预占 5 点', { exact: true })).toBeVisible(); + await expect(menu.getByRole('menuitem', { name: '词元点数与充值', exact: true })).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('home-account-menu-desktop.png') }); + await page.keyboard.press('Escape'); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await expect(trigger).toBeFocused(); + await trigger.click(); + await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + + await trigger.click(); + await menu.getByRole('menuitem', { name: '词元点数与充值', exact: true }).click(); + const wallet = page.getByTestId('point-wallet-dialog'); + await expect(wallet).toBeVisible(); + await expect(wallet.getByTestId('point-recharge-recipient')).toHaveText('充值到:小麦(child-wallet)'); + await expect(wallet.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled(); + await wallet.getByText('我的可用点数', { exact: true }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + await expect(wallet).toBeVisible(); + await wallet.getByRole('button', { name: '关闭词元点数', exact: true }).click(); + await expect(wallet).toHaveCount(0); + + await trigger.click(); + await menu.getByRole('menuitem', { name: '个人资料', exact: true }).click(); + const profile = page.getByRole('dialog', { name: '个人资料', exact: true }); + await expect(profile.getByLabel(/名字/)).toHaveValue('小麦'); + await profile.getByRole('button', { name: '关闭个人资料', exact: true }).click(); + await trigger.click(); + await menu.getByRole('menuitem', { name: '设置', exact: true }).click(); + await expect(page).toHaveURL(/\/settings$/); + await page.evaluate(() => { window.location.hash = '#/module-select'; }); + await expect(trigger).toContainText('小麦'); + + await app.evaluate(({ BrowserWindow }) => { const window = BrowserWindow.getAllWindows()[0]; window.setMinimumSize(360, 600); window.setSize(375, 800); }); + await expect.poll(() => page.evaluate(() => innerWidth)).toBe(375); + await trigger.click(); + await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点'); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + const menuBox = await menu.boundingBox(); + expect(menuBox!.x).toBeGreaterThanOrEqual(0); + expect(menuBox!.x + menuBox!.width).toBeLessThanOrEqual(375); + await page.screenshot({ path: testInfo.outputPath('home-account-menu-375.png') }); + await menu.getByRole('menuitem', { name: '退出登录', exact: true }).click(); + await expect(page).toHaveURL(/\/login$/); + await expect(trigger).toHaveCount(0); + const requests = await app.evaluate(() => (globalThis as typeof globalThis & { homeAccountFixture: { requests: { method: string; path: string }[] } }).homeAccountFixture.requests); + const billingReads = requests.filter(request => request.path.startsWith('/api/works/billing/')); + expect(billingReads.length).toBeGreaterThan(0); + expect(billingReads.every(request => request.method === 'GET')).toBe(true); + expect(requests.filter(request => request.path === '/api/works/user/agent-profile').every(request => request.method === 'GET')).toBe(true); + expect(requests.filter(request => request.path === '/api/auth/logout' && request.method === 'POST')).toHaveLength(1); + } finally { + await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined); + await closeElectronApp(app); + } + }); + test('opens remembered Code projects and keeps the module return entry in chat instead of project settings', async ({ electronTestResources, launchElectronApp, diff --git a/tests/unit/home-account-menu.test.tsx b/tests/unit/home-account-menu.test.tsx new file mode 100644 index 00000000..8ac090dc --- /dev/null +++ b/tests/unit/home-account-menu.test.tsx @@ -0,0 +1,134 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ModuleSelection } from '@/pages/ModuleSelection'; +import { useAuthStore } from '@/stores/auth'; +import { useUserProfileStore } from '@/stores/user-profile'; +import { billingSummary, permanentBalance } from '../fixtures/permanent-points'; + +const api = vi.hoisted(() => ({ fetch: vi.fn() })); +vi.mock('@/lib/host-api', () => ({ hostApiFetch: api.fetch })); + +let profileName: string; +function profileResponse() { + return { success: true, profile: { display_name: profileName, age: null, gender: null, avatar_url: null, + share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, + completed: true, version: 1, updated_at: '2026-10-13T00:00:00Z' } }; +} +function LocationProbe() { + const location = useLocation(); + return {location.pathname}; +} +function renderHome() { + return render( + } /> + } /> + ); +} +const writes = () => api.fetch.mock.calls.filter(([, init]) => init?.method && init.method !== 'GET'); + +beforeEach(() => { + vi.resetAllMocks(); profileName = '小麦'; + useAuthStore.setState({ initialized: true, loading: false, error: null, accessToken: 'home-fixture', tokenType: 'Bearer', + expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false, legacyRefreshToken: null, + user: { username: 'child-wallet', userId: 'home-user', tenantId: null, deptId: null, authorities: [] }, + moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } }); + useUserProfileStore.setState({ profilesByUserId: {}, syncStatesByUserId: {} }); + api.fetch.mockImplementation(async (path: string) => { + if (path === '/api/works/user/agent-profile') return profileResponse(); + if (path === '/api/works/billing/me') return { success: true, data: billingSummary() }; + if (path.startsWith('/api/works/billing/recharge/orders?')) return { success: true, data: { items: [] } }; + if (path === '/api/auth/logout') return { success: true }; + throw new Error('Unexpected home account fixture request ' + path); + }); +}); +afterEach(cleanup); + +it('shows the authenticated account and opens the real personal wallet with read requests only', async () => { + renderHome(); + const trigger = await screen.findByTestId('home-account-menu-trigger'); + await waitFor(() => expect(trigger).toHaveTextContent('小麦')); + expect(screen.getAllByTestId(/^ai-module-option-/)).toHaveLength(3); + fireEvent.click(trigger); + const menu = screen.getByTestId('home-account-menu'); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(await within(menu).findByTestId('sidebar-total-token-points')).toHaveTextContent('75 点'); + expect(within(menu).getByText('永久有效 · 预占 5 点', { exact: true })).toBeInTheDocument(); + fireEvent.click(within(menu).getByRole('menuitem', { name: '词元点数与充值', exact: true })); + const dialog = await screen.findByTestId('point-wallet-dialog'); + await waitFor(() => expect(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled()); + expect(within(dialog).getByTestId('point-recharge-recipient')).toHaveTextContent('充值到:小麦(child-wallet)'); + expect(api.fetch).toHaveBeenCalledWith('/api/works/billing/recharge/orders?offset=0', undefined); + fireEvent.mouseDown(within(dialog).getByText('我的可用点数', { exact: true })); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(dialog).toBeVisible(); + expect(writes()).toEqual([]); +}); + +it('opens the existing profile dialog and navigates to settings without saving profile changes', async () => { + renderHome(); + await screen.findByText('小麦,欢迎回来!'); + fireEvent.click(screen.getByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '个人资料', exact: true })); + const profile = await screen.findByRole('dialog', { name: '个人资料', exact: true }); + expect(within(profile).getByLabelText(/名字/)).toHaveValue('小麦'); + fireEvent.click(within(profile).getByRole('button', { name: '关闭个人资料', exact: true })); + fireEvent.click(screen.getByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '设置', exact: true })); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/settings'); + expect(writes()).toEqual([]); +}); + +it('closes the menu on Escape and outside clicks while preserving the wallet instance', async () => { + renderHome(); + const trigger = await screen.findByTestId('home-account-menu-trigger'); + fireEvent.click(trigger); + const menu = screen.getByTestId('home-account-menu'); + const wallet = await within(menu).findByTestId('sidebar-point-balance'); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(menu).toHaveAttribute('aria-hidden', 'true'); + fireEvent.click(trigger); + fireEvent.mouseDown(screen.getByRole('heading', { name: '今天,想创作什么?', exact: true })); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(trigger); + expect(within(menu).getByTestId('sidebar-point-balance')).toBe(wallet); + expect(writes()).toEqual([]); +}); + +it('remounts the wallet for another account and ignores the previous delayed balance', async () => { + let finish!: (value: { success: boolean; data: ReturnType }) => void; + const base = api.fetch.getMockImplementation()!; + let firstBalance = true; + api.fetch.mockImplementation((path: string, init) => { + if (path === '/api/works/billing/me') { + if (firstBalance) { firstBalance = false; return new Promise(resolve => { finish = resolve; }); } + return Promise.resolve({ success: true, data: billingSummary({ own_points: permanentBalance({ total_remaining: '2.00' }) }) }); + } + return base(path, init); + }); + renderHome(); + fireEvent.click(await screen.findByTestId('home-account-menu-trigger')); + await waitFor(() => expect(firstBalance).toBe(false)); + profileName = '小红'; + await act(async () => useAuthStore.setState({ user: { username: 'second-wallet', userId: 'second-user', tenantId: null, deptId: null, authorities: [] } })); + const trigger = screen.getByTestId('home-account-menu-trigger'); + await waitFor(() => expect(trigger).toHaveTextContent('小红')); + if (trigger.getAttribute('aria-expanded') !== 'true') fireEvent.click(trigger); + await waitFor(() => expect(within(screen.getByTestId('home-account-menu')).getByTestId('sidebar-total-token-points')).toHaveTextContent('2 点')); + await act(async () => finish({ success: true, data: billingSummary({ own_points: permanentBalance({ total_remaining: '999.00' }) }) })); + expect(screen.queryByText('999 点', { exact: true })).not.toBeInTheDocument(); + expect(writes()).toEqual([]); +}); + +it('logs out only the fixture account and hides the home account entry for signed-out users', async () => { + renderHome(); + fireEvent.click(await screen.findByTestId('home-account-menu-trigger')); + fireEvent.click(within(screen.getByTestId('home-account-menu')).getByRole('menuitem', { name: '退出登录', exact: true })); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/login'); + expect(useAuthStore.getState().user).toBeNull(); + expect(writes()).toEqual([['/api/auth/logout', expect.objectContaining({ method: 'POST' })]]); + cleanup(); + renderHome(); + expect(screen.queryByTestId('home-account-menu-trigger')).not.toBeInTheDocument(); +}); From 45e6b91715489a2c2d9e1da8783aa9d1b5362ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 13:03:43 +0800 Subject: [PATCH 06/15] docs(trial): verify restored homepage account controls --- .../tasks/20261013-combined-trial-update-c42d19a7.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md index 4161d70c..1e8c4340 100644 --- a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-combined-trial-update-c42d19a7 - Base commit: 8289f27f3e53ef455b656f1c43d2620d235d5014 - Owner: codex -- Status: Planning +- Status: Ready for Integration ## Scope @@ -25,7 +25,10 @@ - Do not open a real recharge order, modify profile, logout the real user or introduce another billing/auth authority during validation. - Implementation verified: homepage account footer/menu, real PointWallet balance/preoccupation/recharge UI, existing profile dialog, settings route and logout action. Wallet remains mounted across menu closure; account identity keys isolate balances. Homepage uses a scrolling viewport and correct responsive padding so its footer remains reachable. - Verification: 25 focused unit tests (homepage 5 / module navigation 9 / wallet 11), typecheck, scoped lint and full build passed. New fixture Electron account scenario and the three-card responsive scenario passed; screenshots at 1440 and 375 visually inspected. Initial account fixture failure was an absent fake session, corrected using the real versioned storage contract, without changing the product's authentication guard. -- Entire dist-electron and resources independently match the running 7c51bb4 snapshot byte-for-byte. No Main/shared/resource/package source changes. Renderer-only application update pending; source commit below will identify the build. +- Entire dist-electron and resources independently match the running 7c51bb4 snapshot byte-for-byte. No Main/shared/resource/package source changes. Renderer-only application update completed from product 5f7c851022bf3be36000c4729e04dbd52704a482; effective product tree is 5f7c851 while Main source stays 7c51bb4. +- Durable overlay: `/Users/chillishark/Makelore 麦洛/.trial-runtime/home-account-20261013-c42d19a7`, `overlay-manifest.json` and `verification-receipt.json`. Parent full-runtime artifacts/manifest remain immutable. Current pointer accurately identifies separate Main and Renderer/effective sources; original Main PID 9968 / port 58003 and authenticated session were preserved. +- Actual application checks: temporarily visited homepage after clean-state checks, new trigger present, four menu actions and successful wallet loading verified using booleans only; closed the menu and restored the original full CloudAgents hash/query. No credential/input/username/balance content recorded, no profile save, recharge or logout. Focus 83608→83608, no login form/visible alert; inspector closed. +- Initial reload verification ran before React/auth mounted and paused conservatively; final fresh checks confirmed no dirty input/dialog/lease and completed verification without another reload. Intermediate evidence retained. Root independently ran `python3 -B prepare.py --verify`, returning active-verified and unchanged Main PID. ## Project Context Loaded @@ -78,8 +81,10 @@ - Historical Code navigation E2E compares brand against inner tablist height; review the assertion against the existing toolbar contract in a separate scoped task. - A client update does not deploy Yuxi history/capability/empty-introduction support; preserve these known backend limits. - Combined source is on this feature branch; primary main remains untouched. Integrate the retained feature sources and canonical promotions separately when requested. This handoff's final documentation commit changes no built product bytes from 7c51bb4. +- Homepage follow-up effective tree 5f7c851 adds only the account access/layout and tests/README; later handoff documentation changes do not alter those built product bytes. Preserve the two-layer runtime proof when updating again. ## Promotion Candidates - Current-state / product snapshot: approved Robot hiding, channel prerequisite hint, knowledge two-pane management/chat-file batch picker, and definitive-create rejection versus uncertain-result recovery. Evidence is accepted source commits, exact product-blob comparison, combined tests and verified runtime receipts. Future impact: next application build and source integration must preserve all five source groups; no conflicting product decision or additional human confirmation required within the authorized update scope. - Runtime evidence index: current temporary application uses full Main+Renderer snapshot 7c51bb4 and standard same-account session restoration, independent of its source checkout; first-attempt rollback and OS-constrained bounds must remain distinguished from exact window restoration. Promote only during canonical integration; do not treat this as production backend/paid-model acceptance. +- Current-state / success criteria: the module homepage now exposes the same existing balance/recharge/profile/settings/logout functions as the module sidebars, with keyboard/close behavior, account isolation and persistent wallet lifecycle. Evidence: source 5f7c851, focused tests/fixture screenshots and verified live renderer overlay; no new billing/auth contract or contradictory decision. From 383ce71fafaa64e55509d35c3a2d8c6185afabb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 13:18:47 +0800 Subject: [PATCH 07/15] fix(home): align stacked module entries with creation title --- ...20261013-combined-trial-update-c42d19a7.md | 16 +- README.md | 4 +- src/pages/CloudAgents/CloudRecovery.tsx | 86 +++++-- src/pages/CloudAgents/agent-home.css | 20 ++ src/pages/CloudAgents/index.tsx | 12 +- src/pages/ModuleSelection/index.tsx | 10 +- src/styles/globals.css | 68 ++++-- tests/e2e/cloud-agent-recovery.spec.ts | 220 ++++++++++++++++++ tests/e2e/main-navigation.spec.ts | 58 +++-- tests/unit/cloud-agents-page.test.tsx | 126 ++++++++-- tests/unit/cloud-recovery.test.tsx | 209 +++++++++++++++++ 11 files changed, 719 insertions(+), 110 deletions(-) create mode 100644 tests/e2e/cloud-agent-recovery.spec.ts create mode 100644 tests/unit/cloud-recovery.test.tsx diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md index 1e8c4340..c77e813a 100644 --- a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -8,13 +8,27 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-combined-trial-update-c42d19a7 - Base commit: 8289f27f3e53ef455b656f1c43d2620d235d5014 - Owner: codex -- Status: Ready for Integration +- Status: Planning ## Scope - Assemble and validate completed homepage and Agent changes, then update the existing local temporary application with both Main and Renderer while retaining its profile and login. - Keep the current temporary application as the target; no production backend deployment, repository push, primary checkout change or worktree cleanup. - Follow-up: restore access to the existing account controls on the standalone module homepage and update the current trial. +- Latest follow-up: restore the desktop title-left / three-card vertical layout with the Code card centered on the title; retain Robot hiding and account controls. + +## Homepage Vertical Layout Follow-up · 2026-10-13 + +- Concurrent / Planning Gates: Passed on same-task resume; successful start and matching feature / task / branch / absolute worktree / base ownership verified. Retain unchanged startup, positioning, architecture, domain, accepted decisions, evidence/reflection/commitment context from the previous gate; reread the active record, startup, planning gate and local AGENTS. Source task records stay read-only; primary checkout remains untouched. +- Peer delta review: new completed Agent-create reminder and recovery-trial tasks affect recovery UI/runtime, not homepage layout. Canvas remains investigation-only. No conflicting homepage decision. Refresh active runtime binding and preserve any newly deployed recovery product delta before updating; do not reuse an old current pointer. +- User explicitly requests the prior vertical presentation: left welcome/title and right Agents, Code, Canvas cards. Robot remains hidden. Align the second Code card's center with the h1 center, excluding the welcome line; use equal card heights and equal vertical gaps. Keep the account footer and existing permissions/routes/drag region. +- Plan: desktop at 1024px and above uses two columns with the welcome line above the left h1 outside its alignment flow; the right cards retain image-left horizontal contents and stack vertically. Smaller widths put the title above the same card stack. Preserve current light-system assets and interaction styling, without changing Main/backend/billing/auth behavior. +- Preserve latest deployed recovery source 7d4a247b64765dfd843cb22fc18b65e1be3196fd by applying its exact seven-file product/test delta versus 5f7c851 (README combined semantically). Six source/test blobs match 7d exactly; no peer docs imported, no Main/shared/package/lock change. Preserve current recovery dialog/trigger, original operation identity, onSettled/outcome and key-result protections. +- Verification plan: focused navigation/account/wallet and recovery units, typecheck, scoped lint, complete build, Electron geometry and account-footer scenarios; inspect 1440/1024/768/375 screenshots. Refresh Renderer only after full Main/preload/resource/package comparison and live clean-state check; verify geometry and restore the original route/session/focus. +- Implementation complete: desktop two-column grid, three horizontal image-left cards stacked with equal heights/gaps; h1 is the left alignment reference and the welcome line sits above it. Narrow widths retain the title-above stack, account footer remains reachable, Robot stays hidden. No other product differences against current deployed 7d4a247 beyond ModuleSelection/globals and README. +- Verification completed before staging: 5 focused unit files / 77 tests passed; typecheck, seven-file scoped ESLint and complete build:vite passed. Four UI scenarios passed (Electron homepage geometry/account controls and browser recovery at 1440/375). Geometry at 1440/1024/768/375 checks equal card height/gap, h1/Code center error under 1px at desktop, horizontal image/text, full hit areas and no horizontal overflow. All four layout screenshots and the account menu screenshot visually inspected. No unrelated historical Code test rerun/weakening. +- Runtime update pending: refreshed current source is Renderer 7d4a247 over unchanged Main 7c51bb4; active pointer carries stale 5f effective fields from the peer overlay, so the next receipt must bind exact active Renderer/source-tree evidence and correct effective-source metadata. + ## Homepage Account Follow-up · 2026-10-13 diff --git a/README.md b/README.md index ac645270..07088222 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,14 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。三个入口在桌面等宽并排、插画在上文字在下,小窗口改为纵向排列的横向插画卡片;Agents 标语为“打造你想象中的AI助手”。首页左下角保留账户入口,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。首页左下角保留账户入口,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 - `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后续提问。历史回看需要配套 Yuxi API 的思考和工具记录投影;实时过程沿用既有事件流。平台工具和内置做事手册使用 Yuxi 下发的通俗名称与说明;逐条消息的思考、工具输入/结果/失败独立于 Markdown 正文,实时与历史工具卡片共用展示文案,展开可查看原始工具标识。个人上传手册保留作者文案,资源选择仍保存原始标识。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。 + 历史待确认记录收起在智能体列表标题区的「创建记录/待处理记录」入口,只有点击后才打开记录窗口;配置、聊天和创建页不再置顶这些旧提醒。记录保留智能体名称和原请求身份,用户可明确「再试一次」或「不再提醒」,不会自动重试或清除。当前创建失败继续在创建表单内提示;关闭记录窗口保留已收到的结果和密钥,切换配置、聊天或创建页后可返回列表继续查看。密钥须明确关闭结果后,才可处理其他记录。 + 高级配置的知识库支持下拉多选,分为「MakeLore知识」和「我的知识库」,已选项可单独移除。MakeLore知识来自当前账号有权读取的 Yuxi 共享目录,只显示名称与简介;移除仅取消当前智能体选用,不删除原库。知识库区域左右分区:左侧「上传自己的文件」管理本人知识库,右侧选择「MakeLore知识」和「我的知识库」,小窗口上下排列。左侧可新建库或追加资料,提供「从电脑上传」和「从聊天选择文件」;聊天文件选择面板显示目标知识库、可搜索的来源聊天和文件名称/类型/大小,支持多选,逐文件导入并保留独立操作身份,部分失败只重试失败项;也可明确确认放弃本页重试,已保存文件仍保留。仅选本人已上传的聊天附件,聊天正文和助手生成的文件不直接入库。上传和导入后自动解析、建立索引并持续显示真实处理状态;失败可重试且保留已有可用内容。使用说明与费用以折叠说明呈现。暂不可选用的个人库仍可管理文件,已有绑定保留并提示不可用;未确认请求在当前库保留同一操作身份供重试。知识库选择只改草稿,显式保存/应用后生效;个人云端执行已自动接入所选知识库的检索,无需另外勾选 knowledge-base Skill。此交互复用现有接口,无需新增后端补丁。 云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。 diff --git a/src/pages/CloudAgents/CloudRecovery.tsx b/src/pages/CloudAgents/CloudRecovery.tsx index 098c4b64..8a2506fd 100644 --- a/src/pages/CloudAgents/CloudRecovery.tsx +++ b/src/pages/CloudAgents/CloudRecovery.tsx @@ -1,5 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { History, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { AppError } from '@/lib/error-model'; import type { CloudPendingOperation } from '../../../shared/cloud-agents'; @@ -16,49 +19,92 @@ const labels: Record = { }; const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']); -export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOperation[]; onResolved: () => void }) { +export function CloudRecovery({ pending, onResolved, triggerContainer }: { pending: CloudPendingOperation[]; onResolved: () => void; triggerContainer?: HTMLElement | null }) { + const [open, setOpen] = useState(false); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); - const [result, setResult] = useState<{ operation: string; value: unknown } | null>(null); + const [result, setResult] = useState<{ operation: string; name?: string; value: unknown } | null>(null); + const inFlight = useRef(false); + const awaitingKeyClose = useRef(false); + const alive = useRef(true); + const creationOnly = pending.length > 0 ? pending.every(item => item.operation === 'createAgent') : result?.operation === 'createAgent'; + const title = creationOnly ? '创建记录' : '待处理记录'; + useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); + useEffect(() => { if (triggerContainer === null && !busy) setOpen(false); }, [triggerContainer, busy]); + const close = () => { if (!inFlight.current) setOpen(false); }; const resolve = async (item: CloudPendingOperation, discard = false) => { - setBusy(item.id); setError(''); setNotice(''); setResult(null); + if (inFlight.current || awaitingKeyClose.current) return; + inFlight.current = true; + setBusy(item.id); setError(''); setNotice(''); try { const response = await cloudAgentsApi.resolvePending(item.id, discard); + if (!alive.current) return; if (response.requires_input) { setResult(null); setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。'); } else if (response.retired) { setResult(null); setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。'); - } else if (!discard) setResult({ operation: item.operation, value: response.result }); + } else if (!discard) { + awaitingKeyClose.current = item.operation === 'createKey'; + setResult({ operation: item.operation, + name: typeof item.input.name === 'string' ? item.input.name : undefined, value: response.result }); + } onResolved(); } catch (e) { - const message = e instanceof Error ? e.message : '暂时无法确认结果'; + if (!alive.current) return; + const message = e instanceof Error ? e.message : '这次没能完成,请稍后再试。'; if (e instanceof AppError && e.details?.commandOutcome === 'definitive_failure') { setError(`${labels[item.operation] ?? '操作'}失败:${message}`); onResolved(); } else setError(message); } - finally { setBusy(''); } + finally { inFlight.current = false; if (alive.current) setBusy(''); } }; - if (!pending.length && !result && !error && !notice) return null; - return ; + {!pending.length && !result && !error && !notice &&

记录已经处理完了。

} + {pending.length > 0 &&

不再提醒只会关闭提醒,已经保存的内容会保留。

} +
+ + + ; } diff --git a/src/pages/CloudAgents/agent-home.css b/src/pages/CloudAgents/agent-home.css index d29e0de5..1599c998 100644 --- a/src/pages/CloudAgents/agent-home.css +++ b/src/pages/CloudAgents/agent-home.css @@ -61,6 +61,26 @@ .agent-home-loading svg { width: 38px; height: 38px; color: #8273a9; } .agent-home-error { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; padding: 12px 16px; border: 1px solid #e3dfd6; border-radius: 14px; background: #fff; font-size: 14px; } .agent-home-error p { overflow-wrap: anywhere; } +.agent-recovery-slot:empty { display: none; } +.agent-recovery-trigger { gap: 7px; color: #606c7e; font-size: 13px; } +.agent-recovery-count { display: inline-grid; min-width: 21px; height: 21px; padding-inline: 5px; place-items: center; border-radius: 8px; background: #ede9f6; color: #7161a5; font-size: 11px; } +.agent-recovery-dialog.agent-recovery-modal { display: flex; max-height: calc(100dvh - 32px); flex-direction: column; gap: 20px; border-radius: 24px; } +.agent-recovery-dialog .agent-recovery-heading { flex-shrink: 0; flex-direction: row; align-items: flex-start; justify-content: space-between; gap: 12px; text-align: left; } +.agent-recovery-heading > div { min-width: 0; } +.agent-recovery-heading > button { flex-shrink: 0; margin-top: -8px; margin-right: -8px; } +.agent-recovery-heading p { margin-top: 8px; line-height: 1.7; } +.agent-recovery-content { display: flex; min-height: 0; flex-direction: column; gap: 14px; overflow: auto; overscroll-behavior: contain; font-size: 14px; } +.agent-recovery-record { padding: 16px; border: 1px solid #e5e0ef; border-radius: 16px; background: #f8f6fd; overflow-wrap: anywhere; } +.agent-recovery-record h3 { font-weight: 600; line-height: 1.6; } +.agent-recovery-time { margin-top: 3px; color: #788193; font-size: 12px; } +.agent-recovery-record-help { margin-top: 10px; color: #606c7e; line-height: 1.7; } +.agent-recovery-record-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.agent-recovery-record-actions button { min-height: 40px; border-radius: 10px; } +.agent-recovery-footnote { color: #788193; font-size: 12px; line-height: 1.7; } +.agent-recovery-result { padding: 16px; border: 1px solid #dbe9df; border-radius: 16px; background: #f2f8f3; line-height: 1.7; overflow-wrap: anywhere; } +.agent-recovery-result pre { overflow: auto; } +.agent-recovery-footer { flex-shrink: 0; } +.agent-recovery-footer button { min-height: 44px; } @media (max-width: 1150px) { .agent-home { padding: 32px 28px 40px; } } diff --git a/src/pages/CloudAgents/index.tsx b/src/pages/CloudAgents/index.tsx index 72b217bd..474b7694 100644 --- a/src/pages/CloudAgents/index.tsx +++ b/src/pages/CloudAgents/index.tsx @@ -40,6 +40,7 @@ function AgentWorkspace() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(''); + const [recoveryTarget, setRecoveryTarget] = useState(null); const cancelDelete = useRef(null); const deleteTrigger = useRef(null); const createButton = useRef(null); @@ -115,7 +116,6 @@ function AgentWorkspace() { void cloudAgentsApi.remember(null).catch(failure => { if (mounted.current) setError(errorMessage(failure)); }); refreshRecovery(); }; - const recovery = ; const accepted = (agent: CloudAgentDraft) => { if (!mounted.current) return; @@ -156,22 +156,21 @@ function AgentWorkspace() { } }; - if (selected) return
{recovery} { + const renderWorkspace = () => { + if (selected) return
{ setSelected(null); setEntryView(undefined); setStartingPrompt(undefined); clearRecent(); }} onSaved={accepted} />
; - if (shared || activity) return
{recovery} { setShared(null); setActivity(null); clearRecent(); }} />
; + if (shared || activity) return
{ setShared(null); setActivity(null); clearRecent(); }} />
; if (creating) return
- {recovery}
{ setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} onSettled={refreshRecovery} onCancel={() => { setCreating(false); void load(); }} />
; return (
- {recovery}
@@ -184,6 +183,7 @@ function AgentWorkspace() {
}
+
{(section !== 'mine' || loading || error || agents.length > 0) && }
@@ -241,6 +241,8 @@ function AgentWorkspace() {
); + }; + return <>{renderWorkspace()}; } function CreateAgentForm({ onCreated, onCancel, onSettled }: { diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index a342ce41..e37f12a7 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -51,7 +51,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea return (
-
+
-
-
-

+

+
+

{userDisplayName ? `${userDisplayName},欢迎回来!` : '欢迎回来!'}

-

今天,想创作什么?

+

今天,想创作什么?

{ + fixtureDir = await realpath(await mkdtemp(join(tmpdir(), 'makelore-recovery-fixture-'))); + await symlink(resolve('node_modules'), join(fixtureDir, 'node_modules'), 'dir'); + const html = join(fixtureDir, 'index.html'); + const output = join(fixtureDir, 'dist'); + const entry = resolve('tests/e2e/__cloud-agent-recovery-harness.tsx'); + await writeFile(html, '
'); + await build({ + configFile: false, root: fixtureDir, publicDir: false, logLevel: 'warn', + resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' }, + css: { postcss: resolve('.') }, + build: { outDir: output, emptyOutDir: true, rollupOptions: { input: html } }, + plugins: [{ + name: 'isolated-cloud-agent-recovery', enforce: 'pre', + resolveId(id) { if (id === '/__cloud-agent-recovery-harness.tsx') return entry; }, + load(id) { + if (id === entry) return ` + import { createRoot } from 'react-dom/client'; + import { createMemoryRouter, RouterProvider } from 'react-router-dom'; + import CloudAgents from ${JSON.stringify(resolve('src/pages/CloudAgents/index.tsx'))}; + import ${JSON.stringify(resolve('src/styles/globals.css'))}; + const router = createMemoryRouter([{ path: '/cloud-agents', element: }], { initialEntries: ['/cloud-agents?view=mine'] }); + createRoot(document.getElementById('root')).render(
); + `; + if (id.endsWith('/src/stores/auth.ts')) return 'export function useAuthStore(select) { return select({ user: { userId: "recovery-fixture" } }); }'; + }, + }], + }); + const mime: Record = { html: 'text/html', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml', + png: 'image/png', woff2: 'font/woff2', woff: 'font/woff' }; + server = createServer(async (req, res) => { + const pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://127.0.0.1').pathname); + const file = resolve(output, '.' + (pathname === '/' ? '/index.html' : pathname)); + if (!file.startsWith(output + sep)) { res.writeHead(403); res.end(); return; } + try { res.setHeader('Content-Type', mime[file.split('.').pop()!] ?? 'application/octet-stream'); res.end(await readFile(file)); } + catch { res.writeHead(404); res.end(); } + }); + await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', done); }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture HTTP address is unavailable'); + baseUrl = `http://127.0.0.1:${address.port}`; +}); +test.afterAll(async () => { + try { + if (server?.listening) { server.closeAllConnections(); await new Promise((done, reject) => server.close(error => error ? reject(error) : done())); } + } finally { + try { if (fixtureDir) await rm(fixtureDir, { recursive: true, force: true }); } + finally { (viteDependencies('esbuild') as { stop(): void }).stop(); } + } +}); + +const agent: CloudAgentDraft = { + slug: 'ml-' + 'a'.repeat(32), name: '资料小助手', purpose: '一起观察和记录。', system_prompt: '用简单的中文回答。', + draft_revision: 1, configuration: { ...EMPTY_CLOUD_CONFIGURATION, model: 'fixture-model' }, + published_version: 1, enabled: true, updated_at: '2026-10-13T00:00:00Z', +}; +const longName = ('观察小伙伴_' + 'SuperLongUnbrokenAgentName'.repeat(4)).slice(0, 100); +const records: CloudPendingOperation[] = Array.from({ length: 8 }, (_, index) => ({ + id: `e2e-create-record-${index + 1}`, operation: 'createAgent', + input: { operation_id: `e2e-create-record-${index + 1}`, name: index === 0 ? longName : `我的观察伙伴 ${index + 1}`, purpose: '', icon: null }, + created_at: `2026-10-13T0${8 - index}:30:00Z`, +})); +const retryError = '创建结果暂时无法确认,请稍后再试。'; +type FixtureCall = { path: string; method: string; input: Record | null }; +type RecoveryFixture = { agents: CloudAgentDraft[]; pending: CloudPendingOperation[]; retry: 'fail' | 'success'; calls: FixtureCall[] }; +type FixtureWindow = Window & { __recoveryFixture: RecoveryFixture }; + +async function installFixture(page: Page, count = 2) { + page.on('pageerror', error => { throw error; }); + await page.addInitScript(({ agent, records, retryError }) => { + const scope = window as unknown as FixtureWindow; + const state: RecoveryFixture = { agents: [agent], pending: records, retry: 'fail', calls: [] }; + scope.__recoveryFixture = state; + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json: structuredClone(json) } }); + Object.defineProperty(window, 'electron', { value: { ipcRenderer: { + invoke: async (channel: string, request?: { path?: string; method?: string; body?: string }) => { + if (channel === 'hostapi:token') return 'fixture'; + if (channel === 'hostapi:base-url') return 'http://fixture.invalid'; + if (channel !== 'hostapi:fetch') throw new Error('Unexpected fixture IPC ' + channel); + const path = request?.path ?? ''; const method = request?.method ?? 'GET'; + const input = request?.body ? JSON.parse(request.body) as Record : null; + state.calls.push({ path, method, input }); + if (path === '/api/cloud-agents/recovery') return result({ recent: null, pending: state.pending }); + if (path === '/api/cloud-agents/recovery/recent') return result({ saved: true }); + if (path === '/api/cloud-agents/recovery/pending') { + const record = state.pending.find(item => item.id === input?.id); + if (!record) throw new Error('Recovery must keep the original pending id'); + if (input?.discard) { state.pending = state.pending.filter(item => item.id !== record.id); return result({ discarded: true }); } + if (state.retry === 'fail') return { ok: true, data: { status: 503, ok: false, json: { error: retryError } } }; + const created = { ...agent, slug: 'ml-' + 'b'.repeat(32), name: String(record.input.name), published_version: null }; + state.agents.push(created); state.pending = state.pending.filter(item => item.id !== record.id); + return result({ operation: 'createAgent', result: created }); + } + if (path.startsWith('/api/cloud-agents/agents')) { + if (method !== 'GET') throw new Error('No new Agent may be created by reminder browsing'); + return result(path === '/api/cloud-agents/agents' ? { agents: state.agents, next_cursor: null } : agent); + } + if (path !== '/api/cloud-agents/actions') throw new Error('Unexpected fixture path ' + path); + switch (input?.operation) { + case 'access': return result({ published_version: 1, enabled: true, share_url: 'niancode://agents/' + agent.slug, + api_url: 'http://fixture.invalid', grants: [], applications: [], versions: [{ version: 1, draft_revision: 1 }] }); + case 'catalog': return result({ models: [{ id: 'fixture-model', name: '创作模型' }], resources: {}, pricing: null }); + case 'knowledge': return result({ databases: [], models: [] }); + case 'threads': return result({ threads: [], next_offset: null }); + default: throw new Error('Unexpected fixture operation ' + String(input?.operation)); + } + }, + } } }); + }, { agent, records: records.slice(0, count), retryError }); +} + +async function recoveryCalls(page: Page) { + return page.evaluate(() => (window as unknown as FixtureWindow).__recoveryFixture.calls.filter(call => call.path === '/api/cloud-agents/recovery/pending')); +} +async function expectNoReminderOutsideDialog(page: Page) { + await expect(page.getByRole('complementary', { name: '未确认的云端操作', exact: true })).toHaveCount(0); + await expect(page.getByText('有 2 个操作的结果尚未确认', { exact: true })).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +} +async function expectModalFits(dialog: Locator) { + const geometry = await dialog.evaluate(element => { + const box = element.getBoundingClientRect(); + return { fits: box.left >= 0 && box.top >= 0 && box.right <= innerWidth && box.bottom <= innerHeight, + horizontalOverflow: element.scrollWidth > element.clientWidth }; + }); + expect(geometry).toEqual({ fits: true, horizontalOverflow: false }); +} + +for (const width of [1440, 375]) { + test(`creation records stay in the header and use a scrollable modal at ${width}px`, async ({ page }) => { + const count = width === 375 ? 8 : 2; + await page.setViewportSize({ width, height: 850 }); await installFixture(page, count); await page.goto(baseUrl); + const home = page.getByTestId('cloud-agents-page'); + const trigger = home.getByRole('button', { name: new RegExp(`^创建记录.*${count}`) }); + await expect(trigger).toBeVisible(); await expect(page.getByRole('dialog')).toHaveCount(0); + expect(await trigger.evaluate(button => Boolean(button.closest('.agent-home-header')))).toBe(true); + await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await expectNoReminderOutsideDialog(page); expect(await recoveryCalls(page)).toEqual([]); + await page.screenshot({ path: test.info().outputPath(`recovery-home-${width}.png`), animations: 'disabled' }); + await trigger.click(); + const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + await expect(dialog).toBeVisible(); await expect(dialog).toContainText(longName); + await expect(dialog.locator('.agent-recovery-record')).toHaveCount(count); + await expect(dialog.locator('.agent-recovery-time').first()).not.toHaveText(''); + const close = dialog.getByRole('button', { name: '返回列表', exact: true }); + await expect(close).toBeInViewport(); await expectModalFits(dialog); + await dialog.locator('.agent-recovery-record').last().scrollIntoViewIfNeeded(); + await expect(dialog.locator('.agent-recovery-record').last().getByRole('button', { name: '再试一次', exact: true })).toBeInViewport(); + await expect(close).toBeInViewport(); await expectModalFits(dialog); + expect(await recoveryCalls(page)).toEqual([]); + await page.screenshot({ path: test.info().outputPath(`recovery-modal-${width}.png`), animations: 'disabled' }); + await close.click(); await expect(dialog).toHaveCount(0); + await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await trigger.click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(count); + await close.click(); expect(await recoveryCalls(page)).toEqual([]); + }); +} + +test('retry keeps the same pending identity and errors remain inside the modal', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); + const trigger = page.getByRole('button', { name: /^创建记录/ }); + await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + for (let attempt = 0; attempt < 2; attempt++) { + await dialog.getByRole('button', { name: '再试一次', exact: true }).first().click(); + await expect(dialog.getByRole('alert')).toContainText(retryError); + await expect(dialog.getByRole('button', { name: '再试一次', exact: true }).first()).toBeEnabled(); + } + expect((await recoveryCalls(page)).map(call => call.input)).toEqual([{ id: records[0].id, discard: false }, { id: records[0].id, discard: false }]); + await page.screenshot({ path: test.info().outputPath('recovery-retry-error-375.png'), animations: 'disabled' }); + await dialog.getByRole('button', { name: '关闭记录', exact: true }).click(); + await expect(dialog).toHaveCount(0); await expect(page.getByRole('alert')).toHaveCount(0); + await expectNoReminderOutsideDialog(page); await expect(page.getByRole('article', { name: agent.name, exact: true })).toBeVisible(); + await trigger.click(); await expect(dialog.locator('.agent-recovery-record')).toHaveCount(2); + expect(await recoveryCalls(page)).toHaveLength(2); +}); + +test('discard is explicit and reminders do not follow into configuration, chat or creation', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 850 }); await installFixture(page); await page.goto(baseUrl); + const trigger = page.getByRole('button', { name: /^创建记录/ }); + await trigger.click(); const dialog = page.getByRole('dialog', { name: '创建记录', exact: true }); + await dialog.getByRole('button', { name: '不再提醒', exact: true }).first().click(); + await expect(dialog.locator('.agent-recovery-record')).toHaveCount(1); + expect((await recoveryCalls(page)).map(call => call.input)).toEqual([{ id: records[0].id, discard: true }]); + await dialog.getByRole('button', { name: '返回列表', exact: true }).click(); + await expect(trigger).toContainText('1'); + const card = page.getByRole('article', { name: agent.name, exact: true }); + await card.getByRole('button', { name: '调整', exact: true }).click(); + await expect(page.getByTestId('cloud-agent-editor')).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '我的智能体', exact: true }).click(); + await card.getByRole('button', { name: '和它聊聊', exact: true }).click(); + await expect(page.getByRole('button', { name: '返回智能体列表', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '返回智能体列表', exact: true }).click(); + await page.getByRole('button', { name: '创建智能体', exact: true }).click(); + await expect(page.getByRole('heading', { name: '创建智能体', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: /^创建记录/ })).toHaveCount(0); await expectNoReminderOutsideDialog(page); + await page.getByRole('button', { name: '取消', exact: true }).click(); + await expect(trigger).toContainText('1'); expect(await recoveryCalls(page)).toHaveLength(1); + const calls = await page.evaluate(() => (window as unknown as FixtureWindow).__recoveryFixture.calls); + expect(calls.filter(call => call.path.startsWith('/api/cloud-agents/agents') && call.method !== 'GET')).toEqual([]); +}); diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index d25ca55e..74ccff8a 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -314,7 +314,7 @@ test.describe('Makelore module navigation without setup flow', () => { } }); - test('lays out three module cards in a desktop row and a compact mobile stack', async ({ launchElectronApp }) => { + test('aligns the desktop home title with the middle of three stacked horizontal module cards', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -343,9 +343,7 @@ test.describe('Makelore module navigation without setup flow', () => { imageWidth: image?.width ?? 0, imageHeight: image?.height ?? 0, imageLeft: image?.left ?? 0, - imageTop: image?.top ?? 0, contentLeft: content?.left ?? 0, - contentTop: content?.top ?? 0, }; })); @@ -355,47 +353,45 @@ test.describe('Makelore module navigation without setup flow', () => { BrowserWindow.getAllWindows()[0]?.setMinimumSize(360, 600); }); - for (const width of [1440, 1024, 768]) { - await app.evaluate(({ BrowserWindow }, windowWidth) => BrowserWindow.getAllWindows()[0]?.setSize(windowWidth, 900), width); + for (const width of [1440, 1024, 768, 375]) { + await app.evaluate(({ BrowserWindow }, windowWidth) => BrowserWindow.getAllWindows()[0]?.setSize(windowWidth, windowWidth === 375 ? 800 : 900), width); await expect.poll(() => page.evaluate(() => innerWidth)).toBe(width); const cards = await readCardMetrics(); expect(cards).toHaveLength(3); - expect(Math.max(...cards.map(card => card.top)) - Math.min(...cards.map(card => card.top))).toBeLessThan(1); + expect(Math.max(...cards.map(card => card.left)) - Math.min(...cards.map(card => card.left))).toBeLessThan(1); expect(Math.max(...cards.map(card => card.width)) - Math.min(...cards.map(card => card.width))).toBeLessThan(1); - const gaps = cards.slice(1).map((card, index) => card.left - cards[index]!.left - cards[index]!.width); + expect(Math.max(...cards.map(card => card.height)) - Math.min(...cards.map(card => card.height))).toBeLessThan(1); + const gaps = cards.slice(1).map((card, index) => card.top - cards[index]!.top - cards[index]!.height); expect(Math.min(...gaps)).toBeGreaterThan(0); expect(Math.abs(gaps[0]! - gaps[1]!)).toBeLessThan(1); for (const card of cards) { expect(card.left).toBeGreaterThanOrEqual(0); expect(card.left + card.width).toBeLessThanOrEqual(width); - expect(card.contentTop).toBeGreaterThanOrEqual(card.imageTop + card.imageHeight - 1); + expect(card.imageWidth).toBeLessThan(card.width / 2); + expect(card.imageHeight).toBeGreaterThan(card.height * 0.75); + expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1); } - await expect(page.getByRole('heading', { name: '今天,想创作什么?' })).toHaveCSS('text-align', 'center'); - expect(await moduleCards.evaluateAll(elements => elements.every(element => { - const bounds = element.getBoundingClientRect(); - return [[2, 2], [bounds.width - 2, bounds.height - 2], [bounds.width / 2, bounds.height / 2]].every(([x, y]) => - element.contains(document.elementFromPoint(bounds.left + x!, bounds.top + y!)), - ); - }))).toBe(true); + const heading = await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).boundingBox(); + expect(heading).not.toBeNull(); + if (width >= 1024) { + expect(heading!.x + heading!.width).toBeLessThanOrEqual(cards[0]!.left); + expect(Math.abs(heading!.y + heading!.height / 2 - cards[1]!.top - cards[1]!.height / 2)).toBeLessThan(1); + expect(await moduleCards.evaluateAll(elements => elements.every(element => { + const bounds = element.getBoundingClientRect(); + return [[2, 2], [bounds.width - 2, bounds.height - 2], [bounds.width / 2, bounds.height / 2]].every(([x, y]) => + element.contains(document.elementFromPoint(bounds.left + x!, bounds.top + y!)), + ); + }))).toBe(true); + } else { + expect(heading!.y + heading!.height).toBeLessThanOrEqual(cards[0]!.top); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + expect(await moduleCards.locator('.module-option-card-title, .module-option-card-description').evaluateAll(elements => + elements.every(element => element.scrollWidth <= element.clientWidth), + )).toBe(true); await page.screenshot({ path: test.info().outputPath(`module-entry-${width}.png`) }); } - await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.setSize(375, 800)); - await expect.poll(() => page.evaluate(() => innerWidth)).toBe(375); - const compactCards = await readCardMetrics(); - expect(Math.max(...compactCards.map(card => card.left)) - Math.min(...compactCards.map(card => card.left))).toBeLessThan(1); - for (const [index, card] of compactCards.entries()) { - if (index > 0) expect(card.top).toBeGreaterThan(compactCards[index - 1]!.top + compactCards[index - 1]!.height); - expect(card.imageWidth).toBeLessThan(card.width / 2); - expect(card.imageHeight).toBeGreaterThan(card.height * 0.75); - expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1); - } - expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); - expect(await moduleCards.locator('.module-option-card-title, .module-option-card-description').evaluateAll(elements => - elements.every(element => element.scrollWidth <= element.clientWidth), - )).toBe(true); - await page.screenshot({ path: test.info().outputPath('module-entry-375.png') }); - await moduleCards.first().hover(); await moduleCards.first().focus(); await expect.poll(async () => await moduleCards.first().evaluate((element) => { diff --git a/tests/unit/cloud-agents-page.test.tsx b/tests/unit/cloud-agents-page.test.tsx index bd0289b7..792e595f 100644 --- a/tests/unit/cloud-agents-page.test.tsx +++ b/tests/unit/cloud-agents-page.test.tsx @@ -575,9 +575,13 @@ it('restores the exact previous thread and presents an uncertain operation witho expect(screen.queryByLabelText('历史对话')).not.toBeInTheDocument(); expect(api.resolvePending).not.toHaveBeenCalled(); expect(api.call.mock.calls.some(call => call[0] === 'submit')).toBe(false); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '返回智能体列表', exact: true })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + await screen.findByRole('dialog', { name: '待处理记录' }); api.resolvePending.mockResolvedValue({ result: { request_id: 'same-request', status: 'queued' } }); api.recovery.mockResolvedValue({ recent: null, pending: [] }); - fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' })); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); await screen.findByText('发送消息的结果已确认。'); expect(api.resolvePending).toHaveBeenCalledWith('submit:pending', false); }); @@ -605,7 +609,8 @@ it('guides an unconfirmed WeChat verification back to its code entry without cla input: { slug: draft.slug, session_key: 'qr-session', operation_id: 'verify-operation' }, created_at: '2026-09-13T01:00:00Z' }] }); api.resolvePending.mockResolvedValue({ requires_input: true, operation: 'wechatBindVerification' }); open(); - fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + fireEvent.click(await screen.findByRole('button', { name: '再试一次' })); await screen.findByText(/这次微信验证还需要验证码/); expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument(); expect(api.resolvePending).toHaveBeenCalledWith('wechat-code', false); @@ -616,11 +621,12 @@ it('refreshes recovery after a definitive rejection without presenting a confirm api.recovery.mockResolvedValueOnce({ recent: null, pending: [pending] }).mockResolvedValue({ recent: null, pending: [] }); api.resolvePending.mockRejectedValue(new AppError('CONFIG', '名称不可用', undefined, { commandOutcome: 'definitive_failure' })); open(); - fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + fireEvent.click(await screen.findByRole('button', { name: /^创建记录/ })); + fireEvent.click(await screen.findByRole('button', { name: '再试一次' })); expect(await screen.findByRole('alert')).toHaveTextContent('创建智能体失败:名称不可用'); await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); - expect(screen.queryByRole('button', { name: '重试并确认结果' })).not.toBeInTheDocument(); - expect(screen.queryByText('创建智能体的结果已确认。')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument(); + expect(screen.queryByText(/创建成功,可以回到列表继续设置/)).not.toBeInTheDocument(); expect(screen.queryByText('查看结果')).not.toBeInTheDocument(); expect(api.resolvePending).toHaveBeenCalledWith(pending.id, false); expect(api.create).not.toHaveBeenCalled(); @@ -631,12 +637,13 @@ it('keeps an old recovery record retryable when a parameter response cannot dete api.recovery.mockResolvedValue({ recent: null, pending: [pending] }); api.resolvePending.mockRejectedValue(new AppError('CONFIG', '请检查名称', undefined, { commandOutcome: 'unknown', status: 422, code: 'invalid_input' })); open(); - fireEvent.click(await screen.findByRole('button', { name: '重试并确认结果' })); + fireEvent.click(await screen.findByRole('button', { name: /^创建记录/ })); + fireEvent.click(await screen.findByRole('button', { name: '再试一次' })); await screen.findByRole('alert'); - expect(screen.getByRole('button', { name: '重试并确认结果' })).toBeEnabled(); - expect(screen.queryByText('创建智能体的结果已确认。')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '再试一次' })).toBeEnabled(); + expect(screen.queryByText(/创建成功,可以回到列表继续设置/)).not.toBeInTheDocument(); expect(api.recovery).toHaveBeenCalledTimes(1); - fireEvent.click(screen.getByRole('button', { name: '重试并确认结果' })); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2)); expect(api.resolvePending.mock.calls).toEqual([[pending.id, false], [pending.id, false]]); expect(api.create).not.toHaveBeenCalled(); @@ -648,13 +655,84 @@ it('offers only explicit discard for a recovery record whose channel operation w .mockResolvedValue({ recent: null, pending: [] }); api.resolvePending.mockResolvedValue({ operation: 'channelPolicy', discarded: true, retired: true }); open(); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); expect(await screen.findByText(/旧版联系人权限操作(已移除)/)).toBeVisible(); - expect(screen.queryByRole('button', { name: '重试并确认结果' })).toBeNull(); + expect(screen.queryByRole('button', { name: '再试一次' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '丢弃旧记录' })); await screen.findByText(/旧版联系人权限操作已移除/); expect(api.resolvePending).toHaveBeenCalledWith('channel-policy-old', false); }); +it('keeps historic creation records out of the new form while showing the current creation error inline', async () => { + api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'create:old', operation: 'createAgent', + input: { operation_id: 'original-old', name: '之前的失败创建' }, created_at: '2026-10-13T03:00:00Z' }] }); + api.create.mockRejectedValue(new Error('本次创建失败,请重新检查')); + open(); + expect(await screen.findByRole('button', { name: /^创建记录/ })).toHaveTextContent('1'); + expect(screen.queryByRole('article', { name: '创建「之前的失败创建」' })).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '创建智能体', exact: true })); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: '创建记录' })).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '新的搭档' } }); + fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); + expect(await screen.findByRole('alert')).toHaveTextContent('本次创建失败,请重新检查'); + expect(screen.queryByRole('article', { name: '创建「之前的失败创建」' })).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('places the records entry only in the list header and preserves it when returning from configuration or chat', async () => { + publishedAssistant(4); + api.recovery.mockResolvedValue({ recent: null, pending: [{ id: 'create:historic', operation: 'createAgent', + input: { operation_id: 'same-historic', name: '旧记录搭档' }, created_at: '2026-10-13T03:00:00Z' }] }); + open(); + const trigger = await screen.findByRole('button', { name: /^创建记录/ }); + expect(trigger.closest('header')).not.toBeNull(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '调整', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + expect(await screen.findByRole('button', { name: /^创建记录/ })).toHaveTextContent('1'); + fireEvent.click(screen.getByRole('button', { name: '和它聊聊', exact: true })); + await screen.findByLabelText('消息'); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText(/有 1 个操作的结果尚未确认/)).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('retains an unclosed key through internal configuration navigation and clears it across account changes', async () => { + publishedAssistant(4); + const secret = 'configuration-navigation-key-fixture'; + const pending = { id: 'key:historical', operation: 'createKey', input: { application_id: 'fixture-application', operation_id: 'original-key' }, created_at: '2026-10-13T03:00:00Z' }; + api.recovery.mockResolvedValue({ recent: null, pending: [pending] }); + api.resolvePending.mockImplementation(async () => { + api.recovery.mockResolvedValue({ recent: null, pending: [] }); + return { result: { secret } }; + }); + const view = open(); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await screen.findByText(new RegExp(secret)); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + fireEvent.click(await screen.findByRole('button', { name: '调整', exact: true })); + await screen.findByTestId('cloud-agent-editor'); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: '待处理记录' })).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '我的智能体', exact: true })); + fireEvent.click(await screen.findByRole('button', { name: /^待处理记录/ })); + expect(await screen.findByText(new RegExp(secret))).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + auth.user.userId = 'different-records-account'; + view.rerender(); + await screen.findByRole('button', { name: '调整', exact: true }); + expect(screen.queryByRole('button', { name: /^待处理记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); +}); + it('freezes the uploaded avatar and creation identity when retrying a lost response', async () => { const avatar = { icon: '/api/storage/public/images/create.png', icon_url: 'https://images.test/create.png' }; api.list.mockResolvedValue({ agents: [], next_cursor: null }); @@ -730,15 +808,18 @@ it('preserves the creation identity for unknown command outcomes even when the e await screen.findByRole('button', { name: '更换头像' }); fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果'); - await screen.findByText('有 1 个操作的结果尚未确认'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(retainedPending).toHaveLength(1); + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); + expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument(); expect(screen.getByLabelText('名称')).toBeDisabled(); expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled(); expect(screen.getByRole('button', { name: '移除头像' })).toBeDisabled(); fireEvent.click(screen.getByRole('button', { name: '重试创建' })); await screen.findByTestId('cloud-agent-editor'); expect(api.create.mock.calls[1][0]).toEqual(api.create.mock.calls[0][0]); - await waitFor(() => expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument()); - expect(api.recovery).toHaveBeenCalledTimes(3); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(3)); + expect(await api.recovery.mock.results.at(-1)!.value).toEqual({ recent: null, pending: [] }); expect(api.uploadAvatar).toHaveBeenCalledTimes(1); }); @@ -747,11 +828,6 @@ it('keeps an uncertain creation identity after its reminder is removed and a ret let retainedPending: unknown[] = []; api.recovery.mockImplementation(async () => ({ recent: null, pending: retainedPending })); api.uploadAvatar.mockResolvedValue(avatar); - api.resolvePending.mockImplementation(async (_id, discard) => { - expect(discard).toBe(true); - retainedPending = []; - return { discarded: true }; - }); api.create.mockImplementationOnce(async input => { retainedPending = [{ id: 'create:discarded', operation: 'createAgent', input, created_at: '2026-09-13T01:00:00Z' }]; throw new AppError('NETWORK', '连接中断', undefined, { commandOutcome: 'unknown' }); @@ -763,12 +839,18 @@ it('keeps an uncertain creation identity after its reminder is removed and a ret fireEvent.click(screen.getByRole('button', { name: '上传头像' })); await screen.findByRole('button', { name: '更换头像' }); fireEvent.click(screen.getByRole('button', { name: '下一步', exact: true })); - await screen.findByText('有 1 个操作的结果尚未确认'); - fireEvent.click(screen.getByRole('button', { name: '移除提醒', exact: true })); - await waitFor(() => expect(screen.queryByText('有 1 个操作的结果尚未确认')).not.toBeInTheDocument()); - expect(api.resolvePending).toHaveBeenCalledWith('create:discarded', true); + await screen.findByRole('alert'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2)); + expect(retainedPending).toHaveLength(1); + // Clearing the journal independently must not change the still-mounted form's uncertainty. + // The records entry is now confined to the list, and explicit discard is tested there. + retainedPending = []; + expect(screen.queryByRole('button', { name: /^创建记录/ })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '重试创建', exact: true })); expect(await screen.findByRole('alert')).toHaveTextContent('创建结果暂未确认,可重试确认结果。参数被拒绝'); + await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(3)); + expect(await api.recovery.mock.results.at(-1)!.value).toEqual({ recent: null, pending: [] }); + expect(api.resolvePending).not.toHaveBeenCalled(); expect(screen.getByLabelText('名称')).toBeDisabled(); expect(screen.getByLabelText('名称')).toHaveValue('原搭档'); expect(screen.getByRole('button', { name: '更换头像' })).toBeDisabled(); diff --git a/tests/unit/cloud-recovery.test.tsx b/tests/unit/cloud-recovery.test.tsx new file mode 100644 index 00000000..02a7a56b --- /dev/null +++ b/tests/unit/cloud-recovery.test.tsx @@ -0,0 +1,209 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CloudRecovery } from '@/pages/CloudAgents/CloudRecovery'; +import { AppError } from '@/lib/error-model'; +import type { CloudPendingOperation } from '../../shared/cloud-agents'; + +const api = vi.hoisted(() => ({ resolvePending: vi.fn() })); +vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api })); +const first: CloudPendingOperation = { id: 'create:first', operation: 'createAgent', input: { operation_id: 'original-first', name: '故事搭档' }, created_at: '2026-10-13T03:54:49Z' }; +const second: CloudPendingOperation = { id: 'create:second', operation: 'createAgent', input: { operation_id: 'original-second', name: '阅读伙伴' }, created_at: '2026-10-13T03:55:18Z' }; +const key: CloudPendingOperation = { id: 'key:original', operation: 'createKey', input: { application_id: 'application', operation_id: 'original-key', name: '应用密钥' }, created_at: '2026-10-13T04:00:00Z' }; +const resolved = vi.fn(); +beforeEach(() => { vi.resetAllMocks(); }); +afterEach(cleanup); +async function openRecords() { + fireEvent.click(screen.getByRole('button', { name: /^(创建记录|待处理记录)/ })); + return await screen.findByRole('dialog'); +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} + +it('shows only a compact counted creation entry until explicitly opened, without retrying or discarding records', async () => { + render(); + expect(screen.getByRole('button', { name: /^创建记录/ })).toHaveTextContent('2'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('complementary', { name: '未确认的云端操作' })).not.toBeInTheDocument(); + expect(screen.queryByRole('article', { name: '创建「故事搭档」' })).not.toBeInTheDocument(); + expect(screen.queryByText(/有 2 个操作的结果尚未确认/)).not.toBeInTheDocument(); + const dialog = await openRecords(); + expect(within(dialog).getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + expect(within(dialog).getByRole('article', { name: '创建「阅读伙伴」' })).toBeVisible(); + expect(within(dialog).getAllByRole('button', { name: '再试一次', exact: true })).toHaveLength(2); + expect(within(dialog).getAllByRole('button', { name: '不再提醒', exact: true })).toHaveLength(2); + expect(api.resolvePending).not.toHaveBeenCalled(); expect(resolved).not.toHaveBeenCalled(); +}); + +it('uses a general records entry for mixed operations and keeps the original message visible only inside the dialog', async () => { + const message = { ...second, id: 'message:original', operation: 'submit', input: { query: '我的上次问题' } }; + render(); + expect(screen.getByRole('button', { name: /^待处理记录/ })).toHaveTextContent('2'); + expect(screen.queryByText('我的上次问题')).not.toBeInTheDocument(); + const dialog = await openRecords(); + expect(within(dialog).getByText('我的上次问题')).toBeVisible(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('keeps failed records and retries the same record ID after closing and reopening the dialog', async () => { + api.resolvePending.mockRejectedValueOnce(new Error('原操作仍未确认')).mockResolvedValueOnce({ result: { slug: 'original-agent' } }); + render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getByRole('button', { name: '再试一次' })); + await screen.findByRole('alert'); + expect(screen.getByRole('alert')).toHaveTextContent('原操作仍未确认'); + expect(resolved).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + const reopened = await openRecords(); + expect(within(reopened).getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + fireEvent.click(within(reopened).getByRole('button', { name: '再试一次' })); + await waitFor(() => expect(resolved).toHaveBeenCalledOnce()); + expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); +}); + +it('removes only the explicitly chosen local reminder and never implicitly retries it', async () => { + api.resolvePending.mockResolvedValue({ discarded: true }); + const view = render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getAllByRole('button', { name: '不再提醒' })[1]); + await waitFor(() => expect(resolved).toHaveBeenCalledOnce()); + expect(api.resolvePending.mock.calls).toEqual([[second.id, true]]); + view.rerender(); + expect(screen.getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + expect(screen.queryByRole('article', { name: '创建「阅读伙伴」' })).not.toBeInTheDocument(); +}); + +it('serializes double clicks and other actions and prevents dismissal until a request finishes', async () => { + const response = deferred<{ result: { slug: string } }>(); + api.resolvePending.mockReturnValue(response.promise); + render(); + const dialog = await openRecords(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); + const retry = within(dialog).getAllByRole('button', { name: '再试一次' })[0]; + act(() => { fireEvent.click(retry); fireEvent.click(retry); }); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeDisabled(); + for (const discard of within(dialog).getAllByRole('button', { name: '不再提醒' })) expect(discard).toBeDisabled(); + fireEvent.keyDown(dialog, { key: 'Escape' }); + fireEvent.pointerDown(document.body, { button: 0, pointerType: 'mouse' }); + expect(screen.getByRole('dialog')).toBeVisible(); + expect(resolved).not.toHaveBeenCalled(); + await act(async () => response.resolve({ result: { slug: 'original-agent' } })); + expect(resolved).toHaveBeenCalledOnce(); + expect(within(dialog).getByRole('button', { name: '关闭记录' })).toBeEnabled(); +}); + +it('keeps a returned key available after modal close and reopen until the result is explicitly closed', async () => { + const secret = 'fixture-key-for-unit-only'; + api.resolvePending.mockResolvedValue({ result: { secret, id: 'created-key' } }); + const view = render(); + const dialog = await openRecords(); + fireEvent.click(within(dialog).getAllByRole('button', { name: '再试一次' })[0]); + await screen.findByText(new RegExp(secret)); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + await openRecords(); + expect(screen.getByText(new RegExp(secret))).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + await openRecords(); + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); +}); + +it('keeps a result entry available when the final pending record was acknowledged', async () => { + api.resolvePending.mockResolvedValue({ result: { secret: 'last-key-fixture' } }); + const view = render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await screen.findByText(/last-key-fixture/); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + await openRecords(); expect(screen.getByText(/last-key-fixture/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(screen.queryByText(/last-key-fixture/)).not.toBeInTheDocument(); +}); + +it('guides verification requiring fresh input without claiming that verification completed', async () => { + const verification = { ...first, id: 'wechat:verification', operation: 'wechatBindVerification', input: { session_key: 'fixture-session' } }; + api.resolvePending.mockResolvedValue({ requires_input: true, operation: verification.operation }); + render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByText(/还需要验证码/)).toBeVisible(); + expect(screen.queryByText('微信连接验证的结果已确认。')).not.toBeInTheDocument(); + expect(api.resolvePending).toHaveBeenCalledWith(verification.id, false); +}); + +it('offers only explicit discard for retired operations and explains the retired result without auto actions', async () => { + const retired = { ...first, id: 'retired:policy', operation: 'channelPolicy', input: {} }; + api.resolvePending.mockResolvedValue({ retired: true, discarded: true, operation: retired.operation }); + render(); + expect(api.resolvePending).not.toHaveBeenCalled(); + await openRecords(); + expect(screen.queryByRole('button', { name: '再试一次' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '丢弃旧记录' })); + expect(await screen.findByText(/旧版联系人权限操作已移除/)).toBeVisible(); + expect(api.resolvePending).toHaveBeenCalledWith(retired.id, false); +}); + +it('renders no entry or dialog when there are no pending records or retained results', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(api.resolvePending).not.toHaveBeenCalled(); +}); + +it('does not let another action replace an unclosed key result, and unlocks actions after explicit result closure', async () => { + const verification = { ...second, operation: 'wechatBindVerification' }; + api.resolvePending.mockImplementation(async id => id === key.id + ? { result: { secret: 'protected-key-fixture' } } : { requires_input: true, operation: verification.operation }); + const view = render(); + await openRecords(); + fireEvent.click(within(screen.getByRole('article', { name: '创建密钥' })).getByRole('button', { name: '再试一次' })); + await screen.findByText(/protected-key-fixture/); + view.rerender(); + const row = screen.getByRole('article', { name: '微信连接验证' }); + const retry = within(row).getByRole('button', { name: '再试一次' }); + const discard = within(row).getByRole('button', { name: '不再提醒' }); + expect(retry).toBeDisabled(); expect(discard).toBeDisabled(); + fireEvent.click(retry); fireEvent.click(discard); + expect(api.resolvePending).toHaveBeenCalledTimes(1); + expect(screen.getByText(/protected-key-fixture/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '关闭结果' })); + expect(retry).toBeEnabled(); + fireEvent.click(retry); + await screen.findByText(/还需要验证码/); + expect(api.resolvePending.mock.calls).toEqual([[key.id, false], [verification.id, false]]); +}); + +it('refreshes pending records after a definite rejection while keeping the error inside the explicit dialog', async () => { + api.resolvePending.mockRejectedValue(new AppError('CONFIG', '这条创建已被拒绝', undefined, { commandOutcome: 'definitive_failure' })); + const view = render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByRole('alert')).toHaveTextContent('这条创建已被拒绝'); + expect(resolved).toHaveBeenCalledOnce(); + expect(api.resolvePending).toHaveBeenCalledWith(first.id, false); + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: '关闭记录' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + await openRecords(); + expect(screen.queryByRole('article')).not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('这条创建已被拒绝'); +}); + +it('keeps an explicitly unknown command outcome recoverable without refreshing it as a definite failure', async () => { + api.resolvePending.mockRejectedValue(new AppError('NETWORK', '请求结果仍未知', undefined, { commandOutcome: 'unknown' })); + render(); + await openRecords(); fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + expect(await screen.findByRole('alert')).toHaveTextContent('请求结果仍未知'); + expect(resolved).not.toHaveBeenCalled(); + expect(screen.getByRole('article', { name: '创建「故事搭档」' })).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '再试一次' })); + await waitFor(() => expect(api.resolvePending).toHaveBeenCalledTimes(2)); + expect(api.resolvePending.mock.calls).toEqual([[first.id, false], [first.id, false]]); +}); From aa771d97a87a444ce92cd59d5db2aea550996c18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 13:32:46 +0800 Subject: [PATCH 08/15] docs(trial): record verified layout and safe refresh hold --- .../tasks/20261013-combined-trial-update-c42d19a7.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md index c77e813a..4bbd95e0 100644 --- a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -27,7 +27,10 @@ - Verification plan: focused navigation/account/wallet and recovery units, typecheck, scoped lint, complete build, Electron geometry and account-footer scenarios; inspect 1440/1024/768/375 screenshots. Refresh Renderer only after full Main/preload/resource/package comparison and live clean-state check; verify geometry and restore the original route/session/focus. - Implementation complete: desktop two-column grid, three horizontal image-left cards stacked with equal heights/gaps; h1 is the left alignment reference and the welcome line sits above it. Narrow widths retain the title-above stack, account footer remains reachable, Robot stays hidden. No other product differences against current deployed 7d4a247 beyond ModuleSelection/globals and README. - Verification completed before staging: 5 focused unit files / 77 tests passed; typecheck, seven-file scoped ESLint and complete build:vite passed. Four UI scenarios passed (Electron homepage geometry/account controls and browser recovery at 1440/375). Geometry at 1440/1024/768/375 checks equal card height/gap, h1/Code center error under 1px at desktop, horizontal image/text, full hit areas and no horizontal overflow. All four layout screenshots and the account menu screenshot visually inspected. No unrelated historical Code test rerun/weakening. -- Runtime update pending: refreshed current source is Renderer 7d4a247 over unchanged Main 7c51bb4; active pointer carries stale 5f effective fields from the peer overlay, so the next receipt must bind exact active Renderer/source-tree evidence and correct effective-source metadata. +- Source committed as 383ce71fafaa64e55509d35c3a2d8c6185afabb4. Independent committed product comparison against current deployed 7d4a247 confirms only README, ModuleSelection, globals and its navigation test differ. Main/shared/resources/dependencies and retained recovery product/tests are identical. +- Root independently verified all 8 dist-electron files, 57 resources and package.json against the original running Main snapshot. New durable Renderer snapshot prepared at `/Users/chillishark/Makelore 麦洛/.trial-runtime/home-layout-20261013-c42d19a7`; prepare.py --verify independently passes, bound to exact 383ce71 and current predecessor 7d4a247 over unchanged Main 7c51bb4. Parent snapshots preserved; next activation corrects the predecessor pointer's stale 5f effective fields. +- Actual activation held safely: fresh preflight found enabled save button, fixed status “有未保存修改” and active beforeunload protection, no running task/dialog/pending file/lease. No compatibility-link/current-pointer change, navigation, reload, user save/discard or profile access occurred; Main PID 9968 / port 58003 and login preserved, inspector closed. Audit: held-receipt.json, preserved initial attempt, save-status-receipt.json and helper-rebind-receipt.json. The stage checker excludes only proven-saved name/select configuration fields while retaining real dirty/leave guards. +- User said saved and requested continuing; fresh state still shows unsaved configuration, without a saving state or visible error. Do not infer whether save was triggered or edits followed. Asked user to wait for the fixed “已保存” state or report a save failure. Source/layout work verified; live activation remains pending the application becoming safe to refresh. ## Homepage Account Follow-up · 2026-10-13 From 886b4589a199494fb3b4dddebd664602bafb227d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 13:39:44 +0800 Subject: [PATCH 09/15] docs(trial): verify stacked homepage layout deployment --- .../20261013-combined-trial-update-c42d19a7.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md index 4bbd95e0..503800f4 100644 --- a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-combined-trial-update-c42d19a7 - Base commit: 8289f27f3e53ef455b656f1c43d2620d235d5014 - Owner: codex -- Status: Planning +- Status: Ready for Integration ## Scope @@ -29,8 +29,12 @@ - Verification completed before staging: 5 focused unit files / 77 tests passed; typecheck, seven-file scoped ESLint and complete build:vite passed. Four UI scenarios passed (Electron homepage geometry/account controls and browser recovery at 1440/375). Geometry at 1440/1024/768/375 checks equal card height/gap, h1/Code center error under 1px at desktop, horizontal image/text, full hit areas and no horizontal overflow. All four layout screenshots and the account menu screenshot visually inspected. No unrelated historical Code test rerun/weakening. - Source committed as 383ce71fafaa64e55509d35c3a2d8c6185afabb4. Independent committed product comparison against current deployed 7d4a247 confirms only README, ModuleSelection, globals and its navigation test differ. Main/shared/resources/dependencies and retained recovery product/tests are identical. - Root independently verified all 8 dist-electron files, 57 resources and package.json against the original running Main snapshot. New durable Renderer snapshot prepared at `/Users/chillishark/Makelore 麦洛/.trial-runtime/home-layout-20261013-c42d19a7`; prepare.py --verify independently passes, bound to exact 383ce71 and current predecessor 7d4a247 over unchanged Main 7c51bb4. Parent snapshots preserved; next activation corrects the predecessor pointer's stale 5f effective fields. -- Actual activation held safely: fresh preflight found enabled save button, fixed status “有未保存修改” and active beforeunload protection, no running task/dialog/pending file/lease. No compatibility-link/current-pointer change, navigation, reload, user save/discard or profile access occurred; Main PID 9968 / port 58003 and login preserved, inspector closed. Audit: held-receipt.json, preserved initial attempt, save-status-receipt.json and helper-rebind-receipt.json. The stage checker excludes only proven-saved name/select configuration fields while retaining real dirty/leave guards. -- User said saved and requested continuing; fresh state still shows unsaved configuration, without a saving state or visible error. Do not infer whether save was triggered or edits followed. Asked user to wait for the fixed “已保存” state or report a save failure. Source/layout work verified; live activation remains pending the application becoming safe to refresh. +- Initial activation held safely: fresh preflight found enabled save button, fixed status “有未保存修改” and active beforeunload protection, no running task/dialog/pending file/lease. No compatibility-link/current-pointer change, navigation, reload, user save/discard or profile access occurred; Main PID 9968 / port 58003 and login preserved, inspector closed. Audit: held-receipt.json, preserved initial attempt, save-status-receipt.json and helper-rebind-receipt.json. The stage checker excludes only proven-saved name/select configuration fields while retaining real dirty/leave guards. +- User said saved and requested continuing; first recheck still showed unsaved configuration without saving state or visible error. Did not infer whether save was triggered or edits followed. Asked user to wait for the fixed “已保存” state. A later fresh preflight confirmed saved configuration: save disabled, no dirty/beforeunload/input/lease; excluded only proven-saved name/select controls. +- First clean refresh completed geometry and route restoration but the helper incorrectly required unauthenticated /health to return 200. It safely rolled back to predecessor 7d4a247; fresh proof verified original route/profile/assets/login and clean state. Corrected only the task-scoped helper to the existing /api/health contract (200/401), retained fixed-error and rollback receipts, and rebound helper SHA with parent/predecessor proof. No product source changed or tests weakened. +- Final Renderer-only activation succeeded from exact product 383ce71fafaa64e55509d35c3a2d8c6185afabb4. Main source 7c51bb4 / PID 9968 / port 58003 unchanged; current pointer now accurately sets both Renderer and effective source to 383ce71 and retains current recovery 7d4a247 product. New overlay snapshot is independent; predecessor and original Main artifacts remain immutable. +- Actual 1470px window geometry verified: three equal-height/equal-gap horizontal cards stacked vertically, height/gap spread 0, title-to-Code center delta 0.00390625px. No Robot; account trigger/footer present. Original full CloudAgents hash/query and stable URL/profile restored, no login form/alert, fresh foreground PID 73871 unchanged, inspector closed, unauthenticated /api/health 401 accepted as expected. No account/profile/recharge/save/discard/chat actions by the updater. +- Root independently ran prepare.py --verify (active-verified) and checked current/manifest/receipt source consistency, immutable artifact proof, Main PID, live geometry, route/focus/session evidence and closed inspector. Durable final proof: home-layout stage overlay-manifest.json, verification-receipt.json and retained held/rollback/helper-rebind records. Final handoff documentation commits do not change built product bytes. ## Homepage Account Follow-up · 2026-10-13 @@ -76,6 +80,7 @@ ## Outcome +- Latest homepage follow-up completed and deployed: left title / right three-card vertical layout, Code aligned with title center, equal card heights/gaps; Robot hidden, account controls and latest recovery reminders retained. Current Renderer/effective source 383ce71 over unchanged Main 7c51bb4; see the latest follow-up and independent live receipt above. - Product assembly and temporary-application update complete: retained base homepage/channel changes, applied bf39f40 product delta, knowledge c634425 and creation 21852fb without modifying their source task/canonical records. - 22 changed product files match exact accepted source commit blobs; README combines all current behaviors. The only test conflict combined the knowledge static harness with main's normalized cross-platform import paths. - Built product commit: 7c51bb4a008e388071ec37156adeae80eff24167. Runtime is a durable snapshot under `/Users/chillishark/Makelore 麦洛/.trial-runtime/combined-update-20261013-c42d19a7`; current.json points to its Makelore Combined Trial.app, Main PID 9968 / Host API 58003. Both Main and Renderer were replaced; no claim of preserving the former Main process. @@ -105,3 +110,5 @@ - Current-state / product snapshot: approved Robot hiding, channel prerequisite hint, knowledge two-pane management/chat-file batch picker, and definitive-create rejection versus uncertain-result recovery. Evidence is accepted source commits, exact product-blob comparison, combined tests and verified runtime receipts. Future impact: next application build and source integration must preserve all five source groups; no conflicting product decision or additional human confirmation required within the authorized update scope. - Runtime evidence index: current temporary application uses full Main+Renderer snapshot 7c51bb4 and standard same-account session restoration, independent of its source checkout; first-attempt rollback and OS-constrained bounds must remain distinguished from exact window restoration. Promote only during canonical integration; do not treat this as production backend/paid-model acceptance. - Current-state / success criteria: the module homepage now exposes the same existing balance/recharge/profile/settings/logout functions as the module sidebars, with keyboard/close behavior, account isolation and persistent wallet lifecycle. Evidence: source 5f7c851, focused tests/fixture screenshots and verified live renderer overlay; no new billing/auth contract or contradictory decision. + +- Current-state / homepage layout: user explicitly replaces the desktop side-by-side cards with left title and right vertical image-left card stack. Code center aligns with h1 center; smaller windows place title above the stack. Evidence: source 383ce71, four-width geometry/screenshots and actual 1470px live proof. Account controls, Robot hiding and module permissions retained; no unresolved semantic conflict or further confirmation required. Promote only in a separate integration gate. From a9fa221d7a8c9142b04ea20940b4a880fdca7697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 13:55:24 +0800 Subject: [PATCH 10/15] fix(home): reposition title and compact account entry --- ...20261013-combined-trial-update-c42d19a7.md | 13 +++++- README.md | 2 +- src/components/account/HomeAccountMenu.tsx | 15 ++++--- src/pages/ModuleSelection/index.tsx | 2 +- src/styles/globals.css | 17 +++++++ tests/e2e/main-navigation.spec.ts | 44 +++++++++++++++++-- 6 files changed, 80 insertions(+), 13 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md index 503800f4..300a3899 100644 --- a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -8,7 +8,7 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-combined-trial-update-c42d19a7 - Base commit: 8289f27f3e53ef455b656f1c43d2620d235d5014 - Owner: codex -- Status: Ready for Integration +- Status: Planning ## Scope @@ -17,6 +17,17 @@ - Follow-up: restore access to the existing account controls on the standalone module homepage and update the current trial. - Latest follow-up: restore the desktop title-left / three-card vertical layout with the Code card centered on the title; retain Robot hiding and account controls. +## Homepage Position / Compact Account Follow-up · 2026-10-13 + +- Concurrent / Planning Gates: Passed on same-task resume, exact owner/task/worktree/branch/feature/base verified after successful start and structure check. Reread current task/source; retain unchanged startup/positioning/decisions/architecture/domain/evidence/reflection/commitment context. Peer delta review found only compatible un-deployed knowledge and Tool/Skill copy changes, outside this scope; no new homepage dependency and no automatic import. Latest current runtime remains Renderer/effective 383ce71 over Main 7c51bb4, PID 9968 / port 58003. +- User requests title “网友移动1.5cm”, interpreted and communicated as the homophone typo for “往右移动1.5cm”, and the pictured account entry at half its current visual size, moved left 1.5cm toward the lower-left corner. Preserve three-card equal geometry and Code/title vertical alignment, Robot hiding, account menu behavior and current recovery source. +- Plan: move the desktop intro right by CSS 1.5cm (~56.7px), measure the actual h1 text box rather than its full grid column; narrow widths retain centered heading to avoid overflow. Render the trigger surface at half scale in an unchanged 50px keyboard/click footprint; menu/wallet remain unscaled. Move the account controls left up to 1.5cm with a 16px window inset limit and down to a 16px bottom inset; retain the footer flow reservation and normal scrolling. +- Verification: focused account/module/wallet units, typecheck/lint/build, existing Electron account/layout scenarios plus visual geometry assertions at four widths and screenshots. Compare before/after live title and trigger rectangles in Renderer-only update, with complete Main/resource/package byte proof and current-pointer binding. Fresh saved-state guards remain; do not save/discard for the user or perform real account/payment/Agent operations. +- Source implemented and frozen: three product files only (ModuleSelection, globals, HomeAccountMenu), README and focused geometry assertions. Existing handlers, menus/dialog lifecycle, module permissions, Code alignment/card stack and Main are unchanged. +- Verification complete before activation: 25 focused units / 3 files, typecheck, scoped source/test ESLint and full build passed. Two Electron scenarios passed, with title displacement, actual text-range width, half-size surface/avatar, unchanged click footprint, full-size menu/gap, 16px bottom inset, desktop left shift/mobile screen safety and prior navigation/menu/wallet behavior. Layout measured at 1440/1024/768/375; desktop and mobile account screenshots plus 1024/375 layout screenshots visually inspected. Independent Main proof: dist-electron 8 files/resources 57 files/package exact original 7c snapshot. +- Renderer-only update prepared next, current-pointer baseline remains active 383ce71; source commit and final live receipt to be recorded after clean-state activation. + + ## Homepage Vertical Layout Follow-up · 2026-10-13 - Concurrent / Planning Gates: Passed on same-task resume; successful start and matching feature / task / branch / absolute worktree / base ownership verified. Retain unchanged startup, positioning, architecture, domain, accepted decisions, evidence/reflection/commitment context from the previous gate; reread the active record, startup, planning gate and local AGENTS. Source task records stay read-only; primary checkout remains untouched. diff --git a/README.md b/README.md index 07088222..0ab9fc7b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。首页左下角保留账户入口,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。首页左下角保留半尺寸的账户入口,向左偏移约 1.5cm(小窗口保留边距)并靠近底部;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 diff --git a/src/components/account/HomeAccountMenu.tsx b/src/components/account/HomeAccountMenu.tsx index ddcfda3f..edc0b1ba 100644 --- a/src/components/account/HomeAccountMenu.tsx +++ b/src/components/account/HomeAccountMenu.tsx @@ -48,23 +48,26 @@ export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { if (!user) return null; - return
+ return
{ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index e37f12a7..9d39cd6e 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -116,7 +116,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea })}
- {authenticated && authUser &&
+ {authenticated && authUser &&
{ setDismissedSyncError(null); setProfileDialogOpen(true); diff --git a/src/styles/globals.css b/src/styles/globals.css index b82704fe..f783f0d5 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -424,10 +424,25 @@ } .module-selection-title { + width: fit-content; + margin-inline: auto; margin-top: 0.75rem; font-size: clamp(2rem, 3.3vw, 3.75rem); } +.module-selection-account [data-testid='home-account-controls'] { + transform: translateY(1rem); +} + +@media (min-width: 640px) { + .module-selection-account [data-testid='home-account-controls'] { + transform: translate( + max(-1.5cm, calc(1rem - max(2rem, calc((100vw - 80rem) / 2)))), + 2rem + ); + } +} + .module-option-card { position: relative; z-index: 0; @@ -547,6 +562,7 @@ .module-selection-intro { text-align: left; + transform: translateX(1.5cm); } .module-selection-welcome { @@ -556,6 +572,7 @@ } .module-selection-title { + margin-inline: 0; margin-top: 0; } diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index 74ccff8a..608a3c83 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -100,6 +100,27 @@ test.describe('Makelore module navigation without setup flow', () => { await page.goto(applicationUrl); const trigger = page.getByTestId('home-account-menu-trigger'); const menu = page.getByTestId('home-account-menu'); + const surface = page.getByTestId('home-account-trigger-surface'); + const checkAccountGeometry = async (width: number) => { + await expect.poll(async () => (await menu.boundingBox())!.width).toBeCloseTo(288, 1); + const visible = (await surface.boundingBox())!; + const target = (await trigger.boundingBox())!; + const avatar = (await surface.locator('span').first().boundingBox())!; + const panel = (await menu.boundingBox())!; + expect(visible.width).toBeCloseTo(128, 1); + expect(visible.height).toBeCloseTo(25, 1); + expect(avatar.width).toBeCloseTo(16, 1); + expect(avatar.height).toBeCloseTo(16, 1); + expect(target.width).toBeCloseTo(128, 1); + expect(target.height).toBeCloseTo(50, 1); + expect(visible.x).toBeGreaterThanOrEqual(16); + expect(visible.x).toBeCloseTo(width === 1440 ? 80 - 1.5 * 96 / 2.54 : 16, 1); + expect(await page.evaluate(() => innerHeight) - visible.y - visible.height).toBeCloseTo(16, 0); + expect(panel.x).toBeCloseTo(visible.x, 1); + expect(visible.y - panel.y - panel.height).toBeCloseTo(8, 1); + expect(panel.y).toBeGreaterThanOrEqual(0); + expect(panel.x + panel.width).toBeLessThanOrEqual(width); + }; await expect(trigger).toContainText('小麦'); await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); const footer = await trigger.evaluate(element => element.closest('footer')!.getBoundingClientRect().top); @@ -109,6 +130,7 @@ test.describe('Makelore module navigation without setup flow', () => { await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点'); await expect(menu.getByText('永久有效 · 预占 5 点', { exact: true })).toBeVisible(); await expect(menu.getByRole('menuitem', { name: '词元点数与充值', exact: true })).toBeVisible(); + await checkAccountGeometry(1440); await page.screenshot({ path: testInfo.outputPath('home-account-menu-desktop.png') }); await page.keyboard.press('Escape'); await expect(trigger).toHaveAttribute('aria-expanded', 'false'); @@ -145,9 +167,7 @@ test.describe('Makelore module navigation without setup flow', () => { await trigger.click(); await expect(menu.getByTestId('sidebar-total-token-points')).toHaveText('75 点'); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); - const menuBox = await menu.boundingBox(); - expect(menuBox!.x).toBeGreaterThanOrEqual(0); - expect(menuBox!.x + menuBox!.width).toBeLessThanOrEqual(375); + await checkAccountGeometry(375); await page.screenshot({ path: testInfo.outputPath('home-account-menu-375.png') }); await menu.getByRole('menuitem', { name: '退出登录', exact: true }).click(); await expect(page).toHaveURL(/\/login$/); @@ -371,9 +391,25 @@ test.describe('Makelore module navigation without setup flow', () => { expect(card.imageHeight).toBeGreaterThan(card.height * 0.75); expect(card.contentLeft).toBeGreaterThanOrEqual(card.imageLeft + card.imageWidth - 1); } - const heading = await page.getByRole('heading', { name: '今天,想创作什么?', exact: true }).boundingBox(); + const title = page.getByRole('heading', { name: '今天,想创作什么?', exact: true }); + const heading = await title.boundingBox(); expect(heading).not.toBeNull(); + const textWidth = await title.evaluate(element => { + const range = document.createRange(); range.selectNodeContents(element); + return range.getBoundingClientRect().width; + }); + expect(Math.abs(heading!.width - textWidth)).toBeLessThan(1); if (width >= 1024) { + const shift = await title.evaluate(element => { + const intro = element.parentElement!; + const original = intro.style.transform; + const shiftedLeft = element.getBoundingClientRect().left; + try { + intro.style.transform = 'none'; + return shiftedLeft - element.getBoundingClientRect().left; + } finally { intro.style.transform = original; } + }); + expect(shift).toBeCloseTo(1.5 * 96 / 2.54, 1); expect(heading!.x + heading!.width).toBeLessThanOrEqual(cards[0]!.left); expect(Math.abs(heading!.y + heading!.height / 2 - cards[1]!.top - cards[1]!.height / 2)).toBeLessThan(1); expect(await moduleCards.evaluateAll(elements => elements.every(element => { From 430233f825cf97d3e7816f8271b602edf0e1401c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Tue, 13 Oct 2026 14:17:54 +0800 Subject: [PATCH 11/15] fix(home): narrow account button without shrinking its contents --- .../tasks/20261013-combined-trial-update-c42d19a7.md | 6 +++++- README.md | 2 +- src/components/account/HomeAccountMenu.tsx | 4 ++-- tests/e2e/main-navigation.spec.ts | 12 +++++++++--- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md index 300a3899..c52efadd 100644 --- a/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md +++ b/.project-docs/30-worklog/tasks/20261013-combined-trial-update-c42d19a7.md @@ -25,7 +25,11 @@ - Verification: focused account/module/wallet units, typecheck/lint/build, existing Electron account/layout scenarios plus visual geometry assertions at four widths and screenshots. Compare before/after live title and trigger rectangles in Renderer-only update, with complete Main/resource/package byte proof and current-pointer binding. Fresh saved-state guards remain; do not save/discard for the user or perform real account/payment/Agent operations. - Source implemented and frozen: three product files only (ModuleSelection, globals, HomeAccountMenu), README and focused geometry assertions. Existing handlers, menus/dialog lifecycle, module permissions, Code alignment/card stack and Main are unchanged. - Verification complete before activation: 25 focused units / 3 files, typecheck, scoped source/test ESLint and full build passed. Two Electron scenarios passed, with title displacement, actual text-range width, half-size surface/avatar, unchanged click footprint, full-size menu/gap, 16px bottom inset, desktop left shift/mobile screen safety and prior navigation/menu/wallet behavior. Layout measured at 1440/1024/768/375; desktop and mobile account screenshots plus 1024/375 layout screenshots visually inspected. Independent Main proof: dist-electron 8 files/resources 57 files/package exact original 7c snapshot. -- Renderer-only update prepared next, current-pointer baseline remains active 383ce71; source commit and final live receipt to be recorded after clean-state activation. +- Initial implementation a9fa221d7a8c9142b04ea20940b4a880fdca7697 was deployed successfully from 383ce71, with independent regular snapshot home-position-20261013-c42d19a7; Main/session/home route/focus retained. Live title shift 56.69291687px and Code/card geometry unchanged. Account visual was 128x25 with 16px bottom inset; immutable receipts preserve that intermediate result. +- User immediately clarified the account requirement: only width becomes half, while avatar, text and button height stay the original size. This clarification supersedes the initial full-surface-scale interpretation. Keep title/left/bottom positions, restore 50px visual height, 32px avatar, 14px text and 16px arrow, use 128px width with compact horizontal gaps/padding, and keep the full menu 8px above the visible trigger. Do not scale the surface. Latest implementation changes only HomeAccountMenu, README and corresponding test/doc measurements; Main and other product code stay as verified. +- Width-only correction verified: 25 focused units / 3 files, typecheck, scoped component/test lint and complete build passed. Layout Electron scenario passed at four widths; account scenario passed with 128x50 surface, 32px avatar, 14px font, 16px arrow, full-size menu and 8px gap, existing wallet/profile/settings/logout fixture behavior and unchanged 16px bottom/safe-left inset. Desktop/mobile account screenshots visually inspected; no user account operation in these fixtures. +- Initial new arrow check sampled the inherited rotation transition at 16.345px; now waits for the final 16px rectangle without relaxing the assertion or changing product animation. One isolated retry did not mount the fake account; a subsequent unchanged run passed. No authentication implementation changed; retain this fixture-startup observation for a separate investigation if it repeats. Real-app login/trigger will be independently verified during deployment. +- Root independently rechecked complete Main/resources/package byte equality after corrected build. Final correction is ready for Renderer-only activation against active a9fa221; source and receipt recorded next. No additional approval needed; user explicitly requested the correction. ## Homepage Vertical Layout Follow-up · 2026-10-13 diff --git a/README.md b/README.md index 0ab9fc7b..e9d74e81 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用户可在原会话中切换,恢复时使用新选择并保留会话与历史消息。切换不会自动重发消息或自动选择替代模型。 -Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。首页左下角保留半尺寸的账户入口,向左偏移约 1.5cm(小窗口保留边距)并靠近底部;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。首页左下角保留半宽的账户入口,头像、文字与按钮高度保持原尺寸,向左偏移约 1.5cm(小窗口保留边距)并靠近底部;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。 diff --git a/src/components/account/HomeAccountMenu.tsx b/src/components/account/HomeAccountMenu.tsx index edc0b1ba..be425314 100644 --- a/src/components/account/HomeAccountMenu.tsx +++ b/src/components/account/HomeAccountMenu.tsx @@ -59,7 +59,7 @@ export function HomeAccountMenu({ accountName, avatarUrl, onOpenProfile }: { } }}> + className="pointer-events-none absolute bottom-0 left-0 flex min-h-12 w-32 items-center gap-2 rounded-2xl border border-border/80 bg-background px-2 py-2 text-sm font-medium shadow-sm group-hover:bg-surface-subtle"> {accountName}
+ {isCanvasWorkspace &&
}
); } diff --git a/src/components/layout/SettingsLayout.tsx b/src/components/layout/SettingsLayout.tsx new file mode 100644 index 00000000..16daa8e7 --- /dev/null +++ b/src/components/layout/SettingsLayout.tsx @@ -0,0 +1,11 @@ +import { Outlet } from 'react-router-dom'; +import { TitleBar } from './TitleBar'; +import { StandaloneAccountDock } from '@/components/account/StandaloneAccountDock'; + +export function SettingsLayout() { + return
+ +
+ +
; +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index dcd14316..a4cdc38c 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -36,6 +36,8 @@ import { subscribeCodingProjectOpenRequest, } from '@/lib/coding-project-entry'; import { PointWallet } from '@/components/account/PointWallet'; +import { AccountMenuTrigger } from '@/components/account/AccountMenuTrigger'; +import { canLeaveCurrentPage, getSettingsReturnState } from '@/lib/settings-navigation'; import { cn } from '@/lib/utils'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import type { SidebarPeekSource } from './sidebar-peek'; @@ -361,8 +363,9 @@ export function Sidebar({ }; const navigateFromAccountMenu = (path: string) => { + if (path === '/settings' && !canLeaveCurrentPage()) { toast.info('请先保存当前修改,再打开设置。'); return; } setAccountMenuOpen(false); - navigate(path); + navigate(path, path === '/settings' ? { state: getSettingsReturnState(location) } : undefined); }; const openProfileDialog = () => { @@ -810,42 +813,26 @@ export function Sidebar({ onOpenChange={setProfileDialogOpen} /> -
+
-
- +
+
+ setAccountMenuOpen((open) => !open)} + /> +
void handleClick()} className={cn( - 'motion-press flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-accent/30 bg-accent text-primary-foreground shadow-soft hover:bg-accent-strong disabled:cursor-wait disabled:opacity-80', + 'motion-press flex h-10 w-10 max-w-full shrink-0 items-center justify-center rounded-full border border-accent/30 bg-accent text-primary-foreground shadow-soft hover:bg-accent-strong disabled:cursor-wait disabled:opacity-80', collapsed && 'shadow-none', )} > diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 00a8ad64..043df9b1 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -212,6 +212,7 @@ function ProductTitleBar({ data-testid="titlebar-main-surface" className={cn( 'relative flex min-w-0 flex-1 bg-background', + nativeTrafficLights && !nativeFullscreen && !showSidebarControls && !overlay && 'pl-[88px]', overlay && 'pointer-events-none border-b-0 bg-transparent', nativeTrafficLights && 'drag-region', !overlay && (workspaceLayout || sidebarTitlebarHidden ? 'border-b-0' : 'border-b border-border/80'), diff --git a/src/lib/settings-navigation.ts b/src/lib/settings-navigation.ts new file mode 100644 index 00000000..cd54837b --- /dev/null +++ b/src/lib/settings-navigation.ts @@ -0,0 +1,30 @@ +import { AI_MODULE_SELECTION_PATH, getGuardedAiModuleForPath } from './ai-modules'; + +type SettingsLocation = { pathname: string; search?: string; hash?: string; state?: unknown }; + +function localReturnPath(value: unknown): string | null { + if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || value.includes('\\')) return null; + try { + const target = new URL(value, 'https://makelore.local'); + if (target.origin !== 'https://makelore.local') return null; + if (target.pathname !== AI_MODULE_SELECTION_PATH && !getGuardedAiModuleForPath(target.pathname)) return null; + return `${target.pathname}${target.search}${target.hash}`; + } catch { return null; } +} + +export function getSettingsReturnPath(state: unknown): string { + const candidate = state && typeof state === 'object' && 'settingsReturnTo' in state + ? (state as { settingsReturnTo?: unknown }).settingsReturnTo : null; + return localReturnPath(candidate) ?? AI_MODULE_SELECTION_PATH; +} + +export function getSettingsReturnState(location: SettingsLocation): { settingsReturnTo: string } { + const inSettings = location.pathname === '/settings' || location.pathname.startsWith('/settings/'); + return { settingsReturnTo: inSettings ? getSettingsReturnPath(location.state) + : localReturnPath(`${location.pathname}${location.search ?? ''}${location.hash ?? ''}`) ?? AI_MODULE_SELECTION_PATH }; +} + +/** Honor the current page's existing unsaved-input protection before internal navigation. */ +export function canLeaveCurrentPage(): boolean { + return typeof window === 'undefined' || window.dispatchEvent(new Event('beforeunload', { cancelable: true })); +} diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index ec6e0d00..a562e52c 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -36,8 +36,7 @@ [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row[data-selected="true"] { background: #e0ecfa; border-color: #cedef0; color: #304f75; } [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child { min-height: 35px; gap: 7px; } [data-testid="sidebar"][data-classroom="true"] .classroom-conversation-row > button:first-child > svg { color: #7891af; } -[data-testid="sidebar"][data-classroom="true"] .classroom-account-footer { padding: 10px 10px 12px; border-top: 1px solid #dde5ee; background: #edf3f9; } -[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-member-menu-trigger"] { min-height: 42px; border: 1px solid #d9e3ee; border-radius: 11px; background: #fff; font-size: 12px; } +[data-testid="sidebar"][data-classroom="true"] .classroom-account-footer { border-top: 1px solid #dde5ee; background: #edf3f9; } .classroom-workspace [data-testid="coding-conversation-header"] { border-bottom-color: #e8edf3; background: #fff; color: #304968; } .classroom-tabs { display: flex; flex-shrink: 0; height: 46px; gap: 9px; padding: 6px 22px; border-bottom: 1px solid #e8edf3; } .classroom-workspace-toolbar { display: flex; flex-wrap: wrap; flex-shrink: 0; align-items: center; gap: 6px 12px; min-height: 46px; padding: 6px 22px; border-bottom: 1px solid #e8edf3; } diff --git a/src/pages/CloudAgents/CloudKnowledge.tsx b/src/pages/CloudAgents/CloudKnowledge.tsx index a54ee14b..f3edb6cd 100644 --- a/src/pages/CloudAgents/CloudKnowledge.tsx +++ b/src/pages/CloudAgents/CloudKnowledge.tsx @@ -1,5 +1,5 @@ -import { useEffect, useId, useRef, useState } from 'react'; -import { Upload } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Library, Upload } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,11 +10,10 @@ import { CloudKnowledgeDocuments } from './CloudKnowledgeDocuments'; export interface CloudOwnedKnowledgeCatalog { databases: CloudKnowledge[] | null; loading: boolean; error: string } -export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, onCatalog, refreshToken = 0, disabled = false, selectionFull = false, selectedIds = [], selectableIds }: { +export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onCatalog, refreshToken = 0, disabled = false, selectableIds }: { slug: string; onCreated: (kb: CloudKnowledge) => void; onDeleted?: (id: string) => void; onCatalog?: (value: CloudOwnedKnowledgeCatalog) => void; refreshToken?: number; disabled?: boolean; - selectionFull?: boolean; selectedIds?: string[]; - selectableIds?: string[] | null; onSelected?: (kb: CloudKnowledge) => void; + selectableIds?: string[] | null; }) { type CloudEmbeddingModel = CloudAgentOperations['knowledge']['output']['models'][number]; const [catalog, setCatalog] = useState<{ databases: CloudKnowledge[]; models: CloudEmbeddingModel[] } | null>(null); @@ -22,13 +21,13 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const [name, setName] = useState(''); const [model, setModel] = useState(''); const [error, setError] = useState(''); + const [errorTarget, setErrorTarget] = useState<'creation' | 'management'>('management'); const [catalogError, setCatalogError] = useState(''); const [catalogLoading, setCatalogLoading] = useState(true); const [busy, setBusy] = useState(false); const [refresh, setRefresh] = useState(0); const [creating, setCreating] = useState(false); const [documentPending, setDocumentPending] = useState(false); - const targetId = useId(); const createIntent = useRef<{ operation_id: string; name: string; embedding_model: string } | null>(null); usePendingCloudInput(busy || documentPending || Boolean(createIntent.current) || (creating && Boolean(name.trim()))); const operationInFlight = useRef(false); @@ -51,10 +50,10 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on .finally(() => { if (current) setCatalogLoading(false); }); return () => { current = false; }; }, [slug, refresh, refreshToken]); - const act = async (operation: () => Promise) => { + const act = async (operation: () => Promise, target: 'creation' | 'management' = 'management') => { if (disabled || operationInFlight.current) return; operationInFlight.current = true; - setBusy(true); setError(''); + setBusy(true); setError(''); setErrorTarget(target); try { await operation(); } catch(e) { if (alive.current) setError(e instanceof Error ? e.message : '知识库操作失败'); } finally { operationInFlight.current = false; if (alive.current) setBusy(false); } @@ -64,30 +63,63 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on const selectedKnowledge = manageable.find(kb => kb.kb_id === kbId); const managementDisabled = disabled || busy || catalogLoading || Boolean(catalogError); const bindable = (id: string) => selectableIds === undefined || Boolean(selectableIds?.includes(id)); - return
-
-
-

选择或新建自己的知识库,再从电脑或聊天中添加资料。选用会加入右侧配置,保存并应用后生效。

- - {error &&

{error}

} + const creationControls =
+ + {creating &&
+ setName(e.target.value)} /> + + {!catalogLoading && !catalogError && catalog && !catalog.models.length &&
+

暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。

+
+ 管理员配置说明 +
    +
  1. Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,按供应商要求填写有效的服务地址和 API Key。
  2. +
  3. 在同一后台添加并启用 embedding 模型,填写该模型的实际向量维度。
  4. +
  5. 配置完成后回到这里点击「刷新知识库与模型」。
  6. +
+
+
} + +
} +
; + return
+
+
+ {creationControls} + {errorTarget === 'creation' && error &&

{error}

} +
+
+
+ {errorTarget === 'management' && error &&

{error}

} {catalogError &&

知识库与模型目录加载失败:{catalogError}。请刷新重试。

}
- -
+ {documentPending && selectedKnowledge &&

有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。

}
使用说明与费用

上传到知识库的文档可供获准使用此智能体的人检索。解析并建立索引后才会生效;embedding 向量化由平台承担,不计入用户用量、词元点数或智能体费用上限。知识处理如触发对话模型调用,仍按原模型实际用量计费。

@@ -96,35 +128,6 @@ export function CloudKnowledgePanel({ slug, onCreated, onDeleted, onSelected, on {selectedKnowledge && selectableIds !== undefined && !bindable(selectedKnowledge.kb_id) &&

{selectableIds === null ? '知识库选用状态待确认。' : '这个知识库当前暂不可选用。'}你仍可管理自己的文件,刷新目录后再试。

} - {creating &&
- setName(e.target.value)} /> - - {!catalogLoading && !catalogError && catalog && !catalog.models.length &&
-

暂时没有可用的向量模型。Embedding 只在 Yuxi 配置,请联系平台管理员启用后刷新;向量化费用由平台承担,不计入用户用量或智能体费用上限。

-
- 管理员配置说明 -
    -
  1. Yuxi 后台「智能体管理 → 模型供应商」:启用供应商,按供应商要求填写有效的服务地址和 API Key。
  2. -
  3. 在同一后台添加并启用 embedding 模型,填写该模型的实际向量维度。
  4. -
  5. 配置完成后回到这里点击「刷新知识库与模型」。
  6. -
-
-
} - -
} - {documentPending && selectedKnowledge &&

有未确认的文件操作,请先在当前知识库重试或确认状态,再切换知识库。

} {kbId && !selectedKnowledge && !catalogLoading && !catalogError &&

这个知识库已无法管理。{documentPending ? '未确认的操作仍保留在当前页面。' : '请重新选择自己的知识库。'}

{documentPending &&

放弃只清除本地重试请求,不会删除已保存的文件。重新进入后可确认服务器状态。

}
} - {kbId && -
; +
+
; } diff --git a/src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx b/src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx deleted file mode 100644 index 88c0509c..00000000 --- a/src/pages/CloudAgents/CloudKnowledgeAttachmentPicker.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { CheckCircle2, FileText, Loader2, Search, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'; -import { cloudAgentsApi } from '@/lib/cloud-agents-api'; -import { usePendingCloudInput } from './CloudPending'; -import type { CloudAttachment, CloudKnowledgeFile, CloudThread } from '../../../shared/cloud-agents'; - -type Selection = { key: string; threadId: string; threadTitle: string; file: CloudAttachment }; -type ImportJob = Selection & { - operationId: string; status: 'queued' | 'running' | 'saved' | 'failed' | 'done'; - importedFile?: CloudKnowledgeFile; error?: string; -}; - -function fileDetails(file: CloudAttachment) { - const extension = file.file_name.split('.').pop()?.toLowerCase() ?? ''; - const type = file.file_name.includes('.') ? extension.toUpperCase() : '文件'; - const size = Number.isFinite(file.file_size) - ? file.file_size < 1024 ? `${file.file_size} B` : file.file_size < 1024 * 1024 - ? `${Math.ceil(file.file_size / 1024)} KB` : `${(file.file_size / (1024 * 1024)).toFixed(1)} MB` - : '大小未知'; - const reason = file.file_size > 5 * 1024 * 1024 ? '超过 5 MB,暂不能导入' - : !/^(txt|md|csv|json|docx|xlsx|pdf)$/.test(extension) ? '此格式暂不支持知识库导入' : ''; - return { type, size, reason }; -} - -function updatedDate(value: string) { - const date = new Date(value); - return Number.isFinite(date.getTime()) ? date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric', year: 'numeric' }) : '更新时间未知'; -} -function errorMessage(error: unknown) { return error instanceof Error ? error.message : '请稍后重试。'; } - -export function CloudKnowledgeAttachmentPicker({ slug, kbId, kbName, busy, canRead = true, act, onImported, onPendingChange, onClose, onRefreshKnowledge }: { - slug: string; kbId: string; kbName: string; busy: boolean; canRead?: boolean; - act: (operation: () => Promise) => Promise; - onImported: (file: CloudKnowledgeFile) => Promise; - onPendingChange: (pending: boolean) => void; - onClose?: () => void; onRefreshKnowledge?: () => void; -}) { - const scope = slug + ':' + kbId; - const [threads, setThreads] = useState([]); - const [agentNames, setAgentNames] = useState>({}); - const [threadId, setThreadId] = useState(''); - const [threadQuery, setThreadQuery] = useState(''); - const [fileQuery, setFileQuery] = useState(''); - const [files, setFiles] = useState<{ threadId: string; items: CloudAttachment[] }>({ threadId: '', items: [] }); - const [threadsLoading, setThreadsLoading] = useState(true); - const [filesLoading, setFilesLoading] = useState(false); - const [threadError, setThreadError] = useState(''); - const [fileError, setFileError] = useState(''); - const [threadsRevision, setThreadsRevision] = useState(0); - const [filesRevision, setFilesRevision] = useState(0); - const [selected, setSelected] = useState([]); - const [jobs, setJobs] = useState([]); - const [running, setRunning] = useState(false); - const [completed, setCompleted] = useState(0); - const [abandoning, setAbandoning] = useState(false); - const jobsRef = useRef([]); - const runningRef = useRef(false); - const alive = useRef(true); - const activeScope = useRef(scope); - const permitted = useRef(canRead); - const callbacks = useRef({ onImported, onClose }); - useEffect(() => { callbacks.current = { onImported, onClose }; }, [onImported, onClose]); - useEffect(() => { permitted.current = canRead; }, [canRead]); - useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); - useEffect(() => { - activeScope.current = scope; - jobsRef.current = []; runningRef.current = false; - setJobs([]); setSelected([]); setThreadId(''); setThreadQuery(''); setFileQuery(''); setCompleted(0); setRunning(false); setAbandoning(false); - }, [scope]); - const unresolved = jobs.some(job => job.status !== 'done'); - const pending = running || unresolved || selected.length > 0; - const locked = busy || !canRead || running || unresolved; - usePendingCloudInput(pending); - useEffect(() => { onPendingChange(pending); return () => onPendingChange(false); }, [pending, onPendingChange]); - - useEffect(() => { - let live = true; - setThreads([]); setThreadError(''); setThreadsLoading(canRead); - if (!canRead) return; - const load = async () => { - try { - let page = await cloudAgentsApi.call('threads', {}); - const all = [...page.threads]; - const seen = new Set(); - while (live && page.next_offset !== null) { - if (seen.has(page.next_offset)) throw new Error('对话列表读取未完成,请重试。'); - seen.add(page.next_offset); - page = await cloudAgentsApi.call('threads', { offset: page.next_offset }); - all.push(...page.threads); - } - if (live) setThreads([...new Map(all.map(thread => [thread.thread_id, thread])).values()]); - } catch (error) { if (live) setThreadError(errorMessage(error)); } - finally { if (live) setThreadsLoading(false); } - }; - void load(); - return () => { live = false; }; - }, [scope, canRead, threadsRevision]); - - useEffect(() => { - let live = true; - setAgentNames({}); - if (!canRead) return; - const names: Record = {}; - const publish = () => { if (live) setAgentNames({ ...names }); }; - // Names are supplementary: an unavailable agent directory must not hide chat files. - const ownNames = async () => { - let page = await cloudAgentsApi.list(); - const seen = new Set(); - for (;;) { - for (const agent of page.agents) names[agent.slug] = agent.name; - publish(); - if (!live || !page.next_cursor || seen.has(page.next_cursor)) return; - seen.add(page.next_cursor); - page = await cloudAgentsApi.list(page.next_cursor); - } - }; - void ownNames().catch(() => undefined); - void cloudAgentsApi.call('received', {}).then(page => { - if (!live) return; - for (const agent of page.agents) names[agent.slug] = agent.name; - publish(); - }).catch(() => undefined); - return () => { live = false; }; - }, [scope, canRead, threadsRevision]); - - useEffect(() => { - let live = true; - setFiles({ threadId, items: [] }); setFileError(''); setFilesLoading(Boolean(threadId) && canRead); - if (!threadId || !canRead) return; - cloudAgentsApi.call('attachments', { thread_id: threadId }).then(page => { - if (live) setFiles({ threadId, items: page.attachments }); - }).catch(error => { if (live) setFileError(errorMessage(error)); }) - .finally(() => { if (live) setFilesLoading(false); }); - return () => { live = false; }; - }, [scope, canRead, threadId, filesRevision]); - - const currentThread = threads.find(thread => thread.thread_id === threadId); - const visibleThreads = threads.filter(thread => `${thread.title} ${agentNames[thread.agent_slug] ?? ''}`.toLocaleLowerCase().includes(threadQuery.trim().toLocaleLowerCase())); - const visibleFiles = files.threadId === threadId ? files.items.filter(file => file.file_name.toLocaleLowerCase().includes(fileQuery.trim().toLocaleLowerCase())) : []; - const retryJobs = jobs.filter(job => job.status !== 'done'); - const failureOnly = retryJobs.length > 0 && retryJobs.every(job => job.status === 'failed'); - const selectedKeys = new Set(selected.map(item => item.key)); - - function choose(file: CloudAttachment, checked: boolean) { - if (locked || !currentThread || filesLoading || fileError || fileDetails(file).reason) return; - const key = JSON.stringify([threadId, file.file_id]); - if (jobsRef.current.length && jobsRef.current.every(job => job.status === 'done')) { jobsRef.current = []; setJobs([]); } - setCompleted(0); - setSelected(previous => checked ? [...previous.filter(item => item.key !== key), { - key, threadId, threadTitle: currentThread.title || '未命名对话', file, - }] : previous.filter(item => item.key !== key)); - } - - async function importSelected() { - if (busy || !canRead || runningRef.current) return; - const attemptScope = scope; - const current = () => alive.current && activeScope.current === attemptScope; - const initialJobs: ImportJob[] = jobsRef.current.length ? jobsRef.current : selected.map(item => ({ - ...item, operationId: crypto.randomUUID(), status: 'queued' as const, - })); - const attempts = initialJobs.filter(job => job.status !== 'done'); - if (!attempts.length) return; - const update = (key: string, value: Partial) => { - jobsRef.current = jobsRef.current.map(job => job.key === key ? { ...job, ...value } : job); - setJobs([...jobsRef.current]); - }; - jobsRef.current = initialJobs; setJobs([...initialJobs]); - runningRef.current = true; setRunning(true); setAbandoning(false); - try { - await act(async () => { - for (const job of attempts) { - if (!current() || !permitted.current) return; - update(job.key, { status: 'running', error: '' }); - try { - const file = job.importedFile ?? await cloudAgentsApi.call('importKnowledgeAttachment', { - slug, kb_id: kbId, thread_id: job.threadId, attachment_id: job.file.file_id, operation_id: job.operationId, - }); - if (!current()) return; - // A confirmed import is never repeated, even if permission changes or processing fails. - update(job.key, { status: 'saved', importedFile: file }); - if (!permitted.current) return; - try { - await callbacks.current.onImported(file); - if (!current()) return; - update(job.key, { status: 'done', error: '' }); - } catch (error) { - if (!current()) return; - update(job.key, { status: 'done', error: '文件已导入,处理未全部启动。请在知识库重试处理。' + errorMessage(error) }); - } - } catch (error) { - if (!current()) return; - update(job.key, { status: 'failed', error: '导入结果尚未确认:' + errorMessage(error) }); - } - } - if (current() && jobsRef.current.every(job => job.status === 'done')) { - setSelected([]); setCompleted(jobsRef.current.length); - if (jobsRef.current.every(job => !job.error)) { - jobsRef.current = []; setJobs([]); - callbacks.current.onClose?.(); - } - } - }); - } finally { - if (current()) { runningRef.current = false; setRunning(false); } - } - } - - const close = () => { if (!runningRef.current && !unresolved) onClose?.(); }; - return { if (!open) close(); }}> - { if (runningRef.current || unresolved) event.preventDefault(); }} - onInteractOutside={event => { if (runningRef.current || unresolved) event.preventDefault(); }}> -
-
从聊天选择文件导入到「{kbName}」
- {onClose && } -
-
-

选择你在 MakeLore Agents 聊天中上传的文件。聊天文字和助手生成的文件不会导入。

- {!canRead &&

知识库管理权限待确认,文件操作已暂停;未完成请求仍保留。

} - {canRead &&
-
-

选择对话

-
-
- {threadsLoading ?

正在读取对话…

: threadError ?

对话读取失败:{threadError}

- : !threads.length ?

还没有个人智能体对话。

: !visibleThreads.length ?

没有匹配的对话。

: visibleThreads.map(thread => )} -
-
-
-

{currentThread?.title || '选择文件'}

-
-
- {!threadId ?

先选择一段对话,再挑选其中的文件。

: filesLoading ?

正在读取文件…

- : fileError ?

文件读取失败:{fileError}

- : !files.items.length ?

这段对话还没有上传文件。

: !visibleFiles.length ?

没有匹配的文件。

: visibleFiles.map(file => { - const details = fileDetails(file), key = JSON.stringify([threadId, file.file_id]); - return ; - })} -
-
-
} - {selected.length > 0 &&

已选 {selected.length} 个文件{locked ? '。' : ',可继续切换对话选择。'}

-
    {selected.map(item =>
  • {item.file.file_name}{item.threadTitle} -
  • )}
-
} - {jobs.length > 0 &&
    {jobs.map(job =>
  • - {job.file.file_name}{job.status === 'done' ? <>已导入,处理状态见知识库 : job.status === 'running' ? '正在导入…' : job.status === 'saved' ? '已保存,等待继续处理' : job.status === 'failed' ? '等待重试' : '等待导入'} - {job.error &&

    {job.error}

    } -
  • )}
} - {abandoning &&
-

这只会结束当前页面的重试。已保存的文件会保留,可刷新知识库确认。

-
-
} -
-
-

最大 5 MB;支持文本、Word、Excel、文本 PDF。导入后自动处理,可用于回答后才能检索。

- {onRefreshKnowledge && } - {unresolved && !running && !abandoning && } - -
- {completed > 0 &&

已导入 {completed} 个文件,处理状态见知识库。

} -
; -} diff --git a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx index 1a778f84..0cd84b6d 100644 --- a/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeDocuments.tsx @@ -3,7 +3,6 @@ import { CheckCircle2, CircleAlert, FileText, Loader2 } from 'lucide-react'; import { cloudAgentsApi } from '@/lib/cloud-agents-api'; import { Button } from '@/components/ui/button'; import { usePendingCloudInput } from './CloudPending'; -import { CloudKnowledgeAttachmentPicker } from './CloudKnowledgeAttachmentPicker'; import type { CloudKnowledgeFile } from '../../../shared/cloud-agents'; function documentState(file: CloudKnowledgeFile) { @@ -48,11 +47,11 @@ function documentState(file: CloudKnowledgeFile) { return { label: '状态待确认', detail: '请刷新状态后再操作。', stage: 0 }; } -export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库', busy, act, onDeleteKnowledge, canRead = true, onPendingChange, onRefreshKnowledge }: { - slug: string; kbId: string; kbName?: string; busy: boolean; +export function CloudKnowledgeDocuments({ slug, kbId, busy, act, onDeleteKnowledge, canRead = true, onPendingChange }: { + slug: string; kbId: string; busy: boolean; act: (operation: () => Promise) => Promise; onDeleteKnowledge: () => void; - canRead?: boolean; onPendingChange?: (pending: boolean) => void; onRefreshKnowledge?: () => void; + canRead?: boolean; onPendingChange?: (pending: boolean) => void; }) { const [files, setFiles] = useState([]); const [pageCount, setPageCount] = useState(1); @@ -62,8 +61,6 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库' const [readError, setReadError] = useState(''); const [uploading, setUploading] = useState(''); const [submitting, setSubmitting] = useState(null); - const [importing, setImporting] = useState(false); - const [importPending, setImportPending] = useState(false); const [deleting, setDeleting] = useState(null); const [processErrors, setProcessErrors] = useState>({}); const uploadIntents = useRef>({}); @@ -72,7 +69,7 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库' const permitted = useRef(canRead); useEffect(() => { permitted.current = canRead; }, [canRead]); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); - const unresolved = Object.keys(uploadIntents.current).length > 0 || Object.keys(processIntents.current).length > 0 || importPending; + const unresolved = Object.keys(uploadIntents.current).length > 0 || Object.keys(processIntents.current).length > 0; usePendingCloudInput(unresolved); useEffect(() => { onPendingChange?.(unresolved); return () => onPendingChange?.(false); }, [onPendingChange, unresolved]); const refresh = () => setRevision(value => value + 1); @@ -156,13 +153,10 @@ export function CloudKnowledgeDocuments({ slug, kbId, kbName = '当前知识库' return
-

上传后自动解析并建立索引。最大 5 MB;支持 TXT、Markdown、CSV、JSON、Word、Excel、文本 PDF。

{uploading &&

{uploading}

} - {importing && setImporting(false)} onRefreshKnowledge={onRefreshKnowledge} />}
{files.length ? '已显示 ' + files.length + ' 份文档 · ' + states.filter(state => state.ready).length + ' 份可用' : '知识文档'} {active ? '处理中 · 状态自动更新' : '状态自动更新'} diff --git a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx index 9888fb2e..0c78f04a 100644 --- a/src/pages/CloudAgents/CloudKnowledgeSelector.tsx +++ b/src/pages/CloudAgents/CloudKnowledgeSelector.tsx @@ -18,10 +18,9 @@ export function CloudKnowledgeSelector({ slug, options, selected, disabled, load const add = (id: string) => { if (!selectionFull && !selected.includes(id)) onChange([...selected, id]); }; return
option.key)} - onSelected={kb => { if (!loading && !error && options.some(option => option.key === kb.kb_id)) { add(kb.kb_id); onRefresh(); } }} + selectableIds={loading || error ? null : options.map(option => option.key)} onDeleted={id => { onChange(selected.filter(value => value !== id)); onRefresh(); }} - onCreated={kb => { add(kb.kb_id); onRefresh(); }} /> + onCreated={onRefresh} />

从 MakeLore知识和我的知识库中选择,可同时使用多个。

diff --git a/src/pages/CloudAgents/agent-config.css b/src/pages/CloudAgents/agent-config.css index 4995f0b9..151fa693 100644 --- a/src/pages/CloudAgents/agent-config.css +++ b/src/pages/CloudAgents/agent-config.css @@ -42,65 +42,20 @@ .agent-config-resource-manager { margin-top: 18px; } .agent-config-resource-manager > details { border-radius: 14px; background: #ffffffa8; } .agent-resource-card:has(.agent-knowledge-workspace) { container-type: inline-size; } -.agent-knowledge-workspace { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); align-items: start; gap: 18px; min-width: 0; } -.agent-knowledge-upload, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; } +.agent-knowledge-workspace { display: grid; gap: 18px; min-width: 0; } +.agent-knowledge-owned-workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); align-items: start; gap: 18px; min-width: 0; } +.agent-knowledge-upload, .agent-knowledge-management, .agent-knowledge-selection { min-width: 0; padding: 18px; border: 1px solid #dce5e0; border-radius: 17px; background: #fffdfb; overflow-wrap: anywhere; } .agent-knowledge-upload { border-color: #dce5f3; } .agent-knowledge-panel-heading { display: flex; align-items: center; gap: 9px; color: #33485c; } .agent-knowledge-panel-heading h3 { font-size: 16px; font-weight: 650; line-height: 1.5; } .agent-knowledge-panel-heading svg { flex-shrink: 0; color: #607d72; } .agent-knowledge-upload .agent-knowledge-panel-heading svg { color: #4a6e9c; } -.agent-knowledge-field-label { display: block; font-size: 13px; font-weight: 600; color: #4c586b; } .agent-knowledge-help { border-top: 1px solid #e8e6e1; padding-top: 12px; } .agent-knowledge-help summary { min-height: 28px; width: fit-content; cursor: pointer; color: #606c7e; font-size: 12px; line-height: 1.7; } .agent-knowledge-help summary:focus-visible { outline: 2px solid #9a7bb5; outline-offset: 3px; border-radius: 4px; } .agent-knowledge-selection ul { max-height: 480px; overflow: auto; overscroll-behavior: contain; } .agent-knowledge-selection button { flex-shrink: 0; } -.agent-knowledge-import.agent-knowledge-import-modal { display: flex; flex-direction: column; width: calc(100vw - 32px); max-width: 860px; max-height: calc(100dvh - 32px); gap: 18px; border-radius: 22px; padding: 24px; background: #fffdf9; color: #273246; } -.agent-knowledge-import-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-shrink: 0; min-width: 0; } -.agent-knowledge-import-header > div { min-width: 0; overflow-wrap: anywhere; } -.agent-knowledge-import-header p { margin-top: 6px; line-height: 1.7; color: #59657a; } -.agent-knowledge-import-content { display: flex; flex-direction: column; gap: 16px; min-height: 0; min-width: 0; overflow: auto; overscroll-behavior: contain; } -.agent-knowledge-import-help { color: #606c7e; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } -.agent-knowledge-import-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); gap: 16px; min-height: 0; min-width: 0; overflow: auto; } -.agent-knowledge-import-content > .agent-knowledge-import-body { flex-shrink: 0; } -.agent-knowledge-import-chats, .agent-knowledge-import-files { display: flex; flex-direction: column; min-width: 0; min-height: 0; gap: 10px; } -.agent-knowledge-import-chats h3, .agent-knowledge-import-files h3 { font-size: 14px; font-weight: 600; } -.agent-knowledge-import-search { display: flex; align-items: center; gap: 8px; min-width: 0; color: #606c7e; } -.agent-knowledge-import-search svg, .agent-knowledge-import-file > svg { flex-shrink: 0; } -.agent-knowledge-import :is(input:not([type="checkbox"]), select) { width: 100%; min-width: 0; min-height: 42px; border: 1px solid #cdd5e3; border-radius: 11px; background: #fff; padding: 10px 12px; font-size: 14px; } -.agent-knowledge-import :is(input, button):focus-visible { outline: 2px solid #9a7bb5; outline-offset: 2px; } -.agent-knowledge-import-chat-list, .agent-knowledge-import-file-list { min-width: 0; overflow: auto; max-height: 330px; overscroll-behavior: contain; border: 1px solid #dce2ec; border-radius: 13px; background: #fff; } -.agent-knowledge-import-chat-list > :is(p, div), .agent-knowledge-import-file-list > :is(p, div) { padding: 14px; color: #606c7e; font-size: 13px; line-height: 1.7; } -.agent-knowledge-import-chat { display: flex; flex-direction: column; gap: 5px; width: 100%; min-height: 68px; padding: 12px; text-align: left; font-size: 14px; line-height: 1.5; cursor: pointer; overflow-wrap: anywhere; transition: background-color 160ms ease; } -.agent-knowledge-import-chat + .agent-knowledge-import-chat { border-top: 1px solid #edf0f5; } -.agent-knowledge-import-chat:hover { background: #f6f5fa; } -.agent-knowledge-import-chat[aria-pressed="true"] { background: #f1f6ff; box-shadow: inset 3px 0 #6b87b3; } -.agent-knowledge-import-chat:disabled { cursor: not-allowed; } -.agent-knowledge-import-chat small { color: #606c7e; font-size: 12px; } -.agent-knowledge-import-file { display: flex; align-items: flex-start; gap: 10px; min-width: 0; min-height: 64px; padding: 12px; font-size: 14px; line-height: 1.6; cursor: pointer; overflow-wrap: anywhere; } -.agent-knowledge-import-file + .agent-knowledge-import-file { border-top: 1px solid #edf0f5; } -.agent-knowledge-import-file:has(input:checked) { background: #f6f2ff; } -.agent-knowledge-import-file:has(input:disabled) { cursor: not-allowed; } -.agent-knowledge-import-file input[type="checkbox"] { width: 17px; height: 17px; margin-top: 3px; accent-color: #79629d; flex-shrink: 0; } -.agent-knowledge-import-file > span { min-width: 0; } -.agent-knowledge-import-file small { display: block; font-size: 12px; color: #606c7e; } -.agent-knowledge-import-selection { flex-shrink: 0; min-width: 0; border-radius: 12px; background: #f3f0f8; padding: 12px; font-size: 13px; overflow-wrap: anywhere; } -.agent-knowledge-import-selection ul { max-height: 130px; overflow: auto; } -.agent-knowledge-import-selection li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 8px; } -.agent-knowledge-import-selection li > span { min-width: 0; } -.agent-knowledge-import-selection li button { flex-shrink: 0; } -.agent-knowledge-import-selection small { display: block; color: #606c7e; font-size: 12px; } -.agent-knowledge-import-results { max-height: 170px; min-height: 0; flex-shrink: 0; overflow: auto; border: 1px solid #dce5e0; border-radius: 12px; padding: 12px; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } -.agent-knowledge-import-results li + li { border-top: 1px solid #e8e6e1; margin-top: 10px; padding-top: 10px; } -.agent-knowledge-import-results li > span { display: flex; align-items: center; gap: 6px; } -.agent-knowledge-import-results li > span + span { color: #526c5f; } -.agent-knowledge-import-results [role="alert"] { color: #b54532; } -.agent-knowledge-import-abandon { flex-shrink: 0; padding: 12px; border: 1px solid #efd9ba; border-radius: 12px; background: #fff5e9; font-size: 13px; line-height: 1.7; } -.agent-knowledge-import-abandon p { margin-bottom: 10px; } -.agent-knowledge-import-abandon button { margin: 0 8px 6px 0; } -.agent-knowledge-import-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 9px; flex-shrink: 0; } -.agent-knowledge-import-footer p { flex: 1 1 280px; min-width: 0; font-size: 12px; line-height: 1.7; color: #606c7e; } -@container (max-width: 620px) { .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); } } +@container (max-width: 620px) { .agent-knowledge-owned-workspace { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 640px) { .agent-editor-tabs { padding: 10px 12px; } .agent-editor-tabs [role="tab"] { gap: 6px; padding-inline: 8px; font-size: 14px; } @@ -112,12 +67,8 @@ .agent-config-heading-title { gap: 6px; } .agent-config-icon { width: 38px; height: 38px; border-radius: 13px; } .agent-config-section-top > button { margin-left: 48px; } - .agent-knowledge-workspace { grid-template-columns: minmax(0, 1fr); gap: 14px; } - .agent-knowledge-upload, .agent-knowledge-selection { padding: 14px; border-radius: 14px; } - .agent-knowledge-import.agent-knowledge-import-modal { padding: 18px; border-radius: 18px; gap: 14px; } - .agent-knowledge-import-body { grid-template-columns: minmax(0, 1fr); } - .agent-knowledge-import-chat-list { max-height: 160px; } - .agent-knowledge-import-file-list { max-height: 240px; } - .agent-knowledge-import-footer button { width: 100%; } + .agent-knowledge-workspace, .agent-knowledge-owned-workspace { gap: 14px; } + .agent-knowledge-owned-workspace { grid-template-columns: minmax(0, 1fr); } + .agent-knowledge-upload, .agent-knowledge-management, .agent-knowledge-selection { padding: 14px; border-radius: 14px; } } -@media (prefers-reduced-motion: reduce) { .agent-resource-option, .agent-knowledge-import-chat { transition: none; } } +@media (prefers-reduced-motion: reduce) { .agent-resource-option { transition: none; } } diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 9d39cd6e..1efc7209 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -116,7 +116,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea })}
- {authenticated && authUser &&