Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions RELEASELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Markus Cloud / Hub 额度与计费体验落地;认知增强与统一 A2A 消
- **Conversation buffer 状态机** — 抽取会话缓冲状态机,修复聊天竞态与流式重连边界
- **Hub recommended routing** — Hub 推荐路由与模型目录 / 路由能力增强
- **Team Chat 菜单优化** — 「移动到」可搜索折叠、组织秘书保护、文案改为「移出团队」
- **飞书原生发图** — `feishu_send_image` / `feishu_send_message`,支持本地图片上传发送

### Bug Fixes

Expand All @@ -20,6 +21,19 @@ 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 按钮可见性修正

### v0.9.0-rc.3

- **CI / typecheck** — `@markus/core` 补充 `@markus/comms` project reference,修复干净环境下 `feishu.ts` 找不到模块
- **Lint** — embedded browser / browserUrl 的 eqeqeq、prefer-const 修复

### Stats

- 290 files changed, +36,540 / −12,148 lines
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,5 @@
"node-datachannel"
]
},
"version": "0.9.0-rc.0"
"version": "0.9.0-rc.3"
}
2 changes: 1 addition & 1 deletion packages/a2a/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"dependencies": {
"@markus/shared": "workspace:*"
},
"version": "0.9.0-rc.0"
"version": "0.9.0-rc.3"
}
2 changes: 1 addition & 1 deletion packages/chrome-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"esbuild": "^0.25.0",
"typescript": "^5.6.0"
},
"version": "0.9.0-rc.0"
"version": "0.9.0-rc.3"
}
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,5 @@
"commander": "^14.0.3",
"esbuild": "^0.27.4"
},
"version": "0.9.0-rc.0"
"version": "0.9.0-rc.3"
}
17 changes: 14 additions & 3 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,9 @@ export async function createServices(config: ReturnType<typeof loadConfig>) {
}
}

// 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
Expand Down Expand Up @@ -359,6 +359,16 @@ export async function createServices(config: ReturnType<typeof loadConfig>) {
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,
Expand All @@ -367,6 +377,7 @@ export async function createServices(config: ReturnType<typeof loadConfig>) {
skillRegistry,
taskService,
mcpServers,
feishuToolsConfig,
});

if (config.agent?.maxToolIterations) {
Expand Down
2 changes: 1 addition & 1 deletion packages/comms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"dependencies": {
"@markus/shared": "workspace:*"
},
"version": "0.9.0-rc.0"
"version": "0.9.0-rc.3"
}
3 changes: 2 additions & 1 deletion packages/comms/src/feishu/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
36 changes: 36 additions & 0 deletions packages/comms/src/feishu/client.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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<string> {
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<string> {
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<string> {
const imageKey = await this.uploadImage(filePath);
return this.sendImageMessage(receiveId, imageKey, idType);
}

async sendRichTextMessage(chatId: string, title: string, content: Array<Array<Record<string, unknown>>>, idType: ReceiveIdType = 'chat_id'): Promise<string> {
return this.sendMessage(chatId, 'post', JSON.stringify({
zh_cn: { title, content },
Expand Down
1 change: 1 addition & 0 deletions packages/comms/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
7 changes: 4 additions & 3 deletions packages/comms/test/feishu-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ function makeMockClient(): Partial<FeishuClient> {
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),
Expand Down Expand Up @@ -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 () => {
Expand Down
40 changes: 40 additions & 0 deletions packages/comms/test/feishu-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.3"
}
26 changes: 26 additions & 0 deletions packages/core/src/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -327,6 +328,8 @@ export class AgentManager {
private embeddedBrowserHost: EmbeddedBrowserHost | null = null;
private globalSecurityPolicy?: SecurityPolicy;
private globalMcpServers?: Record<string, MCPServerConfig>;
/** When set, register native Feishu send tools (incl. local image upload). */
private feishuToolsConfig?: FeishuToolsConfig;
private skillRegistry?: SkillRegistry;
private skillSearcher?: (query: string) => Promise<Array<{ name: string; description: string; source: string; slug?: string; author?: string; githubRepo?: string; githubSkillPath?: string }>>;
private skillInstaller?: (request: Record<string, unknown>) => Promise<{ installed: boolean; name: string; method: string }>;
Expand Down Expand Up @@ -475,6 +478,7 @@ export class AgentManager {
eventBus?: EventBus;
securityPolicy?: SecurityPolicy;
mcpServers?: Record<string, MCPServerConfig>;
feishuToolsConfig?: FeishuToolsConfig;
skillRegistry?: SkillRegistry;
taskService?: TaskServiceBridge;
templateRegistry?: TemplateRegistry;
Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1733,6 +1755,8 @@ export class AgentManager {
}
}

this.registerFeishuTools(agent);

if (this.agentAuditCallback) {
const cb = this.agentAuditCallback;
agent.setAuditCallback(event => cb(id, event));
Expand Down Expand Up @@ -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[] = [];
Expand Down
Loading
Loading