From 66a1e0bdc4bb7f7fd5065137c43b2c673ecc2427 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 1 Jul 2026 19:39:20 +0800 Subject: [PATCH] feat(feishu): streaming text, merged handling, approval routing, full presets - Show streaming text_delta content in Feishu cards during processing - Handle [merged] replies gracefully instead of showing raw marker - Route approval cards to active Feishu conversation thread - Enable all lark-mcp presets by default (calendar, task, im, base.batch) - Add billing service and license management enhancements Co-authored-by: Cursor --- packages/cli/src/commands/start.ts | 2 +- packages/org-manager/src/api-server.ts | 39 +++++++++++++++++-- packages/org-manager/src/billing-service.ts | 2 +- packages/org-manager/src/feishu-notifier.ts | 14 ++++++- packages/org-manager/src/org-service.ts | 2 +- .../org-manager/test/license-service.test.ts | 2 +- packages/shared/src/types/license.ts | 3 ++ packages/shared/test/shared-types.test.ts | 2 + packages/storage/src/sqlite-storage.ts | 2 +- packages/web-ui/src/api.ts | 4 +- packages/web-ui/src/locales/en/settings.json | 1 + .../web-ui/src/locales/zh-CN/settings.json | 1 + packages/web-ui/src/pages/Settings.tsx | 10 +++-- 13 files changed, 69 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 379d5117..d0768e05 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -332,7 +332,7 @@ export async function createServices(config: ReturnType) { const feishuAppId = feishuIntegration?.appId ?? process.env['FEISHU_APP_ID']; const feishuAppSecret = feishuIntegration?.appSecret ?? process.env['FEISHU_APP_SECRET']; if (feishuAppId && feishuAppSecret) { - const presets = feishuIntegration?.mcp?.presets ?? ['preset.default']; + const presets = feishuIntegration?.mcp?.presets ?? ['preset.default', 'preset.calendar.default', 'preset.task.default', 'preset.im.default', 'preset.base.batch']; let larkMcpBin: string; try { const esmRequire = createRequire(import.meta.url); diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 72cd5803..80e5761f 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -284,7 +284,8 @@ export class APIServer { const teams = this.orgService.listTeams(orgId); const humans = this.orgService.listHumanUsers(orgId); const todayToolCalls = this.getToolCallsTodayFromAgents(); - info.usage = { teams: teams.length, toolCallsToday: todayToolCalls, users: humans.length }; + const agentCount = this.orgService.getAgentManager().listAgents().length; + info.usage = { agents: agentCount, teams: teams.length, toolCallsToday: todayToolCalls, users: humans.length }; } catch { /* non-critical */ } return info; } @@ -1655,11 +1656,15 @@ export class APIServer { const secretary = agentManager.getAgent(secretaryInfo.id); const senderName = payload['senderName'] as string ?? senderId ?? 'feishu_user'; + // Track active conversation so approvals are routed to this chat + this.feishuNotifier?.setActiveConversationChat(chatId); + // Streaming state for real-time card updates const toolCalls: Array<{ name: string; status: 'running' | 'done' | 'error'; durationMs?: number }> = []; let lastCardUpdatePhase: AgentCardPhase = 'thinking'; let cardUpdatePending = false; let cardUpdateTimer: ReturnType | null = null; + let streamingText = ''; // Throttled card update — avoid excessive API calls (max once per 2s) const CARD_UPDATE_INTERVAL_MS = 2000; @@ -1674,6 +1679,7 @@ export class APIServer { agentName, phase: lastCardUpdatePhase, toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined, + content: streamingText || undefined, }); await this.feishuNotifier!.updateCard(statusCardId!, card); } catch (e) { @@ -1683,7 +1689,7 @@ export class APIServer { }; // Stream event handler — updates card state in real-time - const handleStreamEvent = (event: { type: string; tool?: string; phase?: string; success?: boolean; durationMs?: number; agentEvent?: string }) => { + const handleStreamEvent = (event: { type: string; tool?: string; phase?: string; success?: boolean; durationMs?: number; agentEvent?: string; text?: string }) => { if (event.type === 'agent_tool') { if (event.phase === 'start' && event.tool) { toolCalls.push({ name: event.tool, status: 'running' }); @@ -1697,6 +1703,10 @@ export class APIServer { } scheduleCardUpdate(); } + } else if (event.type === 'text_delta' && event.text) { + streamingText += event.text; + lastCardUpdatePhase = 'tool_calling'; + scheduleCardUpdate(); } }; @@ -1714,6 +1724,27 @@ export class APIServer { const elapsedMs = Date.now() - startTime; + // Handle merged messages — user sent while agent was already processing + if (reply === '[merged]' || reply === '[Stream cancelled]') { + log.info('Feishu message was merged into active processing', { chatId }); + if (messageId && this.feishuNotifier) { + if (processingReactionId) { + await this.feishuNotifier.deleteReaction(messageId, processingReactionId); + } + await this.feishuNotifier.addReaction(messageId, 'OnIt'); + } + if (statusCardId && this.feishuNotifier) { + const mergedCard = buildAgentResponseCard({ + agentName, + phase: 'done', + content: '已收到,已合并到当前正在处理的对话中。', + elapsedMs, + }); + await this.feishuNotifier.updateCard(statusCardId, mergedCard).catch(() => {}); + } + return; + } + // Strip thinking/reasoning blocks — only show the clean response to user const { clean: cleanReply } = extractThinkBlocks(reply ?? ''); const displayContent = stripInternalBlocks(cleanReply); @@ -1788,6 +1819,8 @@ export class APIServer { } else { await this.feishuNotifier?.sendTextToChat(chatId, `处理消息时出错: ${errMsg}`); } + } finally { + this.feishuNotifier?.setActiveConversationChat(null); } } @@ -7678,7 +7711,7 @@ EXPLANATION_END`; if (path === '/api/license' && req.method === 'GET') { const raw = this.licenseService ? this.licenseService.getInfo() - : { plan: 'free', features: [], limits: { maxTeams: 5, maxToolCallsPerDay: 5000, maxUsers: 1 } }; + : { plan: 'free', features: [], limits: { maxAgents: 20, maxTeams: 5, maxToolCallsPerDay: 5000, maxUsers: 1 } }; this.json(res, 200, await this.buildLicenseResponse(raw, req)); return; } diff --git a/packages/org-manager/src/billing-service.ts b/packages/org-manager/src/billing-service.ts index e8779e23..8a5c6a76 100644 --- a/packages/org-manager/src/billing-service.ts +++ b/packages/org-manager/src/billing-service.ts @@ -49,7 +49,7 @@ export interface OrgPlan { const DEFAULT_PLANS: Record = { free: { - maxAgents: -1, + maxAgents: 20, maxTokensPerMonth: -1, maxToolCallsPerDay: 5000, maxMessagesPerDay: -1, diff --git a/packages/org-manager/src/feishu-notifier.ts b/packages/org-manager/src/feishu-notifier.ts index 60170b2d..23c075d0 100644 --- a/packages/org-manager/src/feishu-notifier.ts +++ b/packages/org-manager/src/feishu-notifier.ts @@ -311,6 +311,8 @@ export class FeishuNotifier { private wsConnected = false; /** Map messageId → approvalId for tracking replies as comments */ private approvalMessageMap = new Map(); + /** Active Feishu chat being served — approvals are sent here too */ + private activeConversationChatId: string | null = null; constructor(opts: { eventBus: EventBus; @@ -883,9 +885,14 @@ export class FeishuNotifier { matchedTargets.push(...rule.targets); } + // For approvals during active conversation, send to the user's chat directly + const isApprovalType = ['approval_requested', 'approval_approved', 'approval_rejected'].includes(eventType); + if (isApprovalType && this.activeConversationChatId) { + matchedTargets.push({ type: 'chat', channelId: this.activeConversationChatId }); + } + // Fallback: use notifyChatId or notifyOpenId from simplified config if (matchedTargets.length === 0 && (this.config.notifyChatId || this.config.notifyOpenId)) { - const isApprovalType = ['approval_requested', 'approval_approved', 'approval_rejected'].includes(eventType); const shouldForward = isApprovalType ? this.config.notifyOnApproval !== false : this.config.notifyOnNotification === true; @@ -949,6 +956,11 @@ export class FeishuNotifier { } } + /** Set the active Feishu conversation chatId (approvals will also be sent here). */ + setActiveConversationChat(chatId: string | null): void { + this.activeConversationChatId = chatId; + } + /** Send a text message to a specific Feishu chat. */ async sendTextToChat(chatId: string, text: string): Promise { if (!this.apiClient) return; diff --git a/packages/org-manager/src/org-service.ts b/packages/org-manager/src/org-service.ts index d0bbf317..03292ab9 100644 --- a/packages/org-manager/src/org-service.ts +++ b/packages/org-manager/src/org-service.ts @@ -199,7 +199,7 @@ export class OrganizationService { name, ownerId, plan: 'free', - maxAgents: -1, + maxAgents: 20, createdAt: new Date().toISOString(), }; diff --git a/packages/org-manager/test/license-service.test.ts b/packages/org-manager/test/license-service.test.ts index edf31e12..9231802d 100644 --- a/packages/org-manager/test/license-service.test.ts +++ b/packages/org-manager/test/license-service.test.ts @@ -26,7 +26,7 @@ function defaultLicenseJson() { plan: 'free', features: [], limits: { - maxAgents: -1, + maxAgents: 20, maxTokensPerMonth: -1, maxToolCallsPerDay: 5000, maxMessagesPerDay: -1, diff --git a/packages/shared/src/types/license.ts b/packages/shared/src/types/license.ts index 8c7a8461..272a8490 100644 --- a/packages/shared/src/types/license.ts +++ b/packages/shared/src/types/license.ts @@ -1,6 +1,7 @@ export type PlanTier = 'free' | 'enterprise'; export interface PlanLimits { + maxAgents: number; maxTeams: number; maxToolCallsPerDay: number; maxUsers: number; @@ -8,11 +9,13 @@ export interface PlanLimits { export const PLAN_LIMITS: Record = { free: { + maxAgents: 20, maxTeams: 5, maxToolCallsPerDay: 5000, maxUsers: 1, }, enterprise: { + maxAgents: -1, maxTeams: -1, maxToolCallsPerDay: -1, maxUsers: -1, diff --git a/packages/shared/test/shared-types.test.ts b/packages/shared/test/shared-types.test.ts index a91f2662..d4f037e5 100644 --- a/packages/shared/test/shared-types.test.ts +++ b/packages/shared/test/shared-types.test.ts @@ -40,12 +40,14 @@ describe('CognitiveDepth', () => { describe('PLAN_LIMITS', () => { it('defines free tier limits', () => { + expect(PLAN_LIMITS.free.maxAgents).toBe(20); expect(PLAN_LIMITS.free.maxTeams).toBe(5); expect(PLAN_LIMITS.free.maxToolCallsPerDay).toBe(5000); expect(PLAN_LIMITS.free.maxUsers).toBe(1); }); it('defines enterprise tier as unlimited', () => { + expect(PLAN_LIMITS.enterprise.maxAgents).toBe(-1); expect(PLAN_LIMITS.enterprise.maxTeams).toBe(-1); expect(PLAN_LIMITS.enterprise.maxToolCallsPerDay).toBe(-1); expect(PLAN_LIMITS.enterprise.maxUsers).toBe(-1); diff --git a/packages/storage/src/sqlite-storage.ts b/packages/storage/src/sqlite-storage.ts index 08a1a670..16295a74 100644 --- a/packages/storage/src/sqlite-storage.ts +++ b/packages/storage/src/sqlite-storage.ts @@ -793,7 +793,7 @@ export class SqliteOrgRepo { `INSERT OR IGNORE INTO organizations (id, name, owner_id, plan, max_agents, settings, created_at, updated_at) VALUES (?, ?, ?, ?, ?, '{}', ?, ?)` ) - .run(data.id, data.name, data.ownerId, data.plan ?? 'free', data.maxAgents ?? 5, ts, ts); + .run(data.id, data.name, data.ownerId, data.plan ?? 'free', data.maxAgents ?? 20, ts, ts); return this.findOrgById(data.id)!; } diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index f54ba20b..b91b9fbd 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -1590,8 +1590,8 @@ export const api = { request<{ avatarUrl: string }>('/avatars/upload', { method: 'POST', body: JSON.stringify({ image, type, id }) }), }, license: { - get: () => request<{ plan: string; licenseKey?: string; validUntil?: string; isTrial?: boolean; isOffline?: boolean; features: string[]; limits: { maxTeams: number; maxToolCallsPerDay: number; maxUsers: number }; usage?: { teams: number; toolCallsToday: number; users: number }; instanceId: string; hubUserId?: string; username?: string; orgId?: string; orgName?: string; maxSeats?: number; usedSeats?: number }>('/license'), - refresh: () => request<{ plan: string; licenseKey?: string; validUntil?: string; isTrial?: boolean; isOffline?: boolean; features: string[]; limits: { maxTeams: number; maxToolCallsPerDay: number; maxUsers: number }; usage?: { teams: number; toolCallsToday: number; users: number }; instanceId: string; hubUserId?: string; username?: string; orgId?: string; orgName?: string; maxSeats?: number; usedSeats?: number }>('/license/refresh', { method: 'POST' }), + get: () => request<{ plan: string; licenseKey?: string; validUntil?: string; isTrial?: boolean; isOffline?: boolean; features: string[]; limits: { maxAgents: number; maxTeams: number; maxToolCallsPerDay: number; maxUsers: number }; usage?: { agents: number; teams: number; toolCallsToday: number; users: number }; instanceId: string; hubUserId?: string; username?: string; orgId?: string; orgName?: string; maxSeats?: number; usedSeats?: number }>('/license'), + refresh: () => request<{ plan: string; licenseKey?: string; validUntil?: string; isTrial?: boolean; isOffline?: boolean; features: string[]; limits: { maxAgents: number; maxTeams: number; maxToolCallsPerDay: number; maxUsers: number }; usage?: { agents: number; teams: number; toolCallsToday: number; users: number }; instanceId: string; hubUserId?: string; username?: string; orgId?: string; orgName?: string; maxSeats?: number; usedSeats?: number }>('/license/refresh', { method: 'POST' }), activate: (licenseKey: string) => request<{ success: boolean; error?: string }>('/license/activate', { method: 'POST', body: JSON.stringify({ licenseKey }) }), trial: () => diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index 770ca6a0..88e58628 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -559,6 +559,7 @@ "offline": "Offline", "details": "License Details", "manage": "License Management", + "limitAgents": "AI Agents", "limitTeams": "AI Agent Teams", "limitToolCalls": "AI Tool Calls", "limitUsers": "Human Users", diff --git a/packages/web-ui/src/locales/zh-CN/settings.json b/packages/web-ui/src/locales/zh-CN/settings.json index fca3d1d3..65dc08bf 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -559,6 +559,7 @@ "offline": "离线", "details": "许可证详情", "manage": "许可证管理", + "limitAgents": "智能体数", "limitTeams": "AI Agent 团队数", "limitToolCalls": "AI Tool 调用次数", "limitUsers": "人类员工数", diff --git a/packages/web-ui/src/pages/Settings.tsx b/packages/web-ui/src/pages/Settings.tsx index c6838a6c..0dd042a2 100644 --- a/packages/web-ui/src/pages/Settings.tsx +++ b/packages/web-ui/src/pages/Settings.tsx @@ -3395,8 +3395,8 @@ function OrgLicenseSection() { // ── License state ── const [licenseInfo, setLicenseInfo] = useState<{ plan: string; licenseKey?: string; validUntil?: string; isTrial?: boolean; isOffline?: boolean; - features: string[]; limits: { maxTeams: number; maxToolCallsPerDay: number; maxUsers: number }; - usage?: { teams: number; toolCallsToday: number; users: number }; + features: string[]; limits: { maxAgents: number; maxTeams: number; maxToolCallsPerDay: number; maxUsers: number }; + usage?: { agents: number; teams: number; toolCallsToday: number; users: number }; instanceId: string; hubUserId?: string; username?: string; orgId?: string; orgName?: string; maxSeats?: number; usedSeats?: number; } | null>(null); @@ -3973,7 +3973,8 @@ function LicensePlanCard({ isEnterprise, licenseInfo, daysRemaining, effectiveVa )} -
+
+
{licenseInfo?.usage?.agents ?? 0} / ∞
{t('license.limitAgents')}
{licenseInfo?.usage?.teams ?? 0} / ∞
{t('license.limitTeams')}
{licenseInfo?.usage?.toolCallsToday ?? 0} / ∞
{t('license.limitToolCalls')}{t('license.perDay')}
{displayUsers} / {effectiveMaxSeats ?? '∞'}
{t('license.limitUsers')}
@@ -4001,7 +4002,8 @@ function LicensePlanCard({ isEnterprise, licenseInfo, daysRemaining, effectiveVa
{t('license.planFree')}{t('license.freeFeaturesDesc')}
-
+
+
{licenseInfo?.usage?.agents ?? 0} / {licenseInfo?.limits?.maxAgents ?? 20}
{t('license.limitAgents')}
{licenseInfo?.usage?.teams ?? 0} / {licenseInfo?.limits?.maxTeams ?? 5}
{t('license.limitTeams')}
{licenseInfo?.usage?.toolCallsToday ?? 0} / {(licenseInfo?.limits?.maxToolCallsPerDay ?? 5000).toLocaleString()}
{t('license.limitToolCalls')}{t('license.perDay')}
{displayUsers} / {licenseInfo?.limits?.maxUsers ?? 1}
{t('license.limitUsers')}