From 53ef9bddddee48cdbcbb1d2ea7b1ad6d77e81e1c Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 27 Jul 2026 15:18:32 +0800 Subject: [PATCH 1/5] release v0.9.0-rc.1: CI test fixes for agents, preview, models, and browser isolation --- package.json | 2 +- packages/a2a/package.json | 2 +- packages/chrome-extension/package.json | 2 +- packages/cli/package.json | 2 +- packages/comms/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/gui/package.json | 2 +- packages/org-manager/package.json | 2 +- packages/remote/package.json | 2 +- packages/shared/package.json | 2 +- packages/storage/package.json | 2 +- packages/web-ui/package.json | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index f92fec72..888ecbb8 100644 --- a/package.json +++ b/package.json @@ -52,5 +52,5 @@ "node-datachannel" ] }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/a2a/package.json b/packages/a2a/package.json index ad2f3874..4922188d 100644 --- a/packages/a2a/package.json +++ b/packages/a2a/package.json @@ -12,5 +12,5 @@ "dependencies": { "@markus/shared": "workspace:*" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/chrome-extension/package.json b/packages/chrome-extension/package.json index 32cb23c6..c46ebee4 100644 --- a/packages/chrome-extension/package.json +++ b/packages/chrome-extension/package.json @@ -12,5 +12,5 @@ "esbuild": "^0.25.0", "typescript": "^5.6.0" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/cli/package.json b/packages/cli/package.json index e56e58ca..ba2e2f40 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,5 +41,5 @@ "commander": "^14.0.3", "esbuild": "^0.27.4" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/comms/package.json b/packages/comms/package.json index d4cf34e9..86a71c8f 100644 --- a/packages/comms/package.json +++ b/packages/comms/package.json @@ -12,5 +12,5 @@ "dependencies": { "@markus/shared": "workspace:*" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/core/package.json b/packages/core/package.json index b12bdfa8..6411fa9f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,5 +25,5 @@ "@types/turndown": "^5.0.6", "@types/ws": "^8.18.1" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 09c3993d..5685dd6e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -35,5 +35,5 @@ "esbuild": "^0.27.4", "typescript": "^5.9.3" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/gui/package.json b/packages/gui/package.json index 9458c4e6..c948914c 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -17,5 +17,5 @@ "devDependencies": { "@types/sharp": "^0.32.0" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/org-manager/package.json b/packages/org-manager/package.json index 8a247610..3efccb83 100644 --- a/packages/org-manager/package.json +++ b/packages/org-manager/package.json @@ -21,5 +21,5 @@ "devDependencies": { "@types/ws": "^8.18.1" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/remote/package.json b/packages/remote/package.json index d4bb25e6..0123c0c7 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -17,5 +17,5 @@ "devDependencies": { "@types/ws": "^8.18.1" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/shared/package.json b/packages/shared/package.json index e805aa9c..4235f72d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,5 +9,5 @@ "dev": "tsc -b --watch", "clean": "rm -rf dist *.tsbuildinfo" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/storage/package.json b/packages/storage/package.json index a6a8b5d3..4f453321 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -12,5 +12,5 @@ "dependencies": { "@markus/shared": "workspace:*" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index a5ce1f1f..5498583b 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -45,5 +45,5 @@ "typescript": "^5.9.3", "vite": "^7.3.1" }, - "version": "0.9.0-rc.0" + "version": "0.9.0-rc.1" } From c9aa0509672309db30a4048e9e3481fe3e464c01 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 27 Jul 2026 18:18:52 +0800 Subject: [PATCH 2/5] release v0.9.0-rc.2: fix chat load, mailbox ghosts, image preview, and idle wakeup Stabilize Team Chat history loading with a proper loading state, stream large image previews, stop background_exec completion fan-out that created ghost mailbox items, and fix attention-loop lost-wakeup when idle with queued mail. Co-authored-by: Cursor --- .github/workflows/publish.yml | 6 +- RELEASELOG.md | 9 + package.json | 2 +- packages/a2a/package.json | 2 +- packages/chrome-extension/package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/commands/start.ts | 17 +- packages/comms/package.json | 2 +- packages/comms/src/feishu/adapter.ts | 3 +- packages/comms/src/feishu/client.ts | 36 ++ packages/comms/src/index.ts | 1 + packages/comms/test/feishu-adapter.test.ts | 7 +- packages/comms/test/feishu-client.test.ts | 40 ++ packages/core/package.json | 2 +- packages/core/src/agent-manager.ts | 26 ++ packages/core/src/agent.ts | 112 +++++- packages/core/src/attention.ts | 68 +++- packages/core/src/index.ts | 2 + packages/core/src/llm/markus-provider.ts | 66 +++- packages/core/src/llm/provider.ts | 33 +- packages/core/src/llm/router.ts | 119 +++++- packages/core/src/mailbox.ts | 51 ++- packages/core/src/tools/feishu.ts | 136 +++++++ packages/core/src/tools/index.ts | 1 + packages/core/src/tools/mailbox-tools.ts | 11 +- packages/core/src/tools/ui-layout.ts | 18 +- packages/core/src/tools/web-search.ts | 10 +- .../core/test/llm-markus-provider.test.ts | 84 +++- packages/core/test/llm-router.test.ts | 48 +++ packages/core/test/mailbox-core.test.ts | 32 ++ packages/core/test/ui-layout-tools.test.ts | 12 + packages/desktop/package.json | 2 +- packages/desktop/src/embedded-browser.ts | 234 +++++++++++- packages/desktop/src/preload.ts | 10 +- packages/gui/package.json | 2 +- packages/org-manager/package.json | 2 +- packages/org-manager/src/api-server.ts | 81 ++-- packages/org-manager/src/sse-handler.ts | 43 ++- packages/remote/package.json | 2 +- packages/shared/package.json | 2 +- packages/shared/src/limits.ts | 6 +- packages/shared/src/utils/config.ts | 5 + packages/storage/package.json | 2 +- packages/storage/src/sqlite-storage.ts | 6 + packages/web-ui/package.json | 2 +- packages/web-ui/src/api.ts | 11 +- .../src/components/ActivityIndicator.tsx | 10 +- .../web-ui/src/components/ChatModelMenu.tsx | 38 +- packages/web-ui/src/components/ChatPanel.tsx | 14 +- .../web-ui/src/components/EmbeddedBrowser.tsx | 261 ++++++++++--- .../src/components/ExecutionTimeline.tsx | 25 +- .../web-ui/src/components/MarkdownMessage.tsx | 122 +++++- packages/web-ui/src/components/RightPanel.tsx | 7 +- packages/web-ui/src/env.d.ts | 7 +- packages/web-ui/src/index.css | 19 + .../src/lib/ConversationBufferManager.ts | 7 +- packages/web-ui/src/lib/browserUrl.ts | 66 ++++ packages/web-ui/src/locales/en/agent.json | 1 + packages/web-ui/src/locales/en/common.json | 14 + .../web-ui/src/locales/en/onboarding.json | 2 +- packages/web-ui/src/locales/en/settings.json | 2 +- packages/web-ui/src/locales/en/team.json | 7 +- packages/web-ui/src/locales/zh-CN/agent.json | 1 + packages/web-ui/src/locales/zh-CN/common.json | 14 + .../web-ui/src/locales/zh-CN/onboarding.json | 2 +- .../web-ui/src/locales/zh-CN/settings.json | 2 +- packages/web-ui/src/locales/zh-CN/team.json | 7 +- packages/web-ui/src/pages/AgentProfile.tsx | 13 +- packages/web-ui/src/pages/ChatComponents.tsx | 47 ++- packages/web-ui/src/pages/ChatHelpers.test.ts | 36 ++ packages/web-ui/src/pages/ChatHelpers.ts | 47 ++- packages/web-ui/src/pages/Settings.tsx | 6 +- packages/web-ui/src/pages/Team.tsx | 361 ++++++++++++------ templates/skills/feishu-interaction/SKILL.md | 9 +- 74 files changed, 2131 insertions(+), 386 deletions(-) create mode 100644 packages/core/src/tools/feishu.ts create mode 100644 packages/web-ui/src/lib/browserUrl.ts create mode 100644 packages/web-ui/src/pages/ChatHelpers.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4a8786a8..02b7d7a3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -245,8 +245,12 @@ jobs: - name: Build Electron app run: pnpm --filter @markus/desktop build:electron + # PowerShell treats `ls -lh` as Get-ChildItem -lh (invalid). Use bash. - name: Verify Electron dist includes extension zip - run: ls -lh packages/desktop/dist/markus-browser-extension.zip + shell: bash + run: | + test -f packages/desktop/dist/markus-browser-extension.zip + ls -lh packages/desktop/dist/markus-browser-extension.zip # Fetch the ssign client used by build/sign.cjs and export its native # Windows path so electron-builder's custom signer can find it. diff --git a/RELEASELOG.md b/RELEASELOG.md index 78bf08ab..0fcb2b29 100644 --- a/RELEASELOG.md +++ b/RELEASELOG.md @@ -11,6 +11,7 @@ Markus Cloud / Hub 额度与计费体验落地;认知增强与统一 A2A 消 - **Conversation buffer 状态机** — 抽取会话缓冲状态机,修复聊天竞态与流式重连边界 - **Hub recommended routing** — Hub 推荐路由与模型目录 / 路由能力增强 - **Team Chat 菜单优化** — 「移动到」可搜索折叠、组织秘书保护、文案改为「移出团队」 +- **飞书原生发图** — `feishu_send_image` / `feishu_send_message`,支持本地图片上传发送 ### Bug Fixes @@ -20,6 +21,14 @@ Markus Cloud / Hub 额度与计费体验落地;认知增强与统一 A2A 消 - **任务与通知稳定性** — 任务状态实时更新、deliberation / notify 上下文、triage 完整性 - **性能与前端** — Prompt cache 友好与 Tier 3 token 成本优化;web-ui 路由拆分、共享缓存、轮询去重 +### v0.9.0-rc.2 + +- **图片预览** — 大图(webp/png 等)改走 stream,不再被 2MB base64 上限误判为无法预览 +- **Team Chat 历史加载** — 修复空 buffer 跳过 DB 拉取;进入会话显示 loading,加载后展示消息 +- **Mailbox 幽灵条目** — 修复 background_exec 完成回调广播到所有 Agent;过期 callback_result 自动清理;drop 幂等 +- **注意力空闲卡死** — 修复 dequeueAsync lost-wakeup;idle 且有队列时 watchdog nudge;UI 不再显示「等待新邮件」 +- **引用回复 / 重复继续** — 引用内容不写入用户气泡;发送去重与 Resume 按钮可见性修正 + ### Stats - 290 files changed, +36,540 / −12,148 lines diff --git a/package.json b/package.json index 888ecbb8..cbddbbc7 100644 --- a/package.json +++ b/package.json @@ -52,5 +52,5 @@ "node-datachannel" ] }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/a2a/package.json b/packages/a2a/package.json index 4922188d..ce7042e6 100644 --- a/packages/a2a/package.json +++ b/packages/a2a/package.json @@ -12,5 +12,5 @@ "dependencies": { "@markus/shared": "workspace:*" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/chrome-extension/package.json b/packages/chrome-extension/package.json index c46ebee4..6acde40f 100644 --- a/packages/chrome-extension/package.json +++ b/packages/chrome-extension/package.json @@ -12,5 +12,5 @@ "esbuild": "^0.25.0", "typescript": "^5.6.0" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/cli/package.json b/packages/cli/package.json index ba2e2f40..2f097a4b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,5 +41,5 @@ "commander": "^14.0.3", "esbuild": "^0.27.4" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index e33431fb..a1eb4923 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -260,9 +260,9 @@ export async function createServices(config: ReturnType) { } } - // Apply auto-fallback setting - if (config.llm.autoFallback === false) { - llmRouter.setAutoFallback(false); + // Apply auto-fallback setting (router defaults to off — fail loud) + if (typeof config.llm.autoFallback === 'boolean') { + llmRouter.setAutoFallback(config.llm.autoFallback); } // Apply capability routing config @@ -359,6 +359,16 @@ export async function createServices(config: ReturnType) { log.info('Feishu MCP server configured', { presets, localBin: !!larkMcpBin }); } + const feishuToolsConfig = (feishuAppId && feishuAppSecret) + ? { + appId: feishuAppId, + appSecret: feishuAppSecret, + domain: feishuIntegration?.domain, + defaultChatId: typeof feishuIntegration?.notifyChatId === 'string' ? feishuIntegration.notifyChatId : undefined, + defaultOpenId: typeof feishuIntegration?.notifyOpenId === 'string' ? feishuIntegration.notifyOpenId : undefined, + } + : undefined; + const agentManager = new AgentManager({ llmRouter, roleLoader, @@ -367,6 +377,7 @@ export async function createServices(config: ReturnType) { skillRegistry, taskService, mcpServers, + feishuToolsConfig, }); if (config.agent?.maxToolIterations) { diff --git a/packages/comms/package.json b/packages/comms/package.json index 86a71c8f..f1904023 100644 --- a/packages/comms/package.json +++ b/packages/comms/package.json @@ -12,5 +12,5 @@ "dependencies": { "@markus/shared": "workspace:*" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/comms/src/feishu/adapter.ts b/packages/comms/src/feishu/adapter.ts index 1503ca13..c40f17fe 100644 --- a/packages/comms/src/feishu/adapter.ts +++ b/packages/comms/src/feishu/adapter.ts @@ -120,7 +120,8 @@ export class FeishuAdapter implements CommAdapter { return this.client.sendInteractiveMessage(channelId, JSON.parse(content), idType); } if (feishuOpts?.asImage) { - return this.client.sendTextMessage(channelId, content, idType); + // `content` is treated as a local filesystem path to the image. + return this.client.sendLocalImage(channelId, content, idType); } return this.client.sendTextMessage(channelId, content, idType); } diff --git a/packages/comms/src/feishu/client.ts b/packages/comms/src/feishu/client.ts index 435fed72..f484f480 100644 --- a/packages/comms/src/feishu/client.ts +++ b/packages/comms/src/feishu/client.ts @@ -1,3 +1,5 @@ +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; import { createLogger } from '@markus/shared'; const log = createLogger('feishu-client'); @@ -79,6 +81,40 @@ export class FeishuClient { return this.sendMessage(chatId, 'text', JSON.stringify({ text }), idType); } + /** + * Upload a local image for IM messages (msg_type=image). + * Feishu: POST /open-apis/im/v1/images (multipart: image_type + image). + */ + async uploadImage(filePath: string, imageType: 'message' | 'avatar' = 'message'): Promise { + const token = await this.getTenantToken(); + const bytes = await readFile(filePath); + const form = new FormData(); + form.append('image_type', imageType); + form.append('image', new Blob([new Uint8Array(bytes)]), basename(filePath)); + + const res = await fetch(`${this.domain}/open-apis/im/v1/images`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: form, + }); + + const data = (await res.json()) as ApiResponse<{ image_key?: string }>; + if (data.code !== 0 || !data.data?.image_key) { + throw new Error(`Feishu image upload failed: ${data.msg || 'unknown error'}`); + } + return data.data.image_key; + } + + async sendImageMessage(receiveId: string, imageKey: string, idType: ReceiveIdType = 'chat_id'): Promise { + return this.sendMessage(receiveId, 'image', JSON.stringify({ image_key: imageKey }), idType); + } + + /** Upload a local image file and send it as an IM image message. */ + async sendLocalImage(receiveId: string, filePath: string, idType: ReceiveIdType = 'chat_id'): Promise { + const imageKey = await this.uploadImage(filePath); + return this.sendImageMessage(receiveId, imageKey, idType); + } + async sendRichTextMessage(chatId: string, title: string, content: Array>>, idType: ReceiveIdType = 'chat_id'): Promise { return this.sendMessage(chatId, 'post', JSON.stringify({ zh_cn: { title, content }, diff --git a/packages/comms/src/index.ts b/packages/comms/src/index.ts index 11d0948d..3bb130e6 100644 --- a/packages/comms/src/index.ts +++ b/packages/comms/src/index.ts @@ -1,5 +1,6 @@ export { FeishuAdapter } from './feishu/adapter.js'; export { FeishuClient } from './feishu/client.js'; +export type { ReceiveIdType, FeishuConfig, SendMsgType } from './feishu/client.js'; export { buildStatusCard, buildTaskCard, buildProgressCard, buildAgentResponseCard } from './feishu/cards.js'; export type { AgentCardPhase, ToolCallEntry } from './feishu/cards.js'; export { WebUIAdapter } from './webui/adapter.js'; diff --git a/packages/comms/test/feishu-adapter.test.ts b/packages/comms/test/feishu-adapter.test.ts index 36dce6ca..047e0282 100644 --- a/packages/comms/test/feishu-adapter.test.ts +++ b/packages/comms/test/feishu-adapter.test.ts @@ -9,6 +9,7 @@ function makeMockClient(): Partial { getTenantToken: vi.fn().mockResolvedValue('mock-token'), sendTextMessage: vi.fn().mockResolvedValue('om_mock_sent'), sendInteractiveMessage: vi.fn().mockResolvedValue('om_mock_card'), + sendLocalImage: vi.fn().mockResolvedValue('om_mock_image'), replyMessage: vi.fn().mockResolvedValue('om_mock_reply'), replyCard: vi.fn().mockResolvedValue('om_mock_reply_card'), updateMessage: vi.fn().mockResolvedValue(undefined), @@ -368,9 +369,9 @@ describe('FeishuAdapter', () => { expect(mockClient.sendInteractiveMessage).toHaveBeenCalled(); }); - it('sendMessage with asImage sends text', async () => { - await adapter.sendMessage('oc_channel', 'image-url', { asImage: true }); - expect(mockClient.sendTextMessage).toHaveBeenCalledWith('oc_channel', 'image-url', 'chat_id'); + it('sendMessage with asImage uploads local image path', async () => { + await adapter.sendMessage('oc_channel', '/tmp/poster.webp', { asImage: true }); + expect(mockClient.sendLocalImage).toHaveBeenCalledWith('oc_channel', '/tmp/poster.webp', 'chat_id'); }); it('skips duplicate events', async () => { diff --git a/packages/comms/test/feishu-client.test.ts b/packages/comms/test/feishu-client.test.ts index 1603e855..82b27a74 100644 --- a/packages/comms/test/feishu-client.test.ts +++ b/packages/comms/test/feishu-client.test.ts @@ -329,6 +329,46 @@ describe('FeishuClient', () => { }); }); + describe('uploadImage / sendImageMessage', () => { + it('should upload an image and send it as msg_type=image', async () => { + const { writeFile, mkdtemp, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dir = await mkdtemp(join(tmpdir(), 'feishu-img-')); + const filePath = join(dir, 'poster.webp'); + await writeFile(filePath, Buffer.from('fake-image-bytes')); + + try { + mockFetch + .mockResolvedValueOnce({ + json: async () => ({ code: 0, tenant_access_token: 'mock-token', expire: 7200 }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 0, msg: 'ok', data: { image_key: 'img_key_1' } }), + }) + .mockResolvedValueOnce({ + json: async () => ({ code: 0, msg: 'ok', data: { message_id: 'om_img_1' } }), + }); + + const messageId = await client.sendLocalImage('ou_user_1', filePath, 'open_id'); + expect(messageId).toBe('om_img_1'); + + const uploadCall = mockFetch.mock.calls[1]; + expect(uploadCall[0]).toBe('https://open.feishu.cn/open-apis/im/v1/images'); + expect(uploadCall[1].method).toBe('POST'); + expect(uploadCall[1].body).toBeInstanceOf(FormData); + + const sendCall = mockFetch.mock.calls[2]; + expect(sendCall[0]).toBe('https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id'); + const body = JSON.parse(sendCall[1].body); + expect(body.msg_type).toBe('image'); + expect(JSON.parse(body.content)).toEqual({ image_key: 'img_key_1' }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + }); + describe('deleteMessage', () => { it('should delete a message with DELETE request', async () => { mockFetch diff --git a/packages/core/package.json b/packages/core/package.json index 6411fa9f..ea05b2a9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,5 +25,5 @@ "@types/turndown": "^5.0.6", "@types/ws": "^8.18.1" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/core/src/agent-manager.ts b/packages/core/src/agent-manager.ts index 8a276461..5f41963c 100644 --- a/packages/core/src/agent-manager.ts +++ b/packages/core/src/agent-manager.ts @@ -39,6 +39,7 @@ import { createMemoryTools } from './tools/memory.js'; import { createMailboxTools, type MailboxToolContext } from './tools/mailbox-tools.js'; import { createSettingsTools } from './tools/settings.js'; import { createMultiModalTools } from './tools/multimodal.js'; +import { createFeishuTools, type FeishuToolsConfig } from './tools/feishu.js'; import { createRecallTool, type RecallCallbacks } from './tools/recall.js'; import { SemanticMemorySearch, OpenAIEmbeddingProvider, LocalVectorStore } from './memory/semantic-search.js'; import type { SkillRegistry } from './skills/types.js'; @@ -327,6 +328,8 @@ export class AgentManager { private embeddedBrowserHost: EmbeddedBrowserHost | null = null; private globalSecurityPolicy?: SecurityPolicy; private globalMcpServers?: Record; + /** When set, register native Feishu send tools (incl. local image upload). */ + private feishuToolsConfig?: FeishuToolsConfig; private skillRegistry?: SkillRegistry; private skillSearcher?: (query: string) => Promise>; private skillInstaller?: (request: Record) => Promise<{ installed: boolean; name: string; method: string }>; @@ -475,6 +478,7 @@ export class AgentManager { eventBus?: EventBus; securityPolicy?: SecurityPolicy; mcpServers?: Record; + feishuToolsConfig?: FeishuToolsConfig; skillRegistry?: SkillRegistry; taskService?: TaskServiceBridge; templateRegistry?: TemplateRegistry; @@ -492,6 +496,7 @@ export class AgentManager { this.browserBridge = new MarkusBrowserBridge(); this.globalSecurityPolicy = options.securityPolicy; this.globalMcpServers = options.mcpServers; + this.feishuToolsConfig = options.feishuToolsConfig; this.skillRegistry = options.skillRegistry; this.taskService = options.taskService; @@ -703,6 +708,23 @@ export class AgentManager { return { ...config, args }; } + /** Native Feishu tools that cover gaps in lark-mcp (local image upload/send). */ + private registerFeishuTools(agent: Agent): void { + if (!this.feishuToolsConfig) return; + try { + const tools = createFeishuTools(this.feishuToolsConfig); + const names: string[] = []; + for (const tool of tools) { + agent.registerTool(tool); + names.push(tool.name); + } + agent.activateTools(names); + log.info('Native Feishu tools registered', { toolCount: tools.length, tools: names }); + } catch (error) { + log.warn('Failed to register native Feishu tools', { error: String(error) }); + } + } + /** * Trigger Chrome dialog auto-click with smart mutex. * Only one clicker process runs at a time. If triggered while already running, @@ -1733,6 +1755,8 @@ export class AgentManager { } } + this.registerFeishuTools(agent); + if (this.agentAuditCallback) { const cb = this.agentAuditCallback; agent.setAuditCallback(event => cb(id, event)); @@ -2021,6 +2045,8 @@ export class AgentManager { } } + this.registerFeishuTools(agent); + // Set skill MCP activator callback for runtime activation via discover_tools agent.setSkillMcpActivator(async (skillName, mcpServers) => { let tools: AgentToolHandler[] = []; diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index ea986de3..af403163 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -519,9 +519,12 @@ export class Agent { this.tools.set('spawn_subagents', createParallelSubagentTool(subagentCtx)); // Route background_exec completion through the mailbox for proper attention handling. + // IMPORTANT: onBackgroundCompletion is a process-wide bus — only the agent that + // registered the bg session may deliver a callback. Falling back to + // `currentSessionId` used to fan out one completion into N ghost mailbox items. this._bgCompletionUnsub = onBackgroundCompletion((notification) => { - const originSession = this.bgSessionOrigin.get(notification.sessionId) - ?? this.currentSessionId; + const originSession = this.bgSessionOrigin.get(notification.sessionId); + if (!originSession) return; this.bgSessionOrigin.delete(notification.sessionId); const status = notification.exitCode === 0 ? 'succeeded' : `failed (exit ${notification.exitCode})`; @@ -2135,15 +2138,42 @@ export class Agent { /** Cancel any active streaming response (user-initiated) */ cancelActiveStream(): void { - if (this.activeStreamToken) { + // Always keep a durable token so a stop that arrives before handleMessageStream + // links the SSE cancelToken is not lost (previously this was a silent no-op). + if (!this.activeStreamToken) { + this.activeStreamToken = { cancelled: true, userStopped: true }; + } else { this.activeStreamToken.cancelled = true; this.activeStreamToken.userStopped = true; - log.info('Active stream cancelled by user', { agentId: this.id }); + } + this.markStreamCancelTokensStopped(); + log.info('Active stream cancelled by user', { agentId: this.id }); + } + + /** Mark every known stream cancel token (active + mailbox) as user-stopped. */ + private markStreamCancelTokensStopped(): void { + const mark = (ct: unknown) => { + if (!ct || typeof ct !== 'object') return; + const token = ct as { cancelled?: boolean; userStopped?: boolean }; + token.cancelled = true; + token.userStopped = true; + }; + mark(this.activeStreamToken); + const focus = this.attentionController.getCurrentFocus(); + const focusExtra = focus?.payload?.extra as { cancelToken?: unknown } | undefined; + mark(focusExtra?.cancelToken); + for (const item of this.mailbox.getQueuedItems()) { + const extra = item.payload?.extra as { cancelToken?: unknown } | undefined; + mark(extra?.cancelToken); } } /** Get a cancel token for the current stream */ getStreamCancelToken(): { cancelled: boolean; userStopped?: boolean } { + // Preserve an in-flight user stop across token rotation. + if (this.activeStreamToken?.userStopped) { + return this.activeStreamToken; + } this.activeStreamToken = { cancelled: false }; return this.activeStreamToken; } @@ -3996,6 +4026,11 @@ export class Agent { // cancelActiveStream() (called via the cancel-processing API) // properly propagates userStopped to this stream. if (cancelToken) { + // A stop that arrived before this turn started must not be wiped. + if (this.activeStreamToken?.userStopped) { + cancelToken.cancelled = true; + cancelToken.userStopped = true; + } this.activeStreamToken = cancelToken; } @@ -4257,9 +4292,12 @@ export class Agent { const runTool = () => this.executeTool(tc, toolOutputCb, this.currentSessionId); try { const isSubagentTool = tc.name === 'spawn_subagent' || tc.name === 'spawn_subagents'; - let result = isSubagentTool - ? await chatSubagentContext.run({ subagentProgress: subagentProgressCb }, runTool) - : await runTool(); + // Race tools against user stop — otherwise Stop waits until every + // in-flight tool finishes before the loop can observe userStopped. + const toolPromise = isSubagentTool + ? chatSubagentContext.run({ subagentProgress: subagentProgressCb }, runTool) + : runTool(); + let result = await this.raceAgainstUserStop(toolPromise, cancelToken); result = this.offloadLargeResult(tc.name, result); const isToolError = isErrorResult(result); const durationMs = Date.now() - toolStart; @@ -4275,6 +4313,7 @@ export class Agent { return { toolCallId: tc.id, content: result }; } catch (toolErr) { const durationMs = Date.now() - toolStart; + const cancelled = cancelToken?.userStopped || /cancelled by user/i.test(String(toolErr)); this.emitAudit({ type: 'tool_call', action: tc.name, @@ -4283,8 +4322,8 @@ export class Agent { detail: String(toolErr), }); if (streamActId) this.emitActivityLog(streamActId, 'tool_end', tc.name, { arguments: tc.arguments, error: String(toolErr), durationMs, success: false }); - onEvent({ type: 'agent_tool', tool: tc.name, phase: 'end', success: false, arguments: tc.arguments, error: String(toolErr), durationMs }); - return { toolCallId: tc.id, content: `Error: ${String(toolErr)}` }; + onEvent({ type: 'agent_tool', tool: tc.name, phase: 'end', success: false, arguments: tc.arguments, error: cancelled ? 'cancelled by user' : String(toolErr), durationMs }); + return { toolCallId: tc.id, content: cancelled ? 'Error: cancelled by user' : `Error: ${String(toolErr)}` }; } }) ); @@ -4297,6 +4336,22 @@ export class Agent { }); } + if (cancelToken?.userStopped) { + log.info('Stream cancelled by user after tool batch', { agentId: this.id }); + if (this.currentSessionId) { + const content = lastResponseContent + ? lastResponseContent + '\n\n[interrupted by user]' + : '[interrupted by user]'; + this.memory.appendMessage(this.currentSessionId, { + role: 'assistant', + content, + }); + } + if (streamChatActivityId) this.endActivity(streamChatActivityId); + if (this.activeTasks.size === 0) this.setStatus('idle'); + return '[cancelled]'; + } + for (let i = 0; i < response.toolCalls!.length; i++) { const tc = response.toolCalls![i]!; this.loopDetector.record(tc.name, tc.arguments ?? {}, toolResults[i]?.content ?? ''); @@ -6546,6 +6601,45 @@ export class Agent { msg.includes('aborterror'); } + /** + * Resolve `promise` unless the user hits Stop first. Rejects with + * "cancelled by user" so tool loops can unwind promptly (the underlying + * tool may still finish in the background). + */ + private raceAgainstUserStop( + promise: Promise, + cancelToken?: { userStopped?: boolean }, + ): Promise { + if (!cancelToken) return promise; + if (cancelToken.userStopped) return Promise.reject(new Error('cancelled by user')); + return new Promise((resolve, reject) => { + let settled = false; + const timer = setInterval(() => { + if (!settled && cancelToken.userStopped) { + settled = true; + clearInterval(timer); + reject(new Error('cancelled by user')); + } + }, 100); + promise.then( + (value) => { + if (!settled) { + settled = true; + clearInterval(timer); + resolve(value); + } + }, + (err) => { + if (!settled) { + settled = true; + clearInterval(timer); + reject(err); + } + }, + ); + }); + } + private async withNetworkRetry(fn: () => Promise, label: string, signal?: AbortSignal): Promise { let lastError: unknown; for (let attempt = 0; attempt < Agent.NETWORK_RETRY_MAX; attempt++) { diff --git a/packages/core/src/attention.ts b/packages/core/src/attention.ts index db4507f0..d8b0dff5 100644 --- a/packages/core/src/attention.ts +++ b/packages/core/src/attention.ts @@ -156,6 +156,8 @@ export class AttentionController { /** True while the runLoop's while-body is executing; false after the loop exits. */ private loopAlive = false; + /** Bumped on each launchLoop so a superseded loop exits instead of double-consuming. */ + private loopGeneration = 0; constructor( agentId: string, @@ -212,15 +214,18 @@ export class AttentionController { * outer try-catch inside the loop body. */ private launchLoop(): void { - this.loopPromise = this.runLoop().catch(err => { - if (this.running) { + const gen = ++this.loopGeneration; + // Wake any prior waiter so a superseded loop can exit cleanly. + this.mailbox.cancelWait(); + this.loopPromise = this.runLoop(gen).catch(err => { + if (this.running && gen === this.loopGeneration) { log.error('Attention loop exited unexpectedly — restarting in 2 s', { agentId: this.agentId, error: String(err), stack: (err as Error)?.stack, }); setTimeout(() => { - if (this.running) this.launchLoop(); + if (this.running && gen === this.loopGeneration) this.launchLoop(); }, 2000); } }); @@ -288,6 +293,26 @@ export class AttentionController { cleaned, }); } + // Also age-out stale informational/callback ghosts while idle so the + // queue cannot fill with items that never reach pre-triage cleanup. + if (this.mailbox.depth > 0) { + const purged = this.mailbox.purgeStaleItems(); + if (purged > 0) { + log.info('Watchdog: purged stale mailbox items', { + agentId: this.agentId, + purged, + }); + } + } + // Self-heal lost-wakeup: idle with a non-empty queue means the loop + // is parked without having seen the enqueue signal — nudge it. + if (this.state === 'idle' && this.mailbox.depth > 0) { + log.warn('Watchdog: idle with queued mail — nudging attention loop', { + agentId: this.agentId, + queueDepth: this.mailbox.depth, + }); + this.mailbox.nudgeIfPending(); + } } catch (err) { log.debug('Triage scoring failed', { error: String(err) }); } } }, WATCHDOG_INTERVAL_MS); @@ -312,10 +337,10 @@ export class AttentionController { * in the queue AND a TriageJudge is configured, perform LLM-driven * deliberation to decide which item to process first. */ - private async runLoop(): Promise { + private async runLoop(gen: number): Promise { this.loopAlive = true; try { - while (this.running) { + while (this.running && gen === this.loopGeneration) { try { this.setState('idle'); this.currentFocus = undefined; @@ -329,10 +354,16 @@ export class AttentionController { item = await this.mailbox.dequeueAsync(); } catch { // MailboxCancelledError (or any error) while not running → clean exit - if (!this.running) break; + if (!this.running || gen !== this.loopGeneration) break; continue; } + // Superseded by a newer launchLoop — put the item back for the new loop. + if (gen !== this.loopGeneration) { + try { this.mailbox.putBack(item); } catch { /* ignore */ } + break; + } + if (!this.running) { // Re-enqueue so items are not lost on shutdown. // human_chat is NOT re-enqueued: its SSE stream is stale after @@ -490,7 +521,7 @@ export class AttentionController { } } } finally { - this.loopAlive = false; + if (gen === this.loopGeneration) this.loopAlive = false; } } @@ -1274,10 +1305,27 @@ export class AttentionController { dropItem(itemId: string, reason: string): boolean { const item = this.mailbox.getById(itemId); - if (!item || item.status !== 'queued') return false; - if (item.sourceType === 'human_chat') return false; + if (item) { + if (item.status !== 'queued') return false; + if (item.sourceType === 'human_chat') return false; + this.mailbox.drop(itemId); + const decision = this.recordDecision('drop', item, reason); + this.delegate?.onDecisionMade(decision); + return true; + } + // Ghost / already-handled: not in the live queue. Force-drop in persistence + // so the agent stops looping on "Item not found" for stale IDs. this.mailbox.drop(itemId); - const decision = this.recordDecision('drop', item, reason); + const decision = this.recordDecision('drop', { + id: itemId, + agentId: this.agentId, + sourceType: 'system_event', + priority: 4, + status: 'dropped', + payload: { summary: `Ghost/orphan drop: ${reason}`, content: reason }, + metadata: {}, + queuedAt: new Date().toISOString(), + }, reason); this.delegate?.onDecisionMade(decision); return true; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 01228386..cb2104f9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -176,6 +176,8 @@ export { type SubagentProgressCallback, createMultiModalTools, type MultiModalToolsContext, + createFeishuTools, + type FeishuToolsConfig, } from './tools/index.js'; export { ToolLoopDetector, diff --git a/packages/core/src/llm/markus-provider.ts b/packages/core/src/llm/markus-provider.ts index caad264b..116033b2 100644 --- a/packages/core/src/llm/markus-provider.ts +++ b/packages/core/src/llm/markus-provider.ts @@ -21,6 +21,7 @@ import { } from '@markus/shared'; import { CREDIT_EXCEEDED_MSG, + UPSTREAM_BILLING_MISMATCH_MSG, defaultVoiceForModel, formatUpstreamMediaError, isCreditExhaustedHttp, @@ -38,6 +39,7 @@ import { /** Re-export for callers/tests that import helpers from this module. */ export { CREDIT_EXCEEDED_MSG, + UPSTREAM_BILLING_MISMATCH_MSG, formatUpstreamMediaError, isCreditExhaustedHttp, } from './provider.js'; @@ -650,6 +652,37 @@ export class MarkusProvider implements MultiModalProviderInterface { return synced.remainingCu > 0 || synced.remainingUsd > 0; } + /** + * Resolve an upstream payment/credit HTTP status against Hub books. + * Only emit CU_EXCEEDED (+ credit modal) when Hub confirms remaining is zero. + */ + private async resolveCreditHttpError( + status: number, + errText: string, + alreadyRetried: boolean, + ): Promise<'retry'> { + const synced = await this.syncHubCredits({ force: true }); + const hubHasBudget = !!synced && (synced.remainingCu > 0 || synced.remainingUsd > 0); + const hubEmpty = !!synced && synced.remainingCu <= 0 && synced.remainingUsd <= 0; + + if (!alreadyRetried && hubHasBudget) return 'retry'; + + if (hubEmpty) { + fireCreditExhaustedEvent(); + throw new Error(CREDIT_EXCEEDED_MSG); + } + + // Hub still has budget (after retry) or sync failed — do not claim credits exhausted. + const detail = (errText || '') + .replace(/https?:\/\/[^\s]*openrouter\.ai[^\s]*/gi, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180); + throw new Error( + `${UPSTREAM_BILLING_MISMATCH_MSG} (HTTP ${status}${detail ? `: ${detail}` : ''})`, + ); + } + /** Soft-stop before network I/O when remaining credits are already known to be zero. */ private async assertCreditsAvailable(): Promise { const softZero = @@ -657,13 +690,22 @@ export class MarkusProvider implements MultiModalProviderInterface { || this.lastQuotaInfo?.cuRemaining === 0; if (!softZero) return; - if (await this.tryRecoverCredits()) return; + const synced = await this.syncHubCredits({ force: true }); + if (synced && (synced.remainingCu > 0 || synced.remainingUsd > 0)) return; - fireCreditExhaustedEvent(); - if (this.hubRemainingHint === 0) { - throw new Error('CU_EXCEEDED: Organization credits exhausted'); + if (synced && synced.remainingCu <= 0 && synced.remainingUsd <= 0) { + fireCreditExhaustedEvent(); + if (this.hubRemainingHint === 0) { + throw new Error('CU_EXCEEDED: Organization credits exhausted'); + } + throw new Error('CU_EXCEEDED: Credits exhausted'); + } + + // Sync unavailable — clear stale local zeros so we don't false-block. + this.hubRemainingHint = null; + if (this.lastQuotaInfo) { + this.lastQuotaInfo = { ...this.lastQuotaInfo, cuRemaining: -1 }; } - throw new Error('CU_EXCEEDED: Credits exhausted'); } // ------------------------------------------------------------------------- @@ -695,11 +737,8 @@ export class MarkusProvider implements MultiModalProviderInterface { if (!response.ok) { const errText = await response.text().catch(() => ''); if (isCreditExhaustedHttp(response.status, errText)) { - if (!_retried && await this.tryRecoverCredits()) { - return this.chat(request, true); - } - fireCreditExhaustedEvent(); - throw new Error(CREDIT_EXCEEDED_MSG); + const outcome = await this.resolveCreditHttpError(response.status, errText, _retried); + if (outcome === 'retry') return this.chat(request, true); } throw new Error(`Markus proxy error ${response.status}: ${errText}`); } @@ -795,11 +834,8 @@ export class MarkusProvider implements MultiModalProviderInterface { throw new Error(`${prefix}: ${errText}`); } if (isCreditExhaustedHttp(res.status, errText)) { - if (!_retried && await this.tryRecoverCredits()) { - return this.chatStream(request, onEvent, signal, true); - } - fireCreditExhaustedEvent(); - throw new Error(CREDIT_EXCEEDED_MSG); + const outcome = await this.resolveCreditHttpError(res.status, errText, _retried); + if (outcome === 'retry') return this.chatStream(request, onEvent, signal, true); } throw new Error(`Markus proxy error ${res.status}: ${errText}`); } diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts index ea5bb34e..b5640806 100644 --- a/packages/core/src/llm/provider.ts +++ b/packages/core/src/llm/provider.ts @@ -79,19 +79,44 @@ export function defaultVoiceForModel(model?: string): string | undefined { * Unwrap `{ error: { message } }`, keep voice enumerations intact, and cap length * so a huge voice list cannot blow the agent's context. */ -/** True when status/body indicate credits / key USD limit exhausted (not region/auth). */ +/** + * True when status/body look like OpenRouter *payment/credit* errors. + * + * Per OpenRouter docs (errors-and-debugging / limits): + * - 402 Payment Required → account or API key has insufficient credits (`payment_required`) + * - 429 → rate limit (NOT credits) + * - 403 → moderation / permission (NOT credits unless body explicitly says key/credit limit) + * - 409 Conflict → not used by OpenRouter for billing; never treat as credits + * + * Callers must still confirm with Hub remaining before surfacing CU_EXCEEDED to users — + * a stale OR key can 402 while Hub still shows budget. + */ export function isCreditExhaustedHttp(status: number, bodyText: string): boolean { - if (status === 402) return true; + if (status === 409 || status === 429) return false; const t = bodyText || ''; if (/CU_EXCEEDED|CU_MONTHLY_EXCEEDED/i.test(t)) return true; + // Official OR meaning of 402. + if (status === 402) { + if (!t.trim()) return true; + if (/payment_required|insufficient (credits?|quota|balance)|credits? (exhausted|exceeded)|key limit exceeded|quota exceeded/i.test(t)) { + return true; + } + // Generic 402 bodies still mean payment required per OR docs. + return !/rate.?limit|moderation|forbidden|unauthorized/i.test(t); + } + // Legacy / odd gateways sometimes put key-cap text on 400/403 — require explicit credit wording. if (status === 403 || status === 400) { - return /key limit exceeded|total limit|insufficient (credits?|quota|balance)|credits? (exhausted|exceeded)|quota exceeded/i.test(t); + return /key limit exceeded|insufficient (credits?|quota|balance)|credits? (exhausted|exceeded)|payment_required/i.test(t); } - return /key limit exceeded|total limit/i.test(t); + return false; } export const CREDIT_EXCEEDED_MSG = 'CU_EXCEEDED: Credits exhausted. Please top up or upgrade your plan.'; +/** Hub still has budget after an upstream 402 — do not claim the user is out of credits. */ +export const UPSTREAM_BILLING_MISMATCH_MSG = + 'MARKUS_UPSTREAM_ERROR: Upstream returned a payment/credit error, but Hub still shows remaining credits. Please retry shortly or switch model.'; + export function formatUpstreamMediaError(status: number, errText: string): string { if (isCreditExhaustedHttp(status, errText)) { return CREDIT_EXCEEDED_MSG; diff --git a/packages/core/src/llm/router.ts b/packages/core/src/llm/router.ts index 72caf857..96598a4a 100644 --- a/packages/core/src/llm/router.ts +++ b/packages/core/src/llm/router.ts @@ -125,7 +125,8 @@ export class LLMRouter { private autoSelect = false; private providerTiers: ProviderTier[] = []; private fallbackOrder: string[] = []; - private _autoFallback = true; + /** Off by default: fail loud so users see the real error and switch manually. */ + private _autoFallback = false; /** Health tracked per model: key = "providerName:modelId" */ private modelHealth = new Map(); /** Provider-level degradation for non-retryable (auth/billing) errors */ @@ -284,6 +285,61 @@ export class LLMRouter { /reasoning_content.*must be passed back/i.test(msg); } + /** + * Keep a requested model only when it belongs to the target provider. + * Prevents OpenRouter-style slugs (e.g. `z-ai/glm-5.2`) from being forced onto + * Ollama/Anthropic/etc. during auto-select or cross-provider fallback. + */ + private resolveModelForProvider(providerName: string, requestedModel?: string): string | undefined { + if (!requestedModel) return undefined; + const provider = this.providers.get(providerName); + if (!provider) return undefined; + + // Markus / OpenRouter catalogs use vendor/model slugs natively. + if (providerName === 'markus' || provider instanceof MarkusProvider) return requestedModel; + if (providerName === 'openrouter') return requestedModel; + + if (provider.model === requestedModel) return requestedModel; + + const catalog = this.getProviderModels(providerName); + if (catalog.some(m => m.id === requestedModel)) return requestedModel; + + log.warn('Ignoring cross-provider model id', { + providerName, + requestedModel, + using: provider.model || '(provider default)', + }); + return undefined; + } + + /** Strip request.model so the target provider uses its own configured model. */ + private requestForProvider(providerName: string, request: LLMRequest, model?: string): LLMRequest { + const resolved = this.resolveModelForProvider(providerName, model ?? request.model); + if (resolved) return { ...request, model: resolved }; + if (!request.model) return request; + const { model: _drop, ...rest } = request; + return rest; + } + + /** + * Prefer the original (primary) failure when fallback only produced a + * misleading "model not found" from forcing a foreign model id. + */ + private static preferPrimaryError(primary: unknown, last: unknown): Error { + const primaryMsg = primary instanceof Error ? primary.message : String(primary); + const lastMsg = last instanceof Error ? last.message : String(last); + if ( + primaryMsg && + lastMsg !== primaryMsg && + (/not available in your region/i.test(primaryMsg) || + /\b(401|403)\b/.test(primaryMsg) || + /model ['`][^'`]+['`] not found/i.test(lastMsg)) + ) { + return primary instanceof Error ? primary : new Error(primaryMsg); + } + return last instanceof Error ? last : new Error(lastMsg); + } + /** Detect rate-limit (429) errors which should use a shorter circuit breaker cooldown. */ private static isRateLimitError(error: unknown): boolean { const msg = error instanceof Error ? error.message : String(error); @@ -769,25 +825,29 @@ export class LLMRouter { * Pure lookup: explicit assignment -> default model -> any available provider. */ selectForCapability(capabilityType: ModelCapabilityType, request: LLMRequest, _sessionId?: string): { provider: string; model?: string } { + // Enabled in Settings (not merely circuit-healthy). Circuit-degraded providers + // are still returned so the real upstream error surfaces instead of a silent switch. + const isEnabled = (name: string) => this.providers.has(name) && !this.disabledProviders.has(name); + // 1. Check explicit assignment const assignment = this._capabilityRouting.assignments[capabilityType]; if (assignment) { - if (this.isAvailable(assignment.provider)) { + if (isEnabled(assignment.provider)) { return { provider: assignment.provider, model: assignment.model }; } - if (assignment.fallback && this.isAvailable(assignment.fallback.provider)) { + if (this._autoFallback && assignment.fallback && isEnabled(assignment.fallback.provider)) { log.warn(`Capability ${capabilityType} primary ${assignment.provider} unavailable, using fallback`); return { provider: assignment.fallback.provider, model: assignment.fallback.model }; } log.warn(`Capability ${capabilityType} assignment ${assignment.provider} unavailable, falling through to default`); } - // 2. Fallback to default model - if (this._routingDefaultModel && this.providers.has(this._routingDefaultModel.provider) && this.isAvailable(this._routingDefaultModel.provider)) { + // 2. Routing default model (honor even when circuit-degraded) + if (this._routingDefaultModel && isEnabled(this._routingDefaultModel.provider)) { return { provider: this._routingDefaultModel.provider, model: this._routingDefaultModel.model }; } - // 3. Final fallback: any available provider + // 3. Last resort: any provider return { provider: this.selectProvider(request) }; } @@ -917,10 +977,16 @@ export class LLMRouter { } private selectProvider(request: LLMRequest, explicit?: string): string { - // If an explicit provider is requested AND it is available, honour the request. - // If it is disabled/degraded, fall through to normal selection so we don't - // send traffic to a provider the user intentionally turned off. - if (explicit && this.isAvailable(explicit)) return explicit; + // Honour an explicit provider pin (Chat UI / agent override) even when the + // circuit breaker marked it degraded — fail loud with the real error instead + // of silently switching to another provider (which previously reused the + // foreign model id and produced confusing "model not found" errors). + if (explicit && this.providers.has(explicit) && !this.disabledProviders.has(explicit)) { + if (!this.isAvailable(explicit)) { + log.warn(`Explicit provider ${explicit} is circuit-degraded — still using it (no silent switch)`); + } + return explicit; + } if (explicit && this.disabledProviders.has(explicit)) { log.warn(`Explicit provider ${explicit} is disabled — falling through to auto-select`); } @@ -1135,6 +1201,8 @@ export class LLMRouter { if (!provider) { throw new Error(`LLM provider not found: ${primary}. Available: ${[...this.providers.keys()].join(', ')}`); } + routedModel = this.resolveModelForProvider(primary, routedModel); + request = this.requestForProvider(primary, request, routedModel); request = this.resolveMaxTokens(request, primary); log.debug(`Sending request to ${primary}`, { model: routedModel ?? provider.model, messageCount: request.messages.length }); @@ -1142,6 +1210,7 @@ export class LLMRouter { const span = startSpan('llm.chat', { provider: primary, model: routedModel ?? provider.model }); const startTime = Date.now(); let lastError: unknown = null; + let primaryError: unknown = null; // Try primary provider's active model (or routed model) try { @@ -1152,6 +1221,7 @@ export class LLMRouter { return response; } catch (error) { lastError = error; + primaryError = error; log.error(`LLM request failed for ${primary}:${routedModel ?? provider.model}`, { error: String(error) }); // CU_EXCEEDED / MARKUS_RATE_LIMITED are Markus-specific — do NOT fall back to BYOK providers @@ -1177,12 +1247,13 @@ export class LLMRouter { } } - // Fallback to other providers + // Fallback to other providers — always use that provider's own model for (const fallbackName of this.getFallbacks(primary)) { const fb = this.providers.get(fallbackName)!; + const fbRequest = this.requestForProvider(fallbackName, request); log.info(`Falling back to ${fallbackName}`, { model: fb.model }); try { - const { response, model } = await this.tryChat(fallbackName, request); + const { response, model } = await this.tryChat(fallbackName, fbRequest); span.end({ inputTokens: response.usage.inputTokens, outputTokens: response.usage.outputTokens, finishReason: response.finishReason }); log.info(`Fallback to ${fallbackName} succeeded`); this.emitLog(fallbackName, model, request, response, Date.now() - startTime); @@ -1193,9 +1264,10 @@ export class LLMRouter { } } - span.setError(lastError instanceof Error ? lastError : String(lastError)); + const finalError = LLMRouter.preferPrimaryError(primaryError, lastError); + span.setError(finalError); span.end(); - throw lastError; + throw finalError; } } @@ -1284,12 +1356,15 @@ export class LLMRouter { if (!provider) { throw new Error(`LLM provider not found: ${primary}. Available: ${[...this.providers.keys()].join(', ')}`); } + routedModel = this.resolveModelForProvider(primary, routedModel); + request = this.requestForProvider(primary, request, routedModel); request = this.resolveMaxTokens(request, primary); const span = startSpan('llm.chatStream', { provider: primary, model: routedModel ?? provider.model }); const startTime = Date.now(); let lastError: unknown = null; + let primaryError: unknown = null; // Try primary provider's routed model (or its default model) try { @@ -1299,12 +1374,13 @@ export class LLMRouter { return response; } catch (error) { lastError = error; + primaryError = error; if (signal?.aborted) { span.setError(lastError instanceof Error ? lastError : String(lastError)); span.end(); throw lastError; } - log.error(`LLM stream request failed for ${primary}:${provider.model}`, { error: String(error) }); + log.error(`LLM stream request failed for ${primary}:${routedModel ?? provider.model}`, { error: String(error) }); // CU_EXCEEDED / MARKUS_RATE_LIMITED are Markus-specific — do NOT fall back to BYOK providers if (LLMRouter.isCUExceededError(error) || LLMRouter.isMarkusRateLimited(error)) { @@ -1336,11 +1412,13 @@ export class LLMRouter { } } - // Fallback to other providers + // Fallback to other providers — always use that provider's own model for (const fallbackName of this.getFallbacks(primary)) { - log.info(`Stream fallback to ${fallbackName}`); + const fb = this.providers.get(fallbackName)!; + const fbRequest = this.requestForProvider(fallbackName, request); + log.info(`Stream fallback to ${fallbackName}`, { model: fb.model }); try { - const { response, model } = await this.tryStream(fallbackName, request, onEvent, signal); + const { response, model } = await this.tryStream(fallbackName, fbRequest, onEvent, signal); span.end({ inputTokens: response.usage.inputTokens, outputTokens: response.usage.outputTokens, finishReason: response.finishReason }); this.emitLog(fallbackName, model, request, response, Date.now() - startTime); log.info(`Stream fallback to ${fallbackName} succeeded`); @@ -1352,9 +1430,10 @@ export class LLMRouter { } } - span.setError(lastError instanceof Error ? lastError : String(lastError)); + const finalError = LLMRouter.preferPrimaryError(primaryError, lastError); + span.setError(finalError); span.end(); - throw lastError; + throw finalError; } } diff --git a/packages/core/src/mailbox.ts b/packages/core/src/mailbox.ts index 2d41e441..76786ae2 100644 --- a/packages/core/src/mailbox.ts +++ b/packages/core/src/mailbox.ts @@ -87,11 +87,15 @@ export class AgentMailbox { let expired = 0; const now = Date.now(); const queuedItems = this.persistence?.loadQueued?.(this.agentId) ?? []; + const staleTypes = new Set(TRIAGE_STALE_DROP_TYPES); for (const item of queuedItems) { if (this.queue.some(q => q.id === item.id)) continue; const age = now - new Date(item.queuedAt).getTime(); - if (age > MAILBOX_QUEUED_TTL_MS) { + // Hard TTL for everything; informational/callback ghosts expire sooner so a + // long-running agent does not keep replaying stale background completions. + const ttl = staleTypes.has(item.sourceType) ? TRIAGE_STALE_INFO_TTL_MS : MAILBOX_QUEUED_TTL_MS; + if (age > ttl) { this.persistence?.updateStatus(item.id, 'dropped'); expired++; continue; @@ -112,6 +116,8 @@ export class AgentMailbox { merged, }); } + // If the attention loop is already waiting, restored items must wake it. + if (restored > 0) this.wakeIdleLoop(); return { dropped, restored, expired, merged }; } @@ -410,20 +416,38 @@ export class AgentMailbox { * Block until an item is available, then dequeue it. * Throws `MailboxCancelledError` if woken by `cancelWait()` with an empty queue * (normal shutdown path — the attention loop should catch and exit cleanly). + * + * Arms the idle waiter BEFORE re-checking the queue so an enqueue that lands + * between the empty check and wait cannot lose its wakeup (classic lost-wakeup + * race — leaves attention "idle" forever with items still queued). */ async dequeueAsync(): Promise { - const item = this.dequeue(); - if (item) return item; - - await new Promise(resolve => { - this.idleResolve = resolve; - }); + for (;;) { + const item = this.dequeue(); + if (item) return item; + + await new Promise(resolve => { + this.idleResolve = resolve; + // Close the race: work may have arrived after the empty dequeue above. + if (this.queue.length > 0) { + this.idleResolve = undefined; + resolve(); + } + }); - const afterWake = this.dequeue(); - if (!afterWake) { + const afterWake = this.dequeue(); + if (afterWake) return afterWake; + // cancelWait() (or a spurious wake) with an empty queue throw new MailboxCancelledError(); } - return afterWake; + } + + /** + * Nudge the attention loop if it is parked idle while work is already queued. + * Used by the watchdog / recovery paths as a belt-and-suspenders wakeup. + */ + nudgeIfPending(): void { + if (this.queue.length > 0) this.wakeIdleLoop(); } /** @@ -503,10 +527,15 @@ export class AgentMailbox { /** * Drop an item from the queue. + * If the item is not in the in-memory queue (ghost / already dequeued), still + * mark it dropped in persistence so agent tools can clear orphans idempotently. */ drop(itemId: string): MailboxItem | undefined { const idx = this.queue.findIndex(i => i.id === itemId); - if (idx === -1) return undefined; + if (idx === -1) { + this.persistence?.updateStatus(itemId, 'dropped'); + return undefined; + } const [item] = this.queue.splice(idx, 1); item.status = 'dropped'; diff --git a/packages/core/src/tools/feishu.ts b/packages/core/src/tools/feishu.ts new file mode 100644 index 00000000..877b6372 --- /dev/null +++ b/packages/core/src/tools/feishu.ts @@ -0,0 +1,136 @@ +import { access } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { FeishuClient, type ReceiveIdType } from '@markus/comms'; +import { createLogger } from '@markus/shared'; +import type { AgentToolHandler } from '../agent.js'; +import { toolErr, toolOk } from './result.js'; + +const log = createLogger('feishu-tools'); + +export interface FeishuToolsConfig { + appId: string; + appSecret: string; + domain?: string; + /** Default chat_id for group notifications when the agent omits receive_id */ + defaultChatId?: string; + /** Default open_id for p2p ("send to my Feishu") when the agent omits receive_id */ + defaultOpenId?: string; +} + +const RECEIVE_ID_TYPES: ReceiveIdType[] = ['chat_id', 'open_id', 'user_id', 'union_id']; + +function resolveReceiveTarget( + args: Record, + cfg: FeishuToolsConfig, +): { receiveId: string; idType: ReceiveIdType } | { error: string } { + const receiveId = typeof args['receive_id'] === 'string' ? args['receive_id'].trim() : ''; + const rawType = typeof args['receive_id_type'] === 'string' ? args['receive_id_type'].trim() : ''; + const idType = (RECEIVE_ID_TYPES.includes(rawType as ReceiveIdType) ? rawType : '') as ReceiveIdType | ''; + + if (receiveId) { + return { receiveId, idType: idType || 'chat_id' }; + } + if (cfg.defaultOpenId) { + return { receiveId: cfg.defaultOpenId, idType: 'open_id' }; + } + if (cfg.defaultChatId) { + return { receiveId: cfg.defaultChatId, idType: 'chat_id' }; + } + return { + error: + 'Missing receive_id. Pass receive_id + receive_id_type (chat_id|open_id), or configure a Feishu notification target / open_id in Settings → Feishu.', + }; +} + +export function createFeishuTools(cfg: FeishuToolsConfig): AgentToolHandler[] { + const client = new FeishuClient({ + appId: cfg.appId, + appSecret: cfg.appSecret, + domain: cfg.domain, + }); + + return [ + { + name: 'feishu_send_message', + description: + 'Send a text message to Feishu (Lark). Prefer this for plain text. ' + + 'When the user says "send to my Feishu" and chat_list is empty, use receive_id_type=open_id ' + + '(resolve via feishu-lark__calendar_v4_calendar_primary or contact APIs). ' + + 'If Settings has a default notify target, receive_id can be omitted.', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string', description: 'Message text to send' }, + receive_id: { + type: 'string', + description: 'Feishu chat_id or open_id (optional if a default target is configured)', + }, + receive_id_type: { + type: 'string', + enum: RECEIVE_ID_TYPES, + description: 'ID type for receive_id. Default: chat_id (or open_id when using the default open_id).', + }, + }, + required: ['text'], + }, + async execute(args: Record): Promise { + const text = typeof args['text'] === 'string' ? args['text'] : ''; + if (!text.trim()) return toolErr('text is required'); + const target = resolveReceiveTarget(args, cfg); + if ('error' in target) return toolErr(target.error); + try { + const messageId = await client.sendTextMessage(target.receiveId, text, target.idType); + log.info('feishu_send_message ok', { messageId, idType: target.idType }); + return toolOk({ status: 'sent', message_id: messageId, receive_id_type: target.idType }); + } catch (err) { + return toolErr(`Feishu send failed: ${String(err)}`); + } + }, + }, + { + name: 'feishu_send_image', + description: + 'Upload a local image file and send it as a Feishu IM image message. ' + + 'Use this for generated images under ~/.markus/generated/images/ — Feishu MCP cannot upload images. ' + + 'When the user says "send this image to my Feishu", prefer receive_id_type=open_id if no group chat_id is known.', + inputSchema: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Absolute path to a local image file (png/jpg/webp/gif)', + }, + receive_id: { + type: 'string', + description: 'Feishu chat_id or open_id (optional if a default target is configured)', + }, + receive_id_type: { + type: 'string', + enum: RECEIVE_ID_TYPES, + description: 'ID type for receive_id. Default: chat_id (or open_id when using the default open_id).', + }, + }, + required: ['file_path'], + }, + async execute(args: Record): Promise { + const filePathRaw = typeof args['file_path'] === 'string' ? args['file_path'].trim() : ''; + if (!filePathRaw) return toolErr('file_path is required'); + const filePath = resolve(filePathRaw); + try { + await access(filePath); + } catch { + return toolErr(`Image file not found: ${filePath}`); + } + const target = resolveReceiveTarget(args, cfg); + if ('error' in target) return toolErr(target.error); + try { + const messageId = await client.sendLocalImage(target.receiveId, filePath, target.idType); + log.info('feishu_send_image ok', { messageId, filePath, idType: target.idType }); + return toolOk({ status: 'sent', message_id: messageId, file_path: filePath, receive_id_type: target.idType }); + } catch (err) { + return toolErr(`Feishu image send failed: ${String(err)}`); + } + }, + }, + ]; +} diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 60e3ee91..827f8e2c 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -25,6 +25,7 @@ export { createRecallTool, type RecallContext, type RecallCallbacks } from './re export { createMailboxTools, type MailboxToolContext } from './mailbox-tools.js'; export { createWorkflowTools, type WorkflowToolsContext } from './workflow-tools.js'; export { createMultiModalTools, type MultiModalToolsContext } from './multimodal.js'; +export { createFeishuTools, type FeishuToolsConfig } from './feishu.js'; export { toolOk, toolErr, isToolErrorResult } from './result.js'; export { parseOpenRightPanelArgs, diff --git a/packages/core/src/tools/mailbox-tools.ts b/packages/core/src/tools/mailbox-tools.ts index e3563851..7ab3009c 100644 --- a/packages/core/src/tools/mailbox-tools.ts +++ b/packages/core/src/tools/mailbox-tools.ts @@ -174,11 +174,18 @@ export function createMailboxTools(ctx: MailboxToolContext): AgentToolHandler[] if (!itemId || !reason) { return JSON.stringify({ status: 'error', error: 'item_id and reason are required' }); } + const before = ctx.getMindState().queuedItems.some(i => i.id === itemId); const ok = ctx.dropItem(itemId, reason); if (!ok) { - return JSON.stringify({ status: 'error', error: 'Item not found, not queued, or is a protected human_chat item' }); + return JSON.stringify({ status: 'error', error: 'Item is a protected human_chat item or not droppable while processing' }); } - return JSON.stringify({ status: 'dropped', item_id: itemId }); + return JSON.stringify({ + status: before ? 'dropped' : 'already_resolved', + item_id: itemId, + note: before + ? undefined + : 'Item was not in the live queue (already handled or orphaned); marked resolved.', + }); }, }, diff --git a/packages/core/src/tools/ui-layout.ts b/packages/core/src/tools/ui-layout.ts index c6960dbc..c5cf8760 100644 --- a/packages/core/src/tools/ui-layout.ts +++ b/packages/core/src/tools/ui-layout.ts @@ -50,9 +50,21 @@ export function parseOpenRightPanelArgs( } if (url) { - let normalized = url; - if (normalized !== 'about:blank' && !/^https?:\/\//i.test(normalized) && !normalized.startsWith('file:')) { - normalized = `https://${normalized}`; + let normalized = url.trim(); + if (normalized !== 'about:blank' && !/^[a-z][a-z0-9+.-]*:/i.test(normalized)) { + // Absolute local paths → file://; bare hosts → https:// + if ( + normalized.startsWith('/') + || /^[a-zA-Z]:[\\/]/.test(normalized) + || normalized.startsWith('\\\\') + ) { + const p = normalized.replace(/\\/g, '/'); + normalized = /^[a-zA-Z]:\//.test(p) + ? `file:///${encodeURI(p).replace(/#/g, '%23')}` + : `file://${encodeURI(p).replace(/#/g, '%23')}`; + } else { + normalized = `https://${normalized}`; + } } return { ok: true, panel: { kind: 'url', url: normalized, title } }; } diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts index 74756d54..2e426922 100644 --- a/packages/core/src/tools/web-search.ts +++ b/packages/core/src/tools/web-search.ts @@ -576,9 +576,17 @@ async function searchMarkus(query: string, maxResults: number): Promise ''); if (!res.ok) { - if (res.status === 402 || /CU_EXCEEDED|key limit exceeded|total limit/i.test(bodyText)) { + // OpenRouter 402 = payment_required; 429 = rate limit (not credits). Prefer explicit + // credit wording so a transient upstream 402 is not always "credits exhausted". + if ( + /CU_EXCEEDED|CU_MONTHLY_EXCEEDED/i.test(bodyText) + || (res.status === 402 && /insufficient (credits?|quota|balance)|key limit exceeded|payment_required|credits? (exhausted|exceeded)/i.test(bodyText || 'payment_required')) + ) { throw new Error('CU_EXCEEDED: Credits exhausted. Please top up or upgrade your plan.'); } + if (res.status === 429) { + throw new Error(`MARKUS_RATE_LIMITED: ${bodyText.slice(0, 200) || 'Rate limit exceeded'}`); + } let detail = ''; try { const parsed = JSON.parse(bodyText) as { error?: { message?: string }; message?: string }; diff --git a/packages/core/test/llm-markus-provider.test.ts b/packages/core/test/llm-markus-provider.test.ts index e991e996..c5d48409 100644 --- a/packages/core/test/llm-markus-provider.test.ts +++ b/packages/core/test/llm-markus-provider.test.ts @@ -166,13 +166,33 @@ describe('MarkusProvider CU tracking', () => { expect(provider.getCostUsdStats().lastCostUsd).toBe(0.01); }); - it('throws CU_EXCEEDED on 402', async () => { + it('throws CU_EXCEEDED on 402 only when Hub confirms zero remaining', async () => { + const p = new MarkusProvider({ + apiKey: 'sk-or-test', + baseUrl: 'https://openrouter.ai/api/v1', + hubUrl: 'http://hub.test', + hubToken: 'hub_jwt', + model: 'deepseek/deepseek-chat', + }); + vi.mocked(fetch) + .mockResolvedValueOnce(mockResponse({ error: { message: 'quota exceeded' } }, 402)) + .mockResolvedValueOnce(mockResponse({ + ok: true, + remainingCu: 0, + openrouter: { remainingUsd: 0 }, + }, 200)); + await expect( + p.chat({ messages: [{ role: 'user', content: 'hi' }] }), + ).rejects.toThrow('CU_EXCEEDED:'); + }); + + it('throws MARKUS_UPSTREAM_ERROR on 402 when Hub sync is unavailable', async () => { vi.mocked(fetch).mockResolvedValueOnce( mockResponse({ error: { message: 'quota exceeded' } }, 402), ); await expect( provider.chat({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toThrow('CU_EXCEEDED:'); + ).rejects.toThrow('MARKUS_UPSTREAM_ERROR:'); }); it('on 402: calls Hub cu/sync and retries once when remaining > 0', async () => { @@ -221,6 +241,33 @@ describe('MarkusProvider CU tracking', () => { expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2); }); + it('on 402: does not claim CU_EXCEEDED when Hub still has remaining after retry', async () => { + const p = new MarkusProvider({ + apiKey: 'sk-or-test', + baseUrl: 'https://openrouter.ai/api/v1', + hubUrl: 'http://hub.test', + hubToken: 'hub_jwt', + model: 'deepseek/deepseek-chat', + }); + vi.mocked(fetch) + .mockResolvedValueOnce(mockResponse({ error: { message: 'key limit exceeded' } }, 402)) + .mockResolvedValueOnce(mockResponse({ + ok: true, + remainingCu: 5000, + openrouter: { remainingUsd: 5 }, + }, 200)) + .mockResolvedValueOnce(mockResponse({ error: { message: 'key limit exceeded' } }, 402)) + .mockResolvedValueOnce(mockResponse({ + ok: true, + remainingCu: 5000, + openrouter: { remainingUsd: 5 }, + }, 200)); + + await expect( + p.chat({ messages: [{ role: 'user', content: 'hi' }] }), + ).rejects.toThrow(/MARKUS_UPSTREAM_ERROR:/); + }); + it('throws MARKUS_RATE_LIMITED on 429', async () => { vi.mocked(fetch).mockResolvedValueOnce( mockResponse({ error: { message: 'rate limited' } }, 429), @@ -489,7 +536,7 @@ describe('MarkusProvider CU tracking', () => { expect(fetch).not.toHaveBeenCalled(); }); - it('maps OpenRouter 403 key-limit to CU_EXCEEDED without vendor URL', async () => { + it('maps OpenRouter 403 key-limit without claiming CU_EXCEEDED when Hub budget unknown', async () => { const dual = new MarkusProvider({ apiKey: 'sk-or-member', baseUrl: 'https://openrouter.ai/api/v1', @@ -512,7 +559,7 @@ describe('MarkusProvider CU tracking', () => { } catch (e) { err = e; } - expect(String(err)).toMatch(/^Error: CU_EXCEEDED:/); + expect(String(err)).toMatch(/^Error: MARKUS_UPSTREAM_ERROR:/); expect(String(err)).not.toMatch(/openrouter\.ai/i); }); @@ -754,20 +801,43 @@ describe('soft-stop when remaining credits are 0', () => { clearMarkusModelListCache(); }); - it('refuses chat when hubRemainingHint is 0 and Hub sync is unavailable', async () => { + it('does not soft-block when hubRemainingHint is 0 but Hub sync is unavailable', async () => { + // Without a confirmed Hub empty balance, do not claim CU_EXCEEDED. + vi.mocked(fetch).mockResolvedValue(mockResponse(chatCompletionBody('ok'), 200)); + const p = new MarkusProvider({ + provider: 'markus', + apiKey: 'sk-or-test', + baseUrl: 'https://openrouter.ai/api/v1', + model: 'deepseek/deepseek-chat', + }); + p.setHubRemainingHint(0); + const res = await p.chat({ + messages: [{ role: 'user', content: 'hi' }], + model: 'deepseek/deepseek-chat', + }); + expect(res.content).toBe('ok'); + expect(fetch).toHaveBeenCalled(); + }); + + it('refuses chat when Hub sync confirms remaining is zero', async () => { const p = new MarkusProvider({ provider: 'markus', apiKey: 'sk-or-test', baseUrl: 'https://openrouter.ai/api/v1', + hubUrl: 'http://hub.test', + hubToken: 'hub_jwt', model: 'deepseek/deepseek-chat', }); p.setHubRemainingHint(0); + vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ + ok: true, + remainingCu: 0, + openrouter: { remainingUsd: 0 }, + }, 200)); await expect(p.chat({ messages: [{ role: 'user', content: 'hi' }], model: 'deepseek/deepseek-chat', })).rejects.toThrow(/CU_EXCEEDED/); - // No hubUrl/hubToken → sync short-circuits without fetch - expect(fetch).not.toHaveBeenCalled(); }); it('allows chat again after hint is cleared', async () => { diff --git a/packages/core/test/llm-router.test.ts b/packages/core/test/llm-router.test.ts index 028fe394..abc2f7c3 100644 --- a/packages/core/test/llm-router.test.ts +++ b/packages/core/test/llm-router.test.ts @@ -340,6 +340,7 @@ describe('LLMRouter utilities', () => { router.registerProvider('primary', mockProvider('primary', 'm1')); router.registerProvider('fallback', mockProvider('fallback', 'm2')); router.setProviderEnabled('primary', false); + router.setAutoFallback(true); router.setCapabilityRouting({ assignments: { text: { @@ -354,6 +355,53 @@ describe('LLMRouter utilities', () => { expect(selected.provider).toBe('fallback'); }); + it('does not use assignment fallback when autoFallback is off', () => { + const router = new LLMRouter('anthropic'); + router.registerProvider('primary', mockProvider('primary', 'm1')); + router.registerProvider('fallback', mockProvider('fallback', 'm2')); + router.registerProvider('anthropic', mockProvider('anthropic', 'claude')); + router.setProviderEnabled('primary', false); + router.setAutoFallback(false); + router.setCapabilityRouting({ + assignments: { + text: { + provider: 'primary', + model: 'm1', + fallback: { provider: 'fallback', model: 'm2' }, + }, + }, + }); + + const selected = router.selectForCapability('text', { messages: [{ role: 'user', content: 'Hi' }] }); + expect(selected.provider).not.toBe('fallback'); + }); + + it('honours explicit provider pin even when circuit-degraded', async () => { + const router = new LLMRouter('markus'); + const markus = mockProvider('markus', 'deepseek/deepseek-v4-flash', async () => { + throw new Error('Markus proxy error 403: {"error":{"message":"This model is not available in your region.","code":403}}'); + }); + const ollama = mockProvider('ollama', 'qwen3.6'); + router.registerProvider('markus', markus); + router.registerProvider('ollama', ollama); + router.setAutoFallback(false); + router.setFallbackOrder(['markus', 'ollama']); + + // First call degrades markus + await expect(router.chat( + { messages: [{ role: 'user', content: 'Hi' }], model: 'anthropic/claude-opus-5' }, + 'markus', + )).rejects.toThrow(/not available in your region/); + + // Second call must still hit markus (not silently switch to ollama with foreign model id) + await expect(router.chat( + { messages: [{ role: 'user', content: 'Hi again' }], model: 'anthropic/claude-opus-5' }, + 'markus', + )).rejects.toThrow(/not available in your region/); + expect(ollama.chat).not.toHaveBeenCalled(); + expect(markus.chat).toHaveBeenCalledTimes(2); + }); + it('resolveModalityProvider returns assigned provider', () => { const router = new LLMRouter('anthropic'); const minimax = mockProvider('minimax', 'MiniMax-M3', undefined, { imageGeneration: true }) as MultiModalProviderInterface; diff --git a/packages/core/test/mailbox-core.test.ts b/packages/core/test/mailbox-core.test.ts index 0b587579..ab2a0edb 100644 --- a/packages/core/test/mailbox-core.test.ts +++ b/packages/core/test/mailbox-core.test.ts @@ -168,6 +168,38 @@ describe('AgentMailbox async and persistence', () => { await expect(waitPromise).rejects.toBeInstanceOf(MailboxCancelledError); }); + it('dequeueAsync does not lose wakeup when item arrives during arming', async () => { + const { mailbox } = makeMailbox(); + // Simulate the classic race: enqueue lands after the empty check but the + // waiter must still observe it (re-check after arming idleResolve). + const waitPromise = mailbox.dequeueAsync(); + // Enqueue synchronously on the same turn as the wait promise microtask. + mailbox.enqueue('heartbeat', { summary: 'race', content: 'body' }); + const item = await waitPromise; + expect(item.payload.summary).toBe('race'); + expect(mailbox.depth).toBe(0); + }); + + it('nudgeIfPending wakes an idle waiter when queue is non-empty', async () => { + const { mailbox } = makeMailbox(); + // Manually insert without wakeIdleLoop by using recover path semantics: + // enqueue wakes; instead, put an item then cancel the first wait and re-wait. + mailbox.enqueue('heartbeat', { summary: 'pending', content: 'body' }); + // Drain without consuming via a waiter that is not armed — depth stays 1 + // after we cancel a wait on empty… so dequeue once into processing then putBack. + const dequeued = mailbox.dequeue(); + expect(dequeued).toBeDefined(); + mailbox.putBack(dequeued!); + expect(mailbox.depth).toBe(1); + + const waitPromise = mailbox.dequeueAsync(); + // If the waiter armed on a non-empty queue, it should resolve immediately + // via the post-arm re-check (no explicit nudge needed). Nudge is belt-and-suspenders. + mailbox.nudgeIfPending(); + const item = await waitPromise; + expect(item.payload.summary).toBe('pending'); + }); + it('recoverStaleItems restores queued items and drops expired ones', () => { const oldQueuedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); const freshQueuedAt = new Date().toISOString(); diff --git a/packages/core/test/ui-layout-tools.test.ts b/packages/core/test/ui-layout-tools.test.ts index 0a4fad99..2dd0d9c0 100644 --- a/packages/core/test/ui-layout-tools.test.ts +++ b/packages/core/test/ui-layout-tools.test.ts @@ -16,6 +16,18 @@ describe('parseOpenRightPanelArgs', () => { if (r.ok) expect(r.panel).toEqual({ kind: 'file', path: '/tmp/a.md', title: undefined }); }); + it('treats absolute path in url as file://', () => { + const r = parseOpenRightPanelArgs({ url: '/Users/me/logo.png' }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.panel).toEqual({ + kind: 'url', + url: 'file:///Users/me/logo.png', + title: undefined, + }); + } + }); + it('parses deliverable_id', () => { const r = parseOpenRightPanelArgs({ deliverable_id: 'del_1' }); expect(r.ok).toBe(true); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5685dd6e..2d534527 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -35,5 +35,5 @@ "esbuild": "^0.27.4", "typescript": "^5.9.3" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/desktop/src/embedded-browser.ts b/packages/desktop/src/embedded-browser.ts index 994bf2ea..80111ee6 100644 --- a/packages/desktop/src/embedded-browser.ts +++ b/packages/desktop/src/embedded-browser.ts @@ -8,11 +8,53 @@ * Agent control: webContents.debugger speaks CDP — the same protocol used by * chrome-devtools-mcp / the Chrome extension bridge. */ +import { existsSync, statSync } from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { WebContentsView, session, type BrowserWindow } from 'electron'; import { getMainWindow } from './window.js'; const PARTITION = 'persist:markus-embedded-browser'; +/** + * Accept http(s), file://, about:blank, and bare filesystem paths so the + * address bar / agent tools can open local files (e.g. /Users/.../logo.png). + */ +export function normalizeEmbeddedBrowserUrl(raw: string): string { + const next = raw.trim(); + if (!next) return next; + if (next === 'about:blank') return next; + + if (/^[a-z][a-z0-9+.-]*:/i.test(next)) { + if (/^file:/i.test(next)) { + try { return new URL(next).href; } catch { /* repair below */ } + const rest = next.replace(/^file:/i, '').replace(/\\/g, '/'); + const path = rest.replace(/^\/\/(localhost)?/i, '') || rest; + const abs = path.startsWith('/') || /^[a-zA-Z]:\//.test(path) + ? (path.startsWith('/') && /^\/[a-zA-Z]:\//.test(path) ? path.slice(1) : path) + : `/${path}`; + try { return pathToFileURL(abs).href; } catch { return next; } + } + return next; + } + + // Absolute local paths + if (next.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(next) || next.startsWith('\\\\')) { + try { return pathToFileURL(next).href; } catch { return next; } + } + + return `https://${next}`; +} + +/** If url is a local file:// directory, return its filesystem path; else null. */ +export function localDirectoryPathFromUrl(url: string): string | null { + if (!url || !/^file:/i.test(url)) return null; + try { + const p = fileURLToPath(url); + if (existsSync(p) && statSync(p).isDirectory()) return p; + } catch { /* ignore */ } + return null; +} + /** Working viewport while the native view is hidden (agent CDP / layout). */ const DEFAULT_VIEWPORT = { width: 1280, height: 800 }; @@ -24,6 +66,17 @@ interface BrowserSlot { visible: boolean; /** Defer first load until a non-zero working viewport exists. */ pendingUrl?: string; + /** True while webContents is loading a document. */ + isLoading: boolean; + /** Last load failure message (cleared on next successful start). */ + loadError?: string; + /** + * When set, navigation targeted a local directory. Chromium cannot render + * folders — UI should show a friendly prompt and hide the native view. + */ + directoryPath?: string; + /** file:// URL corresponding to directoryPath (for the address bar). */ + directoryUrl?: string; /** True after agent CDP Emulation.setDeviceMetricsOverride; cleared when UI size changes. */ hasDeviceMetricsOverride?: boolean; lastUiX?: number; @@ -81,22 +134,52 @@ function ensureWorkingViewport(slot: BrowserSlot): void { function flushPendingUrl(slot: BrowserSlot): void { if (!slot.pendingUrl) return; ensureWorkingViewport(slot); - const pending = slot.pendingUrl; + const pending = normalizeEmbeddedBrowserUrl(slot.pendingUrl); slot.pendingUrl = undefined; + if (applyDirectoryNavigation(slot, pending)) return; void slot.view.webContents.loadURL(pending).catch(() => {}); } +/** Intercept local directory navigations — Chromium returns ERR_FILE_NOT_FOUND. */ +function applyDirectoryNavigation(slot: BrowserSlot, url: string): boolean { + const dir = localDirectoryPathFromUrl(url); + if (!dir) { + slot.directoryPath = undefined; + slot.directoryUrl = undefined; + return false; + } + slot.directoryPath = dir; + slot.directoryUrl = url; + slot.isLoading = false; + slot.loadError = undefined; + try { slot.view.webContents.stop(); } catch { /* ignore */ } + // Do not loadURL — React shows a folder prompt while the native view is hidden. + emitPageEvent({ + type: 'directory', + pageId: slot.pageId, + browserId: slot.id, + url, + directoryPath: dir, + isLoading: false, + title: dir.split(/[/\\]/).filter(Boolean).pop() || dir, + }); + return true; +} + const slots = new Map(); const pageIdToSlotId = new Map(); let nextPageId = 1; let selectedPageId: number | null = null; export type EmbeddedPageListener = (event: { - type: 'opened' | 'closed' | 'navigated' | 'selected'; + type: 'opened' | 'closed' | 'navigated' | 'selected' | 'loading' | 'loaded' | 'load-failed' | 'directory'; pageId: number; browserId: string; url?: string; title?: string; + isLoading?: boolean; + error?: string; + directoryPath?: string; }) => void; const pageListeners = new Set(); @@ -129,6 +212,29 @@ function allocatePageId(id: string): number { return pageId; } +/** + * Open target=_blank / window.open into a right-panel tab instead of an OS popup. + * Emits `opened` so the renderer creates a tab (browserId must not be `eb_*`). + */ +function openUrlInNewEmbeddedTab(rawUrl: string): void { + const target = normalizeEmbeddedBrowserUrl(rawUrl || 'about:blank'); + if (!target) return; + // Deny non-navigable schemes (javascript:, etc.) + if (!/^(https?:|file:|about:)/i.test(target)) return; + const newId = `rb_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + const created = createEmbeddedBrowser(newId, target); + if (created.ok && created.pageId != null) { + selectedPageId = created.pageId; + emitPageEvent({ + type: 'selected', + pageId: created.pageId, + browserId: newId, + url: target, + title: target === 'about:blank' ? 'New Tab' : target, + }); + } +} + export function createEmbeddedBrowser(id: string, url?: string): { ok: boolean; pageId?: number; error?: string } { try { const win = getWin(); @@ -159,6 +265,58 @@ export function createEmbeddedBrowser(id: string, url?: string): { ok: boolean; win.contentView.addChildView(view); try { (view as ViewWithVisibility).setVisible?.(false); } catch { /* ignore */ } + // Links with target=_blank / window.open → new right-panel tab (no popup window). + view.webContents.setWindowOpenHandler(({ url: openUrl }) => { + const next = (openUrl || '').trim(); + if (next && next !== 'about:blank') { + openUrlInNewEmbeddedTab(next); + return { action: 'deny' }; + } + // window.open() with no URL often uses about:blank then navigates. + // Allow a hidden guest briefly, then steal the first real navigation into a tab. + return { + action: 'allow', + overrideBrowserWindowOptions: { + show: false, + width: 0, + height: 0, + webPreferences: { + partition: PARTITION, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }, + }; + }); + view.webContents.on('did-create-window', (childWindow) => { + const child = childWindow.webContents; + let captured = false; + const capture = (navUrl: string) => { + if (captured) return; + const next = (navUrl || '').trim(); + if (!next || next === 'about:blank') return; + captured = true; + try { childWindow.close(); } catch { /* ignore */ } + openUrlInNewEmbeddedTab(next); + }; + child.on('will-navigate', (e, navUrl) => { + e.preventDefault(); + capture(navUrl); + }); + child.on('did-navigate', (_e, navUrl) => capture(navUrl)); + child.on('page-title-updated', () => { + const u = child.getURL(); + if (u && u !== 'about:blank') capture(u); + }); + // Safety: never leave a hidden popup around. + setTimeout(() => { + if (!captured) { + try { childWindow.close(); } catch { /* ignore */ } + } + }, 15_000); + }); + view.webContents.on('page-title-updated', (_e, title) => { emitPageEvent({ type: 'navigated', pageId, browserId: id, url: view.webContents.getURL(), title }); }); @@ -168,6 +326,50 @@ export function createEmbeddedBrowser(id: string, url?: string): { ok: boolean; view.webContents.on('did-navigate-in-page', (_e, navUrl) => { emitPageEvent({ type: 'navigated', pageId, browserId: id, url: navUrl, title: view.webContents.getTitle() }); }); + view.webContents.on('did-start-loading', () => { + const s = slots.get(id); + if (!s) return; + s.isLoading = true; + s.loadError = undefined; + emitPageEvent({ + type: 'loading', + pageId, + browserId: id, + url: view.webContents.getURL(), + isLoading: true, + }); + }); + view.webContents.on('did-stop-loading', () => { + const s = slots.get(id); + if (!s) return; + s.isLoading = false; + emitPageEvent({ + type: 'loaded', + pageId, + browserId: id, + url: view.webContents.getURL(), + title: view.webContents.getTitle(), + isLoading: false, + }); + }); + view.webContents.on('did-fail-load', (_e, _code, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame) return; + const s = slots.get(id); + if (!s) return; + s.isLoading = false; + const failedUrl = validatedURL || view.webContents.getURL(); + // Fallback: directory navigations that slipped past the pre-check. + if (applyDirectoryNavigation(s, normalizeEmbeddedBrowserUrl(failedUrl))) return; + s.loadError = errorDescription || 'Load failed'; + emitPageEvent({ + type: 'load-failed', + pageId, + browserId: id, + url: failedUrl, + isLoading: false, + error: s.loadError, + }); + }); view.webContents.on('did-finish-load', () => { const s = slots.get(id); if (s?.visible) alignLayoutViewportToUi(s); @@ -178,7 +380,8 @@ export function createEmbeddedBrowser(id: string, url?: string): { ok: boolean; pageId, view, visible: false, - pendingUrl: url || undefined, + pendingUrl: url ? normalizeEmbeddedBrowserUrl(url) : undefined, + isLoading: false, lastUiWidth: DEFAULT_VIEWPORT.width, lastUiHeight: DEFAULT_VIEWPORT.height, }; @@ -313,7 +516,9 @@ export function navigateEmbeddedBrowser(id: string, url: string): { ok: boolean; try { ensureWorkingViewport(slot); slot.pendingUrl = undefined; - void slot.view.webContents.loadURL(url); + const target = normalizeEmbeddedBrowserUrl(url); + if (applyDirectoryNavigation(slot, target)) return { ok: true }; + void slot.view.webContents.loadURL(target); return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; @@ -330,8 +535,12 @@ export function embeddedBrowserAction( try { if (action === 'back' && wc.canGoBack()) wc.goBack(); else if (action === 'forward' && wc.canGoForward()) wc.goForward(); - else if (action === 'reload') wc.reload(); - else if (action === 'stop') wc.stop(); + else if (action === 'reload') { + if (slot.directoryUrl && applyDirectoryNavigation(slot, slot.directoryUrl)) { + return { ok: true }; + } + wc.reload(); + } else if (action === 'stop') wc.stop(); return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; @@ -344,6 +553,9 @@ export function getEmbeddedBrowserState(id: string): { title?: string; canGoBack?: boolean; canGoForward?: boolean; + isLoading?: boolean; + loadError?: string; + directoryPath?: string; pageId?: number; error?: string; } { @@ -352,10 +564,16 @@ export function getEmbeddedBrowserState(id: string): { const wc = slot.view.webContents; return { ok: true, - url: wc.getURL(), - title: wc.getTitle(), + // Prefer the directory file:// URL so the address bar stays meaningful. + url: slot.directoryUrl || wc.getURL(), + title: slot.directoryPath + ? (slot.directoryPath.split(/[/\\]/).filter(Boolean).pop() || slot.directoryPath) + : wc.getTitle(), canGoBack: wc.canGoBack(), canGoForward: wc.canGoForward(), + isLoading: slot.isLoading || wc.isLoading(), + loadError: slot.loadError, + directoryPath: slot.directoryPath, pageId: slot.pageId, }; } diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 2cd9caee..0b67be59 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -59,18 +59,24 @@ contextBridge.exposeInMainWorld('markusDesktop', { cdp: (id: string, method: string, params?: Record) => ipcRenderer.invoke('browser:cdp', id, method, params), onPageEvent: (callback: (event: { - type: 'opened' | 'closed' | 'navigated' | 'selected'; + type: 'opened' | 'closed' | 'navigated' | 'selected' | 'loading' | 'loaded' | 'load-failed' | 'directory'; pageId: number; browserId: string; url?: string; title?: string; + isLoading?: boolean; + error?: string; + directoryPath?: string; }) => void) => { const handler = (_: unknown, event: { - type: 'opened' | 'closed' | 'navigated' | 'selected'; + type: 'opened' | 'closed' | 'navigated' | 'selected' | 'loading' | 'loaded' | 'load-failed' | 'directory'; pageId: number; browserId: string; url?: string; title?: string; + isLoading?: boolean; + error?: string; + directoryPath?: string; }) => callback(event); ipcRenderer.on('browser:page-event', handler); return () => { ipcRenderer.removeListener('browser:page-event', handler); }; diff --git a/packages/gui/package.json b/packages/gui/package.json index c948914c..227eb186 100644 --- a/packages/gui/package.json +++ b/packages/gui/package.json @@ -17,5 +17,5 @@ "devDependencies": { "@types/sharp": "^0.32.0" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/org-manager/package.json b/packages/org-manager/package.json index 3efccb83..855df709 100644 --- a/packages/org-manager/package.json +++ b/packages/org-manager/package.json @@ -21,5 +21,5 @@ "devDependencies": { "@types/ws": "^8.18.1" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 7dea3275..68d8c474 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -1840,7 +1840,12 @@ export class APIServer { const title = !session!.title ? userMessage.slice(0, 60) : undefined; const meta: Record = { ...(extraMeta ?? {}) }; if (images?.length) meta.images = images; - if (replyTo) { meta.replyToId = replyTo.id; meta.replyToSender = replyTo.sender; meta.replyToText = replyTo.text; } + if (replyTo) { + meta.replyToId = replyTo.id; + meta.replyToSender = replyTo.sender; + // Keep a short preview for the quote chip; full body lives on the referenced message. + meta.replyToText = replyTo.text.slice(0, 200); + } const saved = await this.storage.chatSessionRepo.appendMessage( session!.id, agentId, @@ -3677,9 +3682,13 @@ export class APIServer { return; } if (action === 'cancel-processing') { - const agent = this.orgService.getAgentManager().getAgent(agentId!); - agent.cancelActiveStream(); - this.json(res, 200, { status: 'cancelled' }); + try { + const agent = this.orgService.getAgentManager().getAgent(agentId!); + agent.cancelActiveStream(); + this.json(res, 200, { status: 'cancelled' }); + } catch (err) { + this.json(res, 404, { error: err instanceof Error ? err.message : String(err) }); + } return; } if (action === 'daily-report') { @@ -3759,26 +3768,32 @@ export class APIServer { } const userText = body['text'] as string; + // Persist plain user text; inject reply context only into the LLM turn so + // reload does not show the quoted agent message inside the user bubble. + const agentText = replyTo + ? `[REPLY] The user is replying to ${replyTo.sender}'s message:\n"""\n${replyTo.text.slice(0, 4000)}\n"""\n\n${userText}` + : userText; const inject = body['inject'] as boolean | undefined; if (inject) { if (this.storage && sessionId) { await this.persistUserMessage(agentId!, userText, senderId, images, sessionId, replyTo); } - agent.injectFollowUp(userText, senderId, senderInfo, images); + agent.injectFollowUp(agentText, senderId, senderInfo, images); this.json(res, 200, { injected: true }); return; } - // Wrap persistUserMessage to bind DB session → memory session on first message + // Wrap persistUserMessage to bind DB session → memory session on first message. + // Always persist the plain userText (ignore SSE handler's agent-facing text). const bindingPersist = async ( - aId: string, text: string, sId?: string, imgs?: string[], sessId?: string, - ): Promise => { - const persisted = await this.persistUserMessage(aId, text, sId, imgs, sessId, replyTo); + aId: string, _text: string, sId?: string, imgs?: string[], sessId?: string, + ): Promise<{ sessionId: string; messageId: string } | null> => { + const persisted = await this.persistUserMessage(aId, userText, sId, imgs, sessId, replyTo); if (persisted && !sessId) { agent.bindDbSession(persisted.sessionId); } - return persisted?.sessionId ?? null; + return persisted ? { sessionId: persisted.sessionId, messageId: persisted.messageId } : null; }; if (stream) { @@ -3801,7 +3816,7 @@ export class APIServer { const sseHandler = new SSEHandler({ agentId: agentId!, agent, - userText, + userText: agentText, images, fileNames, senderId, @@ -3809,6 +3824,9 @@ export class APIServer { sessionId, wsBroadcaster: this.ws, persistUserMessage: bindingPersist, + deleteUserMessage: (messageId: string) => { + this.storage?.chatSessionRepo.deleteMessage(messageId); + }, persistAssistantMessage: this.persistAssistantMessage.bind(this), executionStreamRepo: this.storage?.executionStreamRepo, isResume, @@ -3819,22 +3837,23 @@ export class APIServer { await sseHandler.handle(res); } else { const userMsgPersisted = await bindingPersist(agentId!, userText, senderId, images, sessionId); + const persistedSessionId = userMsgPersisted?.sessionId ?? null; const toolEvents: Array<{ tool: string; status: 'done' | 'error'; arguments?: unknown; result?: string; durationMs?: number }> = []; let reply: string; const deferredRestoreNonStream = agent.isProcessing() ? sessionRestoreData : undefined; try { - reply = await agent.sendMessage(userText, senderId, senderInfo, { + reply = await agent.sendMessage(agentText, senderId, senderInfo, { images, fileNames, toolEventCollector: toolEvents, ...(deferredRestoreNonStream !== undefined ? { sessionRestore: deferredRestoreNonStream } : {}), }); } catch (err) { const errText = `⚠ AI service error: ${String(err).slice(0, 500)}`; void this.persistAssistantMessage( - userMsgPersisted, agentId!, errText, 0, { isError: true }, + persistedSessionId, agentId!, errText, 0, { isError: true }, ); throw err; } - this.json(res, 200, { reply, sessionId: userMsgPersisted }); + this.json(res, 200, { reply, sessionId: persistedSessionId }); const { thinking, clean: cleanReply } = extractThinkBlocks(reply); const segments: Array> = []; if (thinking.length > 0) segments.push({ type: 'text', content: '', thinking: thinking.join('\n\n') }); @@ -3844,7 +3863,7 @@ export class APIServer { if (segments.length > 0) segments.push({ type: 'text', content: cleanReply }); const meta = segments.length > 0 ? { segments } : undefined; void this.persistAssistantMessage( - userMsgPersisted, + persistedSessionId, agentId!, reply, agent.getState().tokensUsedToday, @@ -10638,7 +10657,9 @@ EXPLANATION_END`; const ext = extname(resolved).toLowerCase(); const name = resolved.split('/').pop() || 'file'; - const imageExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']); + // Browser-previewable still images — served via stream (not base64) so large + // generated assets (webp/png) are not rejected by the inline size cap. + const imageExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']); const audioExts = new Set(['.mp3', '.wav', '.ogg', '.flac', '.aac', '.m4a', '.wma', '.opus']); const videoExts = new Set(['.mp4', '.webm', '.mov', '.mkv', '.avi', '.m4v']); const binaryExts = new Set([ @@ -10647,6 +10668,11 @@ EXPLANATION_END`; '.exe', '.dll', '.so', '.dylib', '.bin', '.wasm', '.woff', '.woff2', '.ttf', '.otf', '.eot', ]); + const imageMime: Record = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.avif': 'image/avif', + }; const audioMime: Record = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.aac': 'audio/aac', '.m4a': 'audio/mp4', @@ -10657,6 +10683,14 @@ EXPLANATION_END`; '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.m4v': 'video/mp4', }; + if (imageExts.has(ext)) { + this.json(res, 200, { + type: 'image', name, path: resolved, size: stat.size, + mimeType: imageMime[ext] ?? 'application/octet-stream', + streamUrl: `/api/files/stream?path=${encodeURIComponent(resolved)}`, + }); + return; + } if (audioExts.has(ext)) { this.json(res, 200, { type: 'audio', name, path: resolved, size: stat.size, @@ -10681,24 +10715,12 @@ EXPLANATION_END`; return; } - const maxSize = 2 * 1024 * 1024; // 2MB limit for inlined text/image previews + const maxSize = 2 * 1024 * 1024; // 2MB limit for inlined text previews if (stat.size > maxSize) { this.json(res, 413, { error: 'File too large for preview', size: stat.size, maxSize }); return; } - if (imageExts.has(ext)) { - const data = readFileSync(resolved); - const mimeMap: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' }; - this.json(res, 200, { - type: 'image', - name, - mimeType: mimeMap[ext] ?? 'application/octet-stream', - content: data.toString('base64'), - }); - return; - } - // Sniff binary content (null bytes) so arbitrary agent-produced extensions // don't get decoded as UTF-8 garbage in the UI. const head = readFileSync(resolved).subarray(0, Math.min(8192, stat.size)); @@ -10760,6 +10782,7 @@ EXPLANATION_END`; '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.m4v': 'video/mp4', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.avif': 'image/avif', }; const mime = mimeMap[ext] ?? 'application/octet-stream'; const size = statSync(resolved).size; diff --git a/packages/org-manager/src/sse-handler.ts b/packages/org-manager/src/sse-handler.ts index 52e6ec4f..f91257b4 100644 --- a/packages/org-manager/src/sse-handler.ts +++ b/packages/org-manager/src/sse-handler.ts @@ -35,7 +35,9 @@ export interface SSEMessageHandlerOptions { broadcastAgentUpdate?: (agentId: string, status: string) => void; broadcastProactiveMessage?: (agentId: string, agentName: string, sessionId: string, messageId: string, message: string, metadata?: Record, targetUserId?: string) => void; }; - persistUserMessage?: (agentId: string, text: string, senderId?: string, images?: string[], sessionId?: string) => Promise; + persistUserMessage?: (agentId: string, text: string, senderId?: string, images?: string[], sessionId?: string) => Promise; + /** Optional: remove a just-persisted user message when the turn was merged into an active one. */ + deleteUserMessage?: (messageId: string) => void; persistAssistantMessage?: (sessionId: string | null, agentId: string, reply: string, tokensUsed: number, meta?: unknown) => Promise; onTextDelta?: (text: string) => void; onToolEvent?: (event: AgentStreamEvent) => void; @@ -143,14 +145,23 @@ export class SSEHandler { } }); + let persistedUserMessageId: string | undefined; if (this.options.persistUserMessage && !this.options.isResume) { - this.sessionId = await this.options.persistUserMessage( + const persisted = await this.options.persistUserMessage( this.options.agentId, this.options.userText, this.options.senderId, this.options.images, this.options.sessionId, ); + if (typeof persisted === 'string') { + this.sessionId = persisted; + } else if (persisted) { + this.sessionId = persisted.sessionId; + persistedUserMessageId = persisted.messageId; + } else { + this.sessionId = this.options.sessionId ?? null; + } } else if (this.options.isResume) { this.sessionId = this.options.sessionId ?? null; } else { @@ -169,7 +180,13 @@ export class SSEHandler { // Deliver sessionId early so the client can persist it even if the stream // is aborted before the final 'done' event arrives. if (this.sessionId) { - this.emitEvent({ type: 'session_start', sessionId: this.sessionId, streamId: this.streamId, messageId: this.assistantMessageId }); + this.emitEvent({ + type: 'session_start', + sessionId: this.sessionId, + streamId: this.streamId, + messageId: this.assistantMessageId, + userMessageId: persistedUserMessageId, + }); } const reply = await this.options.agent.sendMessageStream( @@ -187,10 +204,26 @@ export class SSEHandler { ); if (reply === '[merged]') { - log.info('Message was merged into active processing — closing SSE without persisting', { + log.info('Message was merged into active processing — closing SSE without persisting assistant', { agentId: this.options.agentId, }); - const mergedDone = { type: 'done' as const, content: '', merged: true, sessionId: this.sessionId, segments: [] as unknown[] }; + // The follow-up was absorbed into the live turn. Drop the standalone DB + // user row so reload does not show an extra bubble for the merged text. + if (persistedUserMessageId && this.options.deleteUserMessage) { + try { + this.options.deleteUserMessage(persistedUserMessageId); + } catch (err) { + log.warn('Failed to delete merged user message', { error: String(err) }); + } + } + const mergedDone = { + type: 'done' as const, + content: '', + merged: true, + sessionId: this.sessionId, + userMessageId: persistedUserMessageId, + segments: [] as unknown[], + }; if (this.sseBuffer && !this.sseDisconnected) { this.sseBuffer.send(mergedDone); } diff --git a/packages/remote/package.json b/packages/remote/package.json index 0123c0c7..65a7e21d 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -17,5 +17,5 @@ "devDependencies": { "@types/ws": "^8.18.1" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/shared/package.json b/packages/shared/package.json index 4235f72d..1fd6d9fc 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,5 +9,5 @@ "dev": "tsc -b --watch", "clean": "rm -rf dist *.tsbuildinfo" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/shared/src/limits.ts b/packages/shared/src/limits.ts index b0c13f31..9e21a53f 100644 --- a/packages/shared/src/limits.ts +++ b/packages/shared/src/limits.ts @@ -370,9 +370,11 @@ export const TRIAGE_ITEM_CONTENT_CHARS = 3000; export const TRIAGE_STALE_INFO_TTL_MS = 4 * 60 * 60 * 1000; /** Informational mailbox types eligible for age-based auto-drop before triage. - * These types carry context that decays rapidly and do not require LLM processing. */ + * These types carry context that decays rapidly and do not require LLM processing. + * `callback_result` is included so orphaned/unprocessed background completions + * (and historically fan-out ghosts) do not wedge the queue forever. */ export const TRIAGE_STALE_DROP_TYPES: readonly string[] = [ - 'task_status_update', 'heartbeat', 'memory_consolidation', 'daily_report', + 'task_status_update', 'heartbeat', 'memory_consolidation', 'daily_report', 'callback_result', ]; /** Max tool-use iterations allowed during triage deliberation. diff --git a/packages/shared/src/utils/config.ts b/packages/shared/src/utils/config.ts index 5051ad80..a4780863 100644 --- a/packages/shared/src/utils/config.ts +++ b/packages/shared/src/utils/config.ts @@ -95,6 +95,11 @@ export interface MarkusConfig { feishu?: { appId?: string; appSecret?: string; + domain?: string; + /** Default group chat_id for notifications / agent sends */ + notifyChatId?: string; + /** Default user open_id for p2p ("send to my Feishu") */ + notifyOpenId?: string; mcp?: { enabled?: boolean; presets?: string[]; diff --git a/packages/storage/package.json b/packages/storage/package.json index 4f453321..86dca4a5 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -12,5 +12,5 @@ "dependencies": { "@markus/shared": "workspace:*" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/storage/src/sqlite-storage.ts b/packages/storage/src/sqlite-storage.ts index 33df843f..98896587 100644 --- a/packages/storage/src/sqlite-storage.ts +++ b/packages/storage/src/sqlite-storage.ts @@ -2252,6 +2252,12 @@ export class SqliteChatSessionRepo { return { content, metadata: rawMeta ? JSON.parse(rawMeta) : undefined }; } + /** Delete a single chat message by id (e.g. undo a merged follow-up persist). */ + deleteMessage(messageId: string): boolean { + const result = this.db.prepare('DELETE FROM chat_messages WHERE id = ?').run(messageId); + return result.changes > 0; + } + /** * Remove the last user+assistant exchange from a session (for retry). * Deletes from the end backwards through the last assistant message diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index 5498583b..f0e0f150 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -45,5 +45,5 @@ "typescript": "^5.9.3", "vite": "^7.3.1" }, - "version": "0.9.0-rc.1" + "version": "0.9.0-rc.2" } diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index 4a5beb5c..e0be9d5d 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -20,6 +20,8 @@ export interface AgentToolEvent { export interface StreamCommitEvent { type: 'thinking_commit' | 'text_commit' | 'session_start'; + /** Server-persisted user message id (session_start) — used to replace optimistic UI ids. */ + userMessageId?: string; content: string; createdAt: string; sessionId?: string; @@ -1238,7 +1240,14 @@ export const api = { const event = JSON.parse(trimmed.slice(6)) as { type: string; text?: string; content?: string; thinking?: string; tool?: string; phase?: 'start' | 'end'; success?: boolean; arguments?: unknown; result?: string; error?: string; durationMs?: number; toolCall?: { id?: string; name?: string }; sessionId?: string }; if (event.type === 'session_start' && event.sessionId) { resultSessionId = event.sessionId; - onCommit?.({ type: 'session_start', content: '', createdAt: new Date().toISOString(), sessionId: event.sessionId }); + const userMessageId = (event as { userMessageId?: string }).userMessageId; + onCommit?.({ + type: 'session_start', + content: '', + createdAt: new Date().toISOString(), + sessionId: event.sessionId, + userMessageId, + }); } else if (event.type === 'text_delta' && event.text) { fullContent += event.text; onChunk(event.text); diff --git a/packages/web-ui/src/components/ActivityIndicator.tsx b/packages/web-ui/src/components/ActivityIndicator.tsx index f0f685f9..f8ff72bd 100644 --- a/packages/web-ui/src/components/ActivityIndicator.tsx +++ b/packages/web-ui/src/components/ActivityIndicator.tsx @@ -83,8 +83,8 @@ function TimelineItem({ item }: { item: ToolItem; idx: number; total: number }) }`} /> {t(`execution.tools.${meta.key}`, { defaultValue: meta.label })}{item.status === 'running' ? '…' : ''} {item.status === 'running' && } @@ -146,8 +146,8 @@ export function ActivityIndicator({ activities, isActive, persistent }: Props) { // Nothing has happened yet → a lightweight thinking indicator. if (!hasAny) { return ( -
- {t('activity.thinking')} +
+ {t('activity.thinking')}
); @@ -181,7 +181,7 @@ export function ActivityIndicator({ activities, isActive, persistent }: Props) { className="flex items-center gap-1.5 text-xs w-full min-w-0 select-none text-fg-tertiary hover:text-fg-secondary transition-colors" > - {headLabel} + {headLabel} · {t('activity.step', { count: timeline.length })} {errorCount > 0 && · {t('activity.failed', { count: errorCount })}} {spinning && } diff --git a/packages/web-ui/src/components/ChatModelMenu.tsx b/packages/web-ui/src/components/ChatModelMenu.tsx index c6b2c1bc..b271d78b 100644 --- a/packages/web-ui/src/components/ChatModelMenu.tsx +++ b/packages/web-ui/src/components/ChatModelMenu.tsx @@ -20,13 +20,14 @@ interface ChatModelMenuProps { } /** - * Compact floating model picker for Chat composer. - * "Apply to global" defaults OFF every time the menu opens (one-shot). + * Compact model picker for the chat composer. + * "Apply to global" defaults ON — switching the model updates global routing + * unless the user turns the toggle off for a session-only override. */ export function ChatModelMenu({ value, onSelect, disabled }: ChatModelMenuProps) { const { t } = useTranslation('team'); const [open, setOpen] = useState(false); - const [applyGlobal, setApplyGlobal] = useState(false); + const [applyGlobal, setApplyGlobal] = useState(true); const [query, setQuery] = useState(''); const [loading, setLoading] = useState(false); const [providers, setProviders] = useState([]); @@ -49,6 +50,24 @@ export function ChatModelMenu({ value, onSelect, disabled }: ChatModelMenuProps) models?: Array<{ id: string; name?: string }>; }>; }; + + // If Markus is configured but catalog is still empty, force a Hub refresh. + const markusInfo = data.providers?.['markus']; + if (markusInfo?.configured && markusInfo.enabled !== false + && (!markusInfo.models || markusInfo.models.length === 0)) { + try { + const live = await fetch('/api/models/live/markus', { credentials: 'include' }); + if (live.ok) { + const body = await live.json() as { models?: Array<{ id: string; name?: string }> }; + if (body.models?.length) { + markusInfo.models = body.models; + } + } + } catch { + /* non-fatal — keep empty catalog */ + } + } + const list: ProviderModels[] = []; for (const [name, info] of Object.entries(data.providers ?? {})) { // Only providers that are configured and switched on (same rule as Settings routing). @@ -92,7 +111,7 @@ export function ChatModelMenu({ value, onSelect, disabled }: ChatModelMenuProps) useEffect(() => { if (!open) return; - setApplyGlobal(false); + setApplyGlobal(true); setQuery(''); void loadModels(); }, [open, loadModels]); @@ -123,8 +142,11 @@ export function ChatModelMenu({ value, onSelect, disabled }: ChatModelMenuProps) }, [providers, query]); const effective = value ?? globalDefault; - // Trigger shows the raw model id (session override or global default). - const label = effective?.model ?? ''; + // Trigger shows a short model id (session override or global default). + const shortModel = effective?.model + ? (effective.model.includes('/') ? effective.model.split('/').pop()! : effective.model) + : ''; + const label = shortModel || t('chatModel.title', { defaultValue: 'Model' }); return (
@@ -132,10 +154,10 @@ export function ChatModelMenu({ value, onSelect, disabled }: ChatModelMenuProps) type="button" disabled={disabled} onClick={() => setOpen(v => !v)} - className="max-w-[220px] px-2 py-1.5 text-[11px] text-fg-secondary hover:text-fg-primary disabled:opacity-40 rounded-lg hover:bg-surface-elevated transition-colors flex items-center gap-1" + className="max-w-[180px] px-1.5 py-1 text-[12px] text-fg-secondary hover:text-fg-primary disabled:opacity-40 rounded-md hover:bg-surface-elevated transition-colors flex items-center gap-1" title={effective ? `${effective.provider}: ${effective.model}` : t('chatModel.title', { defaultValue: 'Model' })} > - {label || t('chatModel.title', { defaultValue: 'Model' })} + {label} diff --git a/packages/web-ui/src/components/ChatPanel.tsx b/packages/web-ui/src/components/ChatPanel.tsx index fa683891..e7205075 100644 --- a/packages/web-ui/src/components/ChatPanel.tsx +++ b/packages/web-ui/src/components/ChatPanel.tsx @@ -229,8 +229,8 @@ export function ChatPanel({ setActivities([]); const agentMsgId = `a_${Date.now()}`; - const userMsg: ChatMsg = { id: `u_${Date.now()}`, sender: 'user', text, time: new Date().toLocaleTimeString() }; const agentCreatedAt = new Date().toISOString(); + const userMsg: ChatMsg = { id: `u_${Date.now()}`, sender: 'user', text, time: new Date().toLocaleTimeString(), rawCreatedAt: agentCreatedAt }; setMessages(prev => [ ...prev, @@ -596,7 +596,9 @@ export function ChatPanel({ const prevMsg = idx > 0 ? messages[idx - 1] : null; const curDate = getDateKey(msg.rawCreatedAt); const prevDate = prevMsg ? getDateKey(prevMsg.rawCreatedAt) : ''; - const showDateSep = curDate && curDate !== prevDate; + // Both sides need a date — missing rawCreatedAt on optimistic bubbles used to + // insert a spurious "Today" divider between every user/agent pair. + const showDateSep = Boolean(curDate && prevDate && curDate !== prevDate); const isLastMsg = idx === messages.length - 1; const isStreamingMsg = isLastPending && isLastMsg; const showStreamingBubble = isStreamingMsg; @@ -628,8 +630,8 @@ export function ChatPanel({ {formatSmartTime(msg.time, msg.rawCreatedAt, dateLabels)}
+ showStreamingBubble && msg.sender === 'agent' ? 'streaming-bubble' : '' + }`}> {msg.sender === 'user' ?
{msg.text}
: msg.segments && msg.segments.length > 0 @@ -638,7 +640,9 @@ export function ChatPanel({ isStreaming={isStreamingMsg} liveActivities={isStreamingMsg ? activities : []} /> - : + : msg.isError || msg.text.startsWith('⚠') + ?
{msg.text.replace(/^⚠\s*/, '')}
+ : }
diff --git a/packages/web-ui/src/components/EmbeddedBrowser.tsx b/packages/web-ui/src/components/EmbeddedBrowser.tsx index 0c3aa58c..ca34e685 100644 --- a/packages/web-ui/src/components/EmbeddedBrowser.tsx +++ b/packages/web-ui/src/components/EmbeddedBrowser.tsx @@ -1,8 +1,11 @@ import { useEffect, useId, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { api as httpApi } from '../api.ts'; import { isNativeBrowserOverlayActive, isNativeBrowserPagePaintAllowed, } from '../lib/nativeBrowserOverlay.ts'; +import { normalizeBrowserUrl } from '../lib/browserUrl.ts'; /** * Electron-only embedded browser host. @@ -22,16 +25,29 @@ export function EmbeddedBrowser({ browserId?: string; className?: string; }) { + const { t } = useTranslation('common'); const reactId = useId().replace(/:/g, ''); const browserId = externalBrowserId || `eb_${reactId}`; /** Ephemeral preview without a stable layout browserId — destroy on unmount. */ const ownsView = !externalBrowserId; const hostRef = useRef(null); const addressRef = useRef(null); + const directoryPathRef = useRef(null); const [address, setAddress] = useState(url === 'about:blank' ? '' : url); const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [directoryPath, setDirectoryPath] = useState(null); + const [openingFolder, setOpeningFolder] = useState(false); const api = typeof window !== 'undefined' ? window.markusDesktop?.browser : undefined; + const platform = typeof window !== 'undefined' ? window.markusDesktop?.platform : undefined; + directoryPathRef.current = directoryPath; + + const folderAppLabel = + platform === 'darwin' ? t('browserFolderAppFinder') + : platform === 'win32' ? t('browserFolderAppExplorer') + : t('browserFolderAppGeneric'); useEffect(() => { if (!api) return; @@ -40,13 +56,24 @@ export function EmbeddedBrowser({ const s = await api.getState(browserId); if (cancelled) return; if (!s.ok) { + setIsLoading(true); await api.create(browserId, url); - } else if (url && s.url !== url) { + } else if (url && s.url !== url && !s.directoryPath) { + setIsLoading(true); + await api.navigate(browserId, url); + } else if (url && s.directoryPath && s.url !== url) { + // Re-navigate when the tab URL changed (including another directory). await api.navigate(browserId, url); } - if (!cancelled && document.activeElement !== addressRef.current) { - setAddress(!s.ok || url === 'about:blank' ? (url === 'about:blank' ? '' : url) : (s.url && s.url !== 'about:blank' ? s.url : '')); + if (cancelled) return; + const state = await api.getState(browserId); + if (cancelled || !state.ok) return; + if (document.activeElement !== addressRef.current) { + setAddress(state.url && state.url !== 'about:blank' ? state.url : (url === 'about:blank' ? '' : url)); } + setIsLoading(!!state.isLoading); + setLoadError(state.loadError ?? null); + setDirectoryPath(state.directoryPath ?? null); })(); return () => { cancelled = true; @@ -65,6 +92,51 @@ export function EmbeddedBrowser({ return () => clearTimeout(t); }, [url, browserId]); + // Prefer page events for snappy loading UX (poll remains for back/forward). + useEffect(() => { + if (!api?.onPageEvent) return; + return api.onPageEvent((event) => { + if (event.browserId !== browserId) return; + if (event.type === 'directory') { + setDirectoryPath(event.directoryPath || null); + setIsLoading(false); + setLoadError(null); + if (event.url && document.activeElement !== addressRef.current) { + setAddress(event.url); + } + return; + } + if (event.type === 'loading') { + setIsLoading(true); + setLoadError(null); + setDirectoryPath(null); + return; + } + if (event.type === 'loaded') { + setIsLoading(false); + if (directoryPathRef.current) return; + if (event.url && document.activeElement !== addressRef.current) { + setAddress(event.url === 'about:blank' ? '' : event.url); + } + return; + } + if (event.type === 'load-failed') { + setIsLoading(false); + if (directoryPathRef.current) return; + setLoadError(event.error || 'Load failed'); + if (event.url && document.activeElement !== addressRef.current) { + setAddress(event.url === 'about:blank' ? '' : event.url); + } + return; + } + if (event.type === 'navigated' && event.url && document.activeElement !== addressRef.current) { + if (!directoryPathRef.current) { + setAddress(event.url === 'about:blank' ? '' : event.url); + } + } + }); + }, [api, browserId]); + useEffect(() => { if (!api || !hostRef.current) return; const el = hostRef.current; @@ -84,8 +156,13 @@ export function EmbeddedBrowser({ const sync = () => { if (!alive) return; - // Page leave / panel collapse / HTML modals — never paint above other pages. - if (!isNativeBrowserPagePaintAllowed() || !hostPageActive() || isNativeBrowserOverlayActive()) { + // Page leave / panel collapse / HTML modals / folder prompt — hide native paint. + if ( + !isNativeBrowserPagePaintAllowed() + || !hostPageActive() + || isNativeBrowserOverlayActive() + || !!directoryPathRef.current + ) { void api.setBounds(browserId, { x: 0, y: 0, width: 0, height: 0 }, false); return; } @@ -111,11 +188,18 @@ export function EmbeddedBrowser({ if (!alive) return; void api.getState(browserId).then(s => { if (!alive || !s.ok) return; + const dir = s.directoryPath ?? null; + setDirectoryPath(dir); if (s.url && document.activeElement !== addressRef.current) { - setAddress(s.url === 'about:blank' ? '' : s.url); + // Keep showing the folder file:// URL while in directory mode. + if (dir || s.url !== 'about:blank') { + setAddress(s.url === 'about:blank' ? '' : s.url); + } } setCanGoBack(!!s.canGoBack); setCanGoForward(!!s.canGoForward); + setIsLoading(!!s.isLoading); + if (s.loadError !== undefined) setLoadError(dir ? null : (s.loadError ?? null)); }); }, 800); @@ -140,6 +224,11 @@ export function EmbeddedBrowser({ }; }, [api, browserId]); + // Re-sync bounds whenever folder mode toggles (hide/show native view). + useEffect(() => { + window.dispatchEvent(new Event('markus:embedded-browser-resync')); + }, [directoryPath]); + if (!api) { return (
@@ -156,52 +245,124 @@ export function EmbeddedBrowser({ return (
-
- - - + + +
{ + e.preventDefault(); + let next = address.trim(); + if (!next) return; + next = normalizeBrowserUrl(next); + setIsLoading(true); + setLoadError(null); + setDirectoryPath(null); + void api.navigate(browserId, next); + setAddress(next); + }} + > + setAddress(e.target.value)} + placeholder={t('browserUrlPlaceholder')} + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" + className="w-full px-2 py-1 text-xs bg-surface-primary border border-border-default rounded-md text-fg-primary outline-none focus:border-brand-500" + /> +
+ {isLoading && ( + {t('browserLoading')} + )} +
+ {/* Indeterminate progress while the native view loads */} +
- - -
{ - e.preventDefault(); - let next = address.trim(); - if (!next) return; - if (next !== 'about:blank' && !/^https?:\/\//i.test(next)) next = `https://${next}`; - void api.navigate(browserId, next); - setAddress(next); - }} - > - setAddress(e.target.value)} - placeholder="Enter a URL" - className="w-full px-2 py-1 text-xs bg-surface-primary border border-border-default rounded-md text-fg-primary outline-none focus:border-brand-500" - /> -
+
+
- {/* Native WebContentsView is painted over this host rect */} -
+ {loadError && !isLoading && !directoryPath && ( +
+ {loadError} +
+ )} + {/* Native WebContentsView paints over this host (hidden while showing folder prompt). */} +
+ {directoryPath && ( +
+
+ + + +
+
+

+ {t('browserFolderTitle')} +

+

{directoryPath}

+
+ +
+ )} +
+
); } diff --git a/packages/web-ui/src/components/ExecutionTimeline.tsx b/packages/web-ui/src/components/ExecutionTimeline.tsx index 342b6aaa..1ca7b1ed 100644 --- a/packages/web-ui/src/components/ExecutionTimeline.tsx +++ b/packages/web-ui/src/components/ExecutionTimeline.tsx @@ -40,8 +40,8 @@ export { export function ThinkingDots({ label }: { label?: string }) { const { t } = useTranslation('common'); return ( -
- {label ?? t('execution.thinking')} +
+ {label ?? t('execution.thinking')} {[0, 150, 300].map(d => ( {t(`execution.tools.${meta.key}`, { defaultValue: meta.label })}{info.status === 'running' ? '…' : ''} @@ -931,7 +931,7 @@ function ThinkingRow({ content, time, defaultExpanded }: { content: string; time Thinking always "succeeds", so it keeps a steady violet accent that gives it its own identity, distinct from the green/red status colors of tools. */} - {t('execution.thinking')} + {t('execution.thinking')} {/* Chevron sits right after the title, not pinned to the far right. */} {expanded && time && {time}} @@ -975,6 +975,17 @@ export function ExecEntryRow({ entry, showTime, isLast, defaultThinkingExpanded, return ; } if (entry.type === 'text') { + // Rate-limit / agent failures are stored as ⚠-prefixed prose — render calmly in + // tertiary grey instead of the default answer style (and never as a red bar). + if (entry.content.startsWith('⚠')) { + return ( +
+

+ {entry.content.replace(/^⚠\s*/, '')} +

+
+ ); + } // Plain answer prose, indented to align with the icon-rows' labels. No box or // icon — being clean prose is what distinguishes it from thinking/tools. return ( @@ -1002,9 +1013,9 @@ export function ExecEntryRow({ entry, showTime, isLast, defaultThinkingExpanded, if (entry.type === 'error') { return (
- - - {t('execution.errorPrefix')} {entry.content} + + + {t('execution.errorPrefix')} {entry.content.replace(/^⚠\s*/, '')}
); diff --git a/packages/web-ui/src/components/MarkdownMessage.tsx b/packages/web-ui/src/components/MarkdownMessage.tsx index e23a6d75..fea455ab 100644 --- a/packages/web-ui/src/components/MarkdownMessage.tsx +++ b/packages/web-ui/src/components/MarkdownMessage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useRef, useEffect, useCallback, memo } from 'react'; +import { useMemo, useState, useRef, useEffect, useCallback, memo, type MouseEvent as ReactMouseEvent } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; @@ -362,8 +362,44 @@ function MarkdownImage({ src, alt, onPreview, basePath }: { src: string; alt?: s // ─── Image Preview Modal ──────────────────────────────────────────────────── +async function fetchImageBlob(src: string): Promise { + const res = await fetch(src); + if (!res.ok) throw new Error(`Failed to fetch image (${res.status})`); + return res.blob(); +} + +/** Clipboard image write usually wants image/png — convert when needed. */ +async function blobAsPng(blob: Blob): Promise { + if (blob.type === 'image/png') return blob; + const bmp = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = bmp.width; + canvas.height = bmp.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas unavailable'); + ctx.drawImage(bmp, 0, 0); + bmp.close(); + return new Promise((resolve, reject) => { + canvas.toBlob(b => (b ? resolve(b) : reject(new Error('PNG encode failed'))), 'image/png'); + }); +} + +function guessImageFilename(src: string, blob: Blob): string { + try { + const path = src.startsWith('data:') ? '' : new URL(src, window.location.href).pathname; + const base = path.split('/').pop() || ''; + if (base && /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(base)) return decodeURIComponent(base); + } catch { /* ignore */ } + const ext = (blob.type.split('/')[1] || 'png').replace('jpeg', 'jpg'); + return `image.${ext}`; +} + function ImagePreviewModal({ src, onClose }: { src: string; onClose: () => void }) { + const { t } = useTranslation('common'); + const [flash, setFlash] = useState(null); + const [busy, setBusy] = useState<'copy' | 'download' | null>(null); useNativeBrowserOverlay(true); + useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); @@ -372,19 +408,89 @@ function ImagePreviewModal({ src, onClose }: { src: string; onClose: () => void return () => document.removeEventListener('keydown', handler); }, [onClose]); + const showFlash = useCallback((msg: string) => { + setFlash(msg); + window.setTimeout(() => setFlash(null), 1600); + }, []); + + const handleCopy = useCallback(async (e: ReactMouseEvent) => { + e.stopPropagation(); + if (busy) return; + setBusy('copy'); + try { + const blob = await fetchImageBlob(src); + const png = await blobAsPng(blob); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]); + showFlash(t('imageCopied')); + } catch { + showFlash(t('imageCopyFailed')); + } finally { + setBusy(null); + } + }, [busy, showFlash, src, t]); + + const handleDownload = useCallback(async (e: ReactMouseEvent) => { + e.stopPropagation(); + if (busy) return; + setBusy('download'); + try { + const blob = await fetchImageBlob(src); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = guessImageFilename(src, blob); + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + showFlash(t('imageDownloaded')); + } catch { + showFlash(t('imageDownloadFailed')); + } finally { + setBusy(null); + } + }, [busy, showFlash, src, t]); + + const toolBtn = + 'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm transition-colors disabled:opacity-50'; + return createPortal(
- + + + +
+ {flash && ( +
+ {flash} +
+ )} Preview { if (cancelled) return; if (resp.type === 'image') { - setPreview({ mode: 'image', src: `data:${resp.mimeType || 'image/png'};base64,${resp.content}`, name: resp.name || title }); + // Prefer stream URL (works for large webp/png); fall back to legacy base64 content. + const src = resp.content + ? `data:${resp.mimeType || 'image/png'};base64,${resp.content}` + : (resp.streamUrl + || (resp.path ? api.files.streamUrl(resp.path) : api.files.streamUrl(reference))); + setPreview({ mode: 'image', src, name: resp.name || title }); return; } if (resp.type === 'audio') { diff --git a/packages/web-ui/src/env.d.ts b/packages/web-ui/src/env.d.ts index 9f5922e4..c6f17cd5 100644 --- a/packages/web-ui/src/env.d.ts +++ b/packages/web-ui/src/env.d.ts @@ -34,16 +34,19 @@ interface MarkusDesktopAPI { hideAll(): Promise<{ ok: boolean }>; navigate(id: string, url: string): Promise<{ ok: boolean; error?: string }>; action(id: string, action: 'back' | 'forward' | 'reload' | 'stop'): Promise<{ ok: boolean; error?: string }>; - getState(id: string): Promise<{ ok: boolean; url?: string; title?: string; canGoBack?: boolean; canGoForward?: boolean; pageId?: number; error?: string }>; + getState(id: string): Promise<{ ok: boolean; url?: string; title?: string; canGoBack?: boolean; canGoForward?: boolean; isLoading?: boolean; loadError?: string; directoryPath?: string; pageId?: number; error?: string }>; executeJs(id: string, code: string): Promise<{ ok: boolean; result?: unknown; error?: string }>; capture(id: string): Promise<{ ok: boolean; pngBase64?: string; error?: string }>; cdp(id: string, method: string, params?: Record): Promise<{ ok: boolean; result?: unknown; error?: string }>; onPageEvent?(callback: (event: { - type: 'opened' | 'closed' | 'navigated' | 'selected'; + type: 'opened' | 'closed' | 'navigated' | 'selected' | 'loading' | 'loaded' | 'load-failed' | 'directory'; pageId: number; browserId: string; url?: string; title?: string; + isLoading?: boolean; + error?: string; + directoryPath?: string; }) => void): () => void; }; } diff --git a/packages/web-ui/src/index.css b/packages/web-ui/src/index.css index 6fade81e..6807041a 100644 --- a/packages/web-ui/src/index.css +++ b/packages/web-ui/src/index.css @@ -299,6 +299,25 @@ body { animation: skeleton-shimmer 1.4s ease-in-out infinite; } +/* Running tool / thinking label — soft bright↔dim sweep */ +@keyframes activity-text-shimmer { + 0% { background-position: 100% center; } + 100% { background-position: -100% center; } +} +.activity-text-shimmer { + background-image: linear-gradient( + 90deg, + var(--color-fg-tertiary) 0%, + var(--color-fg-primary) 45%, + var(--color-fg-tertiary) 90% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: activity-text-shimmer 1.6s ease-in-out infinite; +} + /* Horizontal scroll rail with snap for discovery carousels */ .rail { @apply flex gap-4 overflow-x-auto; diff --git a/packages/web-ui/src/lib/ConversationBufferManager.ts b/packages/web-ui/src/lib/ConversationBufferManager.ts index 0c3537b4..c253f116 100644 --- a/packages/web-ui/src/lib/ConversationBufferManager.ts +++ b/packages/web-ui/src/lib/ConversationBufferManager.ts @@ -135,14 +135,19 @@ export class ConversationBufferManager { } const phase = this.getPhase(convKey); + // Accept the result when this session is still the one being loaded OR the + // one the user is viewing. Relying only on `loadingSession` drops the first + // response when a second load for the same conversation races ahead. + const activeSessionId = this.activeSession.get(convKey); const isCurrentView = this.currentConvKey === convKey - && this.loadingSession === sessionId; + && (this.loadingSession === sessionId || activeSessionId === sessionId); if (isCurrentView && phase !== 'streaming') { const displayMsgs = cacheIsFresher ? this.sessionMsgCache.get(sessionId)! : msgs; this.msgBuffers.set(convKey, displayMsgs); + this.loadingSession = sessionId; this.completeLoad(convKey); return { displayChanged: true, newMessages: displayMsgs }; } diff --git a/packages/web-ui/src/lib/browserUrl.ts b/packages/web-ui/src/lib/browserUrl.ts new file mode 100644 index 00000000..d8bd4704 --- /dev/null +++ b/packages/web-ui/src/lib/browserUrl.ts @@ -0,0 +1,66 @@ +/** + * Normalize address-bar / navigate input for the embedded browser. + * Supports http(s), file://, about:blank, and bare filesystem paths. + */ +export function normalizeBrowserUrl(raw: string): string { + const next = raw.trim(); + if (!next) return next; + if (next === 'about:blank') return next; + + // Already has a URI scheme (http, https, file, data, …). + if (/^[a-z][a-z0-9+.-]*:/i.test(next)) { + if (/^file:/i.test(next)) return normalizeFileUrl(next); + return next; + } + + // Absolute local paths → file:// + if (isAbsoluteFilesystemPath(next)) { + return pathToFileUrl(next); + } + + // Bare host / search-like input → https + return `https://${next}`; +} + +function isAbsoluteFilesystemPath(input: string): boolean { + if (input.startsWith('/')) return true; + if (/^[a-zA-Z]:[\\/]/.test(input)) return true; + if (input.startsWith('\\\\')) return true; + return false; +} + +function pathToFileUrl(absPath: string): string { + let p = absPath.replace(/\\/g, '/'); + // UNC \\server\share → file://server/share + if (p.startsWith('//')) { + return `file:${encodePathKeepSlashes(p)}`; + } + // Windows drive letter + if (/^[a-zA-Z]:\//.test(p)) { + return `file:///${encodePathKeepSlashes(p)}`; + } + // POSIX absolute + return `file://${encodePathKeepSlashes(p)}`; +} + +function normalizeFileUrl(input: string): string { + try { + return new URL(input).href; + } catch { + // Repair file:/Users/... or file:C:/... (missing slashes) + let rest = input.replace(/^file:/i, '').replace(/\\/g, '/'); + if (rest.startsWith('//')) { + // file://host/path or file:///path — drop authority slash pair + rest = rest.replace(/^\/\/(localhost)?/i, ''); + if (!rest.startsWith('/') && !/^[a-zA-Z]:\//.test(rest)) rest = `/${rest}`; + } + if (/^\/[a-zA-Z]:\//.test(rest)) rest = rest.slice(1); + if (!rest.startsWith('/') && !/^[a-zA-Z]:\//.test(rest)) rest = `/${rest}`; + return pathToFileUrl(rest); + } +} + +/** encodeURI keeps `/`; still escape `#` which would truncate the path. */ +function encodePathKeepSlashes(p: string): string { + return encodeURI(p).replace(/#/g, '%23'); +} diff --git a/packages/web-ui/src/locales/en/agent.json b/packages/web-ui/src/locales/en/agent.json index f24e6d1e..8986fdf5 100644 --- a/packages/web-ui/src/locales/en/agent.json +++ b/packages/web-ui/src/locales/en/agent.json @@ -377,6 +377,7 @@ "mind": { "loading": "Loading agent mind state...", "idleWaiting": "Idle — waiting for new mail", + "queuedButIdle": "Queue has {{count}} item(s) — attention loop should pick them up…", "decidingWaiting": "Deciding which item to process ({{count}} queued)…", "deliberationInProgress": "Deliberation in progress", "since": "since {{time}}", diff --git a/packages/web-ui/src/locales/en/common.json b/packages/web-ui/src/locales/en/common.json index fd013190..49df7791 100644 --- a/packages/web-ui/src/locales/en/common.json +++ b/packages/web-ui/src/locales/en/common.json @@ -26,6 +26,12 @@ "open": "Open", "copy": "Copy", "copied": "Copied!", + "copyImage": "Copy image", + "downloadImage": "Download", + "imageCopied": "Image copied", + "imageCopyFailed": "Failed to copy image", + "imageDownloaded": "Download started", + "imageDownloadFailed": "Failed to download image", "done": "Done", "search": "Search", "install": "Install", @@ -111,6 +117,14 @@ }, "signOut": "Log out", "openInBrowser": "Open in Browser", + "browserFolderTitle": "This is a folder — the browser can’t preview folders", + "browserFolderOpen": "Open in {{app}}", + "browserFolderOpening": "Opening…", + "browserFolderAppFinder": "Finder", + "browserFolderAppExplorer": "Explorer", + "browserFolderAppGeneric": "file manager", + "browserLoading": "Loading…", + "browserUrlPlaceholder": "URL or local file path", "signOutConfirmTitle": "Log out?", "signOutConfirmMessage": "You will need to sign in again to continue using Markus.", "confirmDelete": "Confirm Delete", diff --git a/packages/web-ui/src/locales/en/onboarding.json b/packages/web-ui/src/locales/en/onboarding.json index cfe46720..4133e149 100644 --- a/packages/web-ui/src/locales/en/onboarding.json +++ b/packages/web-ui/src/locales/en/onboarding.json @@ -41,7 +41,7 @@ "openClawFoundLabel": "Found:", "openClawProviders": "({{count}} providers)", "autoFallback": "Auto-switch model on failure", - "autoFallbackDesc": "Try other providers/models if the primary fails" + "autoFallbackDesc": "Optional; off by default. When on, try other providers if the primary fails" }, "search": { "title": "Web Search (Optional)", diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index b7e3392f..e33677b9 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -90,7 +90,7 @@ "noProviders": "No providers configured", "updated": "Default provider updated to {{name}}", "autoFallback": "Auto-switch Model", - "autoFallbackDesc": "When enabled, automatically try other providers/models if the primary one fails. When disabled, only use the selected model — errors will not trigger fallback." + "autoFallbackDesc": "Off by default. When on, try other providers/models if the primary fails; when off, surface the error so you can switch manually." }, "modelRouting": { "title": "Model Routing", diff --git a/packages/web-ui/src/locales/en/team.json b/packages/web-ui/src/locales/en/team.json index acbb06e8..f29df5a3 100644 --- a/packages/web-ui/src/locales/en/team.json +++ b/packages/web-ui/src/locales/en/team.json @@ -252,10 +252,15 @@ "ai402": "⚠ AI service error: {{detail}}. Please top up the API balance or contact your administrator.", "ai401": "⚠ AI service authentication failed: {{detail}}. Please check the configuration.", "ai429": "⚠ Rate limit exceeded: {{detail}}. Please wait a moment and try again.", + "ai409": "⚠ Request conflict: {{detail}}. Please retry.", "ai502": "⚠ AI upstream service error: {{detail}}. The LLM provider may be temporarily down. Please try again later.", "ai503": "⚠ AI service unavailable: {{detail}}. Please try again later.", + "aiUpstreamBillingMismatch": "⚠ Upstream billing error, but your Markus credits still remain. Please retry shortly or switch model. {{detail}}", + "aiRegionBlocked": "⚠ Model {{model}} is not available in your region. Please switch to another model in the chat composer.", "aiGeneric": "⚠ AI service error: {{detail}}", - "defaultUpstreamDown": "LLM provider returned 502 Bad Gateway" + "defaultUpstreamDown": "LLM provider returned 502 Bad Gateway", + "defaultUpstreamBillingMismatch": "Upstream payment error with remaining local credits", + "defaultConflict": "Conflicting request" }, "page": { "userInputPrompt_one": "{{count}} question awaiting your response", diff --git a/packages/web-ui/src/locales/zh-CN/agent.json b/packages/web-ui/src/locales/zh-CN/agent.json index 74d41b4e..3fc5dd9f 100644 --- a/packages/web-ui/src/locales/zh-CN/agent.json +++ b/packages/web-ui/src/locales/zh-CN/agent.json @@ -377,6 +377,7 @@ "mind": { "loading": "正在加载智能体心智状态…", "idleWaiting": "空闲 — 等待新邮件", + "queuedButIdle": "队列有 {{count}} 项待处理 — 注意力循环应即将接手…", "decidingWaiting": "决策中 — 正在分拣 {{count}} 个待处理项…", "deliberationInProgress": "正在深度分拣", "since": "自 {{time}} 起", diff --git a/packages/web-ui/src/locales/zh-CN/common.json b/packages/web-ui/src/locales/zh-CN/common.json index a2a995f4..2c3c31b9 100644 --- a/packages/web-ui/src/locales/zh-CN/common.json +++ b/packages/web-ui/src/locales/zh-CN/common.json @@ -26,6 +26,12 @@ "open": "打开", "copy": "复制", "copied": "已复制!", + "copyImage": "复制图片", + "downloadImage": "下载", + "imageCopied": "图片已复制", + "imageCopyFailed": "复制图片失败", + "imageDownloaded": "已开始下载", + "imageDownloadFailed": "下载图片失败", "done": "完成", "search": "搜索", "install": "安装", @@ -111,6 +117,14 @@ }, "signOut": "登出", "openInBrowser": "在浏览器中打开", + "browserFolderTitle": "这是一个文件夹,浏览器无法直接预览", + "browserFolderOpen": "在{{app}}中打开", + "browserFolderOpening": "正在打开…", + "browserFolderAppFinder": "访达", + "browserFolderAppExplorer": "资源管理器", + "browserFolderAppGeneric": "文件管理器", + "browserLoading": "加载中…", + "browserUrlPlaceholder": "输入网址或本地文件路径", "signOutConfirmTitle": "确认登出?", "signOutConfirmMessage": "登出后需要重新登录才能继续使用 Markus。", "confirmDelete": "确认删除", diff --git a/packages/web-ui/src/locales/zh-CN/onboarding.json b/packages/web-ui/src/locales/zh-CN/onboarding.json index 5c1bf708..743ea1ed 100644 --- a/packages/web-ui/src/locales/zh-CN/onboarding.json +++ b/packages/web-ui/src/locales/zh-CN/onboarding.json @@ -41,7 +41,7 @@ "openClawFoundLabel": "已找到:", "openClawProviders": "({{count}} 个提供商)", "autoFallback": "模型出错时自动切换", - "autoFallbackDesc": "主模型失败时自动尝试其他提供商/模型" + "autoFallbackDesc": "可选;默认关闭。开启后主模型失败时才自动尝试其他提供商" }, "search": { "title": "网络搜索(可选)", diff --git a/packages/web-ui/src/locales/zh-CN/settings.json b/packages/web-ui/src/locales/zh-CN/settings.json index ad5c4674..ab4e29f7 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -90,7 +90,7 @@ "noProviders": "尚未配置提供商", "updated": "默认提供商已更新为 {{name}}", "autoFallback": "自动切换模型", - "autoFallbackDesc": "启用后,当主模型出错时自动尝试其他提供商/模型。禁用后,仅使用所选模型 — 出错时不会自动切换。" + "autoFallbackDesc": "默认关闭。开启后主模型出错时会自动尝试其他提供商/模型;关闭则直接报错,由你手动切换。" }, "modelRouting": { "title": "模型路由", diff --git a/packages/web-ui/src/locales/zh-CN/team.json b/packages/web-ui/src/locales/zh-CN/team.json index 06c5efaa..2d5f09f5 100644 --- a/packages/web-ui/src/locales/zh-CN/team.json +++ b/packages/web-ui/src/locales/zh-CN/team.json @@ -252,10 +252,15 @@ "ai402": "⚠ AI 服务错误:{{detail}}。请充值 API 余额或联系管理员。", "ai401": "⚠ AI 服务认证失败:{{detail}}。请检查配置。", "ai429": "⚠ 超出速率限制:{{detail}}。请稍后再试。", + "ai409": "⚠ 请求冲突:{{detail}}。请重试。", "ai502": "⚠ AI 上游服务异常:{{detail}}。LLM 供应商可能暂时宕机,请稍后重试。", "ai503": "⚠ AI 服务不可用:{{detail}}。请稍后重试。", + "aiUpstreamBillingMismatch": "⚠ 上游返回计费错误,但您的 Markus 积分仍有余额。请稍后重试或切换模型。{{detail}}", + "aiRegionBlocked": "⚠ 当前地区无法使用模型 {{model}}。请在输入框右下角手动切换到其他可用模型。", "aiGeneric": "⚠ AI 服务错误:{{detail}}", - "defaultUpstreamDown": "LLM 供应商返回 502 Bad Gateway" + "defaultUpstreamDown": "LLM 供应商返回 502 Bad Gateway", + "defaultUpstreamBillingMismatch": "上游计费异常且本地仍有余额", + "defaultConflict": "请求冲突" }, "page": { "userInputPrompt_one": "有 {{count}} 个问题等待你回复", diff --git a/packages/web-ui/src/pages/AgentProfile.tsx b/packages/web-ui/src/pages/AgentProfile.tsx index 92337f68..b6e57b06 100644 --- a/packages/web-ui/src/pages/AgentProfile.tsx +++ b/packages/web-ui/src/pages/AgentProfile.tsx @@ -2228,14 +2228,21 @@ function MindTab({ agentId, highlightId, agentStatus, canManageAgents, onAgentSt if (loading && !mind) return
{t('agent:profilePage.mind.loading')}
; + const queueDepth = mind?.mailboxDepth ?? mind?.queuedItems?.length ?? 0; const effectiveAttentionState: string = (() => { const raw = mind?.attentionState ?? 'idle'; if (raw === 'deciding') return 'deciding'; + // Focused without a focus object is a transient race — keep "focused" only when + // we still have a focus; otherwise fall back. But idle + non-empty queue is NOT + // truly idle (lost-wakeup / about to pick) — surface as deciding so the UI + // doesn't claim "waiting for mail" while work is piled up. + if (raw === 'idle' && queueDepth > 0) return 'deciding'; if (raw !== 'idle' && !mind?.currentFocus) return 'idle'; return raw; })(); const hasStaleProcessingItems = effectiveAttentionState === 'idle' && mailbox?.history?.some(h => h.status === 'processing'); + const hasStuckQueue = (mind?.attentionState ?? 'idle') === 'idle' && queueDepth > 0; return (
@@ -2274,8 +2281,10 @@ function MindTab({ agentId, highlightId, agentStatus, canManageAgents, onAgentSt )} ); - })() : effectiveAttentionState === 'deciding' ? ( - {t('agent:profilePage.mind.decidingWaiting', { count: mind?.mailboxDepth ?? 0 })} + })() : hasStuckQueue ? ( + {t('agent:profilePage.mind.queuedButIdle', { count: queueDepth, defaultValue: 'Queue has {{count}} item(s) — attention loop should pick them up…' })} + ) : effectiveAttentionState === 'deciding' ? ( + {t('agent:profilePage.mind.decidingWaiting', { count: queueDepth })} ) : ( {t('agent:profilePage.mind.idleWaiting')} )} diff --git a/packages/web-ui/src/pages/ChatComponents.tsx b/packages/web-ui/src/pages/ChatComponents.tsx index f0d2cd0c..2fb35eab 100644 --- a/packages/web-ui/src/pages/ChatComponents.tsx +++ b/packages/web-ui/src/pages/ChatComponents.tsx @@ -152,14 +152,13 @@ export function AvatarPopover({ agent, anchorRect, onClose, onViewProfile }: { // ─── Credit error detection ─────────────────────────────────────────────────── -/** Returns true if the error is a Markus Cloud AI credit/quota error. */ +/** Returns true if the error is a confirmed Markus Cloud AI credit/quota error. */ export function isMarkusCreditError(err: unknown): boolean { const raw = String(err); + // Upstream 402 while Hub still has budget — not a user credit-exhausted state. + if (raw.includes('MARKUS_UPSTREAM_ERROR')) return false; return raw.includes('CU_EXCEEDED') - || raw.includes('CU_MONTHLY_EXCEEDED') - || /key limit exceeded|total limit/i.test(raw) - || (/\b403\b/.test(raw) && /limit|insufficient|credit|quota/i.test(raw)) - || (/\b402\b/.test(raw) && /credit|quota|balance|limit/i.test(raw)); + || raw.includes('CU_MONTHLY_EXCEEDED'); } const CREDIT_MUTE_KEY = 'markus:credit-notif-muted'; @@ -211,19 +210,23 @@ export function friendlyAgentError(err: unknown, t: TFunction): string { // this function works regardless of the caller's default namespace. const e = (key: string, opts?: Record) => t(`team:errors.${key}`, opts); - if (isMarkusCreditError(raw) || raw.includes('CU_EXCEEDED') || /key limit exceeded|total limit/i.test(raw)) + if (raw.includes('MARKUS_UPSTREAM_ERROR')) + return e('aiUpstreamBillingMismatch', { detail: detail || e('defaultUpstreamBillingMismatch') }); + if (isMarkusCreditError(raw) || raw.includes('CU_EXCEEDED')) return e('markusCuExceeded'); if (raw.includes('CU_WINDOW_EXCEEDED')) return e('markusWindowExceeded'); if (raw.includes('MARKUS_RATE_LIMITED')) return e('markusRateLimited'); - if (raw.includes('402') || /insufficient.?balance/i.test(raw)) - return e('markusCuExceeded'); + if (/not available in your region/i.test(raw)) + return e('aiRegionBlocked', { model: (raw.match(/\[(?:markus|openrouter):([^\]]+)\]/i)?.[1] || '').trim() || '—' }); if (raw.includes('401') || /unauthorized|invalid.?api.?key/i.test(raw)) return e('ai401', { detail: detail || e('defaultInvalidApiKey') }); if (raw.includes('429') || /rate.?limit/i.test(raw)) return e('ai429', { detail: detail || e('defaultTooManyRequests') }); + if (/\b409\b/.test(raw) || /conflict/i.test(raw)) + return e('ai409', { detail: detail || e('defaultConflict') }); if (raw.includes('502') || /bad.?gateway/i.test(raw)) return e('ai502', { detail: detail || e('defaultUpstreamDown') }); if (raw.includes('503') || /service.?unavailable/i.test(raw)) @@ -260,7 +263,7 @@ export function MessageActions({ } {isCopied ? t('common:copied') : t('common:copy')} - {canRetry && onResume && ( + {canRetry && isStopped && onResume && (
diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index a2e4280b..6e1a7188 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -40,7 +40,7 @@ import { ConfirmModal } from '../components/ConfirmModal.tsx'; import { type MsgSegment, type ChatMsg, type ChatMode, dbMsgToChat, channelMsgToChat, stripNotifyContext, - storedSegmentsToMsgSegments, + storedSegmentsToMsgSegments, dedupeAdjacentUserMessages, appendLiveOutput, appendSubagentLog, formatSmartTime, getDateKey, formatDateLabel, throttle, } from './ChatHelpers.ts'; @@ -589,13 +589,19 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: const [sessionModelOverride, setSessionModelOverride] = useState(null); const reattachAbortRef = useRef(null); + /** Compact (1-line) composer vs taller empty-chat starter. Synced before render. */ + const compactComposerRef = useRef(false); + const adjustTextareaHeight = useCallback(() => { const el = textareaRef.current; if (!el) return; + const compact = compactComposerRef.current; + const minH = compact ? 36 : 52; + const maxH = compact ? 160 : 120; el.style.height = 'auto'; - const h = Math.max(52, Math.min(el.scrollHeight, 120)); + const h = Math.max(minH, Math.min(el.scrollHeight, maxH)); el.style.height = `${h}px`; - el.style.overflowY = h >= 120 ? 'auto' : 'hidden'; + el.style.overflowY = h >= maxH ? 'auto' : 'hidden'; }, []); useEffect(() => { @@ -1126,6 +1132,13 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: chatMode === 'channel' ? messages : messages.filter(m => !m.isActivityLog), [messages, chatMode] ); + + // Keep composer height in sync when switching empty ↔ non-empty sessions. + useEffect(() => { + compactComposerRef.current = mainTab === 'chat' && visibleMessages.length > 0; + adjustTextareaHeight(); + }, [mainTab, visibleMessages.length, adjustTextareaHeight]); + const chatVirtualizer = useVirtualizer({ count: visibleMessages.length, getScrollElement: () => chatScrollRef.current, @@ -1261,13 +1274,16 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: // Load session messages from DB — phase-aware via ConversationBufferManager. // During streaming phase, DB data is written to cache only, never to display. const loadSessionMessages = useCallback(async (sessionId: string, convKey: string): Promise => { - if (currentConvKeyRef.current === convKey) setLoadingChat(true); + // Soft-refresh (buffer already has messages) should not flash a full-page spinner. + const showSpinner = currentConvKeyRef.current === convKey + && (msgBuffers.get(convKey)?.length ?? 0) === 0; + if (showSpinner) setLoadingChat(true); try { const { count, hasMore: more, oldestCursor } = await loadAndDisplay(sessionId, convKey, async () => { const result = await api.sessions.getMessages(sessionId, 50); - const msgs = result.messages.map(dbMsgToChat).filter(m => + const msgs = dedupeAdjacentUserMessages(result.messages.map(dbMsgToChat).filter(m => m.sender !== 'agent' || m.text || (m.segments && m.segments.length > 0) || m.isStreaming - ); + )); return { messages: msgs, hasMore: result.hasMore, @@ -1278,9 +1294,9 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: oldestMsgId.current = oldestCursor; return count; } finally { - if (currentConvKeyRef.current === convKey) setLoadingChat(false); + if (showSpinner && currentConvKeyRef.current === convKey) setLoadingChat(false); } - }, [loadAndDisplay]); + }, [loadAndDisplay, msgBuffers]); /** * After refresh / session switch: if the server still has an active generation @@ -1290,6 +1306,7 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: const reattachCooldownRef = useRef>(new Map()); const tryReattachActiveStream = useCallback(async (agentId: string, sessionId: string, convKey: string) => { if (!agentId || !sessionId || sessionId === NEW_CHAT_PLACEHOLDER_ID) return; + let abortCtrl: AbortController | null = null; try { // Live send() still owns this session's SSE — keep consuming there; a second // attach would double-apply tool/subagent events. @@ -1322,7 +1339,7 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: reattachCooldownRef.current.set(cooldownKey, Date.now()); reattachAbortRef.current?.abort(); - const abortCtrl = new AbortController(); + abortCtrl = new AbortController(); reattachAbortRef.current = abortCtrl; beginStream(convKey); setSending(true); @@ -1624,10 +1641,11 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: endStream(convKey); if (reattachAbortRef.current === abortCtrl) reattachAbortRef.current = null; } catch (err) { - // Aborted by a newer reattach / navigation — leave stream state alone. - if (err instanceof Error && err.name === 'AbortError') return; + // Aborted by stop / newer reattach / navigation — always clear local stream UI. + if (reattachAbortRef.current === abortCtrl) reattachAbortRef.current = null; endStream(convKey); if (currentConvKeyRef.current === convKey) setSending(false); + if (err instanceof Error && err.name === 'AbortError') return; } }, [appendConvActivity, beginStream, endStream, getStreamSession, msgBuffers, setStreamSession, updateConvMsgs, updateConvMsgsRaf]); @@ -1759,17 +1777,21 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: // If no saved tabs, we'll populate from DB below for direct mode setShowSessions(false); - if (bufferedMsgs !== undefined) { - // Already have content (possibly mid-stream) — show immediately + // Empty in-memory buffers must NOT skip the DB load — a prior race can leave + // `[]` in the map and make history look "missing" until a full page refresh. + const hasBufferedContent = bufferedMsgs !== undefined && bufferedMsgs.length > 0; + + if (hasBufferedContent) { + // Already have content (possibly mid-stream) — show immediately, then soft-refresh if (!isSendingNow) bufMgr.completeLoad(newKey); setLoadingChat(false); - setMessages(bufferedMsgs); + setMessages(bufferedMsgs!); setHasMore(false); if (savedActiveSession !== undefined) { setActiveSessionId(savedActiveSession); } if (!savedTabs || savedTabs.length === 0) setOpenSessionTabs([]); - // For channel/dm modes, refresh from server in background to catch anything we missed + // Refresh from server in background to catch anything we missed while away if (chatMode === 'channel' || chatMode === 'dm') { const channelName = chatMode === 'dm' ? makeDmChannel(authUser?.id ?? '', activeDmUserId) @@ -1781,16 +1803,26 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: && savedActiveSession && savedActiveSession !== NEW_CHAT_PLACEHOLDER_ID ) { - // Resume server stream if the original SSE dropped while we were away. - // Live send() is skipped inside tryReattachActiveStream when it still owns the connection. - void tryReattachActiveStream(selectedAgent, savedActiveSession, newKey); + if (!isSendingNow) { + void loadSessionMessages(savedActiveSession, newKey).then(() => { + if (currentConvKeyRef.current === newKey && selectedAgent) { + void tryReattachActiveStream(selectedAgent, savedActiveSession, newKey); + } + }); + } else { + // Resume server stream if the original SSE dropped while we were away. + void tryReattachActiveStream(selectedAgent, savedActiveSession, newKey); + } } } else { - // First visit for this conversation — load from DB + // First visit / empty buffer — show loading spinner, then load from DB beginLoad(newKey); + setLoadingChat(true); setMessages([]); setHasMore(false); oldestMsgId.current = null; + // Clear a stale empty entry so later visits don't treat it as "already loaded" + msgBuffers.delete(newKey); if (chatMode === 'channel' || chatMode === 'dm') { const channelName = chatMode === 'dm' @@ -1825,6 +1857,7 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: } const validId = restoreId && initialTabs.some(t => t.id === restoreId) ? restoreId : initialTabs[0]!.id; setActiveSessionId(validId); + activeSessionBuffer.set(newKey, validId); setStoredActiveSession(selectedAgent!, validId); setOpenSessionTabs(initialTabs); const restored = s.find(ss => ss.id === validId); @@ -1838,9 +1871,14 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: } else { setActiveSessionId(null); setSessionModelOverride(null); + setLoadingChat(false); if (!savedTabs || savedTabs.length === 0) setOpenSessionTabs([]); } + }).catch(() => { + if (currentConvKeyRef.current === newKey) setLoadingChat(false); }); + } else { + setLoadingChat(false); } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -2080,9 +2118,23 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: const parseMentions = (text: string) => parseMentionNames(text); const stopSending = () => { + // 1) Tell the backend to stop FIRST. Aborting the SSE alone is a soft + // disconnect — the agent keeps working for up to SSE_DISCONNECT_FORCE_STOP_MS + // unless cancel-processing marks userStopped. + const agentId = chatMode === 'direct' ? selectedAgent : null; + if (agentId) { + void api.agents.cancelProcessing(agentId).catch(() => {}); + } + + // 2) Abort both the live send() stream and any reattachStream consumer. + // Previously only abortControllerRef was cleared — after refresh/reattach + // the stop button looked clickable but did nothing to the open SSE. abortControllerRef.current?.abort(); abortControllerRef.current = null; - // Immediately unblock the UI — don't wait for the async send() to catch the abort + reattachAbortRef.current?.abort(); + reattachAbortRef.current = null; + + // 3) Unblock the UI immediately const sendKey = currentConvKeyRef.current; resetSending(sendKey); actBuffers.delete(activeSessionId ?? sendKey); @@ -2090,13 +2142,9 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: endStream(sendKey); setSending(false); setActivities([]); - // Tell the backend to stop the agent's active stream so it doesn't keep - // processing after the SSE connection is torn down. - if (chatMode === 'direct' && selectedAgent) { - void api.agents.cancelProcessing(selectedAgent).catch(() => {}); - } }; + const lastSendGuardRef = useRef<{ text: string; at: number } | null>(null); const send = async (retryText?: string, options?: { isRetry?: boolean; isResume?: boolean }) => { const ctxPrefix = (!retryText && chatContext.length > 0) ? chatContext.map(c => c.content).join('\n\n') + '\n\n' @@ -2106,17 +2154,55 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: if (chatMode === 'direct' && !selectedAgent) return; userAtBottomRef.current = true; + // Ignore accidental double-submit of the same text (double Enter / double click). + const now = Date.now(); + const prevSend = lastSendGuardRef.current; + if ( + !options?.isRetry + && !options?.isResume + && text + && prevSend + && prevSend.text === text + && now - prevSend.at < 1500 + ) { + return; + } + lastSendGuardRef.current = { text, at: now }; + // If agent is currently streaming in this same conversation, interrupt it first. // If the user is in a DIFFERENT session (e.g., new chat tab) while another session // streams, DON'T abort — the agent's mailbox will queue or merge the new message. if (sending && chatMode === 'direct') { const isSameSession = activeSessionId && activeSessionId !== NEW_CHAT_PLACEHOLDER_ID; if (isSameSession) { + const prevKey = currentConvKeyRef.current; + const buf = msgBuffers.get(prevKey) ?? []; + const lastUser = [...buf].reverse().find(m => m.sender === 'user'); + // Same text already in-flight — don't stack another user bubble; retry the turn. + if (lastUser?.text === text && !options?.isRetry && !options?.isResume) { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + void api.agents.cancelProcessing(selectedAgent!).catch(() => {}); + resetSending(prevKey); + actBuffers.delete(activeSessionId ?? prevKey); + endStream(prevKey); + // Drop the in-flight user+empty agent pair before the retry re-adds them. + updateConvMsgs(prevKey, prev => { + const u = [...prev]; + // Remove trailing empty/partial agent, then the matching user bubble. + if (u.length > 0 && u[u.length - 1]!.sender === 'agent') u.pop(); + if (u.length > 0 && u[u.length - 1]!.sender === 'user' && u[u.length - 1]!.text === text) u.pop(); + return u; + }); + setSending(false); + setActivities([]); + await new Promise(r => setTimeout(r, 50)); + return send(text, { isRetry: true }); + } // Same session: interrupt current stream and resend abortControllerRef.current?.abort(); abortControllerRef.current = null; void api.agents.cancelProcessing(selectedAgent!).catch(() => {}); - const prevKey = currentConvKeyRef.current; resetSending(prevKey); actBuffers.delete(activeSessionId ?? prevKey); endStream(prevKey); @@ -2210,7 +2296,7 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: // Human-to-human DM or personal notepad — no agent routing const dmChannel = makeDmChannel(authUser?.id ?? '', activeDmUserId); const optId = `opt_${Date.now()}`; - const userMsgDm: ChatMsg = { id: optId, sender: 'user', text, time: new Date().toLocaleTimeString() }; + const userMsgDm: ChatMsg = { id: optId, sender: 'user', text, time: new Date().toLocaleTimeString(), rawCreatedAt: new Date().toISOString() }; if (replyCtx) { userMsgDm.replyToId = replyCtx.id; userMsgDm.replyToSender = replyCtx.sender; userMsgDm.replyToText = replyCtx.text; } updateConvMsgs(sendKey, prev => [...prev, userMsgDm]); try { @@ -2238,7 +2324,7 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: if (currentConvKeyRef.current === sendKey) setSending(false); } else if (chatMode === 'channel') { const optId = `opt_${Date.now()}`; - const userMsgCh: ChatMsg = { id: optId, sender: 'user', text, time: new Date().toLocaleTimeString() }; + const userMsgCh: ChatMsg = { id: optId, sender: 'user', text, time: new Date().toLocaleTimeString(), rawCreatedAt: new Date().toISOString() }; if (replyCtx) { userMsgCh.replyToId = replyCtx.id; userMsgCh.replyToSender = replyCtx.sender; userMsgCh.replyToText = replyCtx.text; } updateConvMsgs(sendKey, prev => [...prev, userMsgCh]); @@ -2264,11 +2350,10 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: } try { - const channelTextWithQuote = replyCtx - ? `> **${replyCtx.sender}**: ${replyCtx.text}\n\n${text}` - : text; + // Persist/send only the user's text. Reply context is carried via replyToId + // (server prefixes [REPLY] for the agent; UI shows the quote header from metadata). const result = await api.channels.sendMessage(activeChannel, { - text: channelTextWithQuote, senderName: authUser?.name ?? t('page.fallbackYou'), mentions, + text, senderName: authUser?.name ?? t('page.fallbackYou'), mentions, senderId: authUser?.id, orgId: 'default', replyToId: replyCtx?.id, @@ -2297,7 +2382,9 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: } else { // direct — build an interleaved segment stream beginStream(sendKey); - const agentMsgId = `a_${Date.now()}`; + const sendNonce = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + const agentMsgId = `a_${sendNonce}`; + const optimisticUserId = `u_${sendNonce}`; // Mutable session ID that gets resolved when session_start event arrives let streamSessionId: string | null = activeSessionId === NEW_CHAT_PLACEHOLDER_ID ? null : activeSessionId; if (options?.isResume) { @@ -2310,7 +2397,7 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: ], streamSessionId); } else { const agentCreatedAt = new Date().toISOString(); - const userMsg: ChatMsg = { id: `u_${Date.now()}`, sender: 'user', text, time: new Date().toLocaleTimeString() }; + const userMsg: ChatMsg = { id: optimisticUserId, sender: 'user', text, time: new Date().toLocaleTimeString(), rawCreatedAt: agentCreatedAt }; if (imagesToSend?.length) userMsg.images = imagesToSend; if (replyCtx) { userMsg.replyToId = replyCtx.id; userMsg.replyToSender = replyCtx.sender; userMsg.replyToText = replyCtx.text; } updateConvMsgs(sendKey, prev => [ @@ -2391,6 +2478,12 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: if (sessionModelOverride) { void api.sessions.setModelOverride(event.sessionId, sessionModelOverride).catch(() => {}); } + // Replace optimistic user id with the server-persisted id so reload/dedupe align. + if (event.userMessageId && !options?.isResume) { + updateConvMsgs(sendKey, prev => prev.map(m => + m.id === optimisticUserId ? { ...m, id: event.userMessageId! } : m + ), event.sessionId); + } // Seed the session cache with current buffer so streaming reads don't start empty const currentBuf = msgBuffers.get(sendKey); if (currentBuf && currentBuf.length > 0) { @@ -2562,11 +2655,10 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: try { lastSseEventTimeRef.current = Date.now(); const effectiveSessionId = activeSessionId === NEW_CHAT_PLACEHOLDER_ID ? null : activeSessionId; - const textWithQuote = replyCtx - ? `> **${replyCtx.sender}**: ${replyCtx.text}\n\n${text}` - : text; + // Persist/send only the user's text. Reply context goes via replyTo metadata + // so reload does not show the quoted agent message inside the user bubble. const streamResult = await api.agents.messageStream( - selectedAgent, textWithQuote, + selectedAgent, text, appendTextChunk, handleToolEvent, abortCtrl.signal, @@ -2581,9 +2673,12 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: ); if (currentConvKeyRef.current === sendKey) { // Message was merged into the agent's active processing — remove the - // empty agent placeholder bubble since no separate reply will arrive. + // empty agent placeholder and the follow-up user bubble (server also + // deletes that DB row so reload won't resurrect it). if (streamResult.merged) { - updateConvMsgs(sendKey, prev => prev.filter(m => m.id !== agentMsgId), streamSessionId); + updateConvMsgs(sendKey, prev => prev.filter(m => + m.id !== agentMsgId && m.id !== optimisticUserId + ), streamSessionId); } // Apply server's authoritative final segments and content so the @@ -3328,6 +3423,9 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: // ── Render ──────────────────────────────────────────────────────────────────── const showChatOnMobile = isMobile && mobileLayer === 'chat'; const isEmptyChat = mainTab === 'chat' && visibleMessages.length === 0 && !sending && !loadingChat; + // Non-empty sessions: Cursor-style single-line composer that grows with content. + const compactComposer = mainTab === 'chat' && visibleMessages.length > 0; + compactComposerRef.current = compactComposer; return (
@@ -4124,11 +4222,14 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: {/* Chat Tab: Messages */}
{loadingChat && visibleMessages.length === 0 && ( -
- +
+ + + {t('page.loadingChat', { defaultValue: 'Loading conversation…' })} +
)} {loadingMore && ( @@ -4150,7 +4251,9 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: const prevMsg = vIdx > 0 ? visibleMessages[vIdx - 1] : null; const curDate = getDateKey(msg.rawCreatedAt); const prevDate = prevMsg ? getDateKey(prevMsg.rawCreatedAt) : ''; - const showDateSep = curDate && curDate !== prevDate; + // Require both sides to have a date — optimistic local bubbles often omit + // rawCreatedAt, which previously made every agent reply look like a new day ("今天"). + const showDateSep = Boolean(curDate && prevDate && curDate !== prevDate); const isLastMsg = vIdx === visibleMessages.length - 1; const isPending = isLastPending && isLastMsg; const isStreamingMsg = (isPending && sending) || !!msg.isStreaming; @@ -4232,10 +4335,8 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: )}
+ showStreamingBubble && msg.sender === 'agent' ? 'streaming-bubble' : '' + }`}> {msg.sender === 'user' ?
{msg.images && msg.images.length > 0 && ( @@ -4426,7 +4527,11 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: {/* Input (only in chat tab) */}
-
+
{mentionDropdown && allMentionItems.length > 0 && (
@@ -4536,88 +4641,104 @@ export function TeamPage({ initialAgentId, authUser, previewMode, previewData }: -
-