| import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js'; | ||
| import { sendPermissionCard, updatePermissionCard } from './message-sender.js'; | ||
| import { createLogger } from '../logger.js'; | ||
| const log = createLogger('FeishuPermission'); | ||
| /** | ||
| * 注册飞书平台的权限消息发送器 | ||
| */ | ||
| export function registerFeishuPermissionSender() { | ||
| registerPermissionSender('feishu', { | ||
| sendPermissionCard, | ||
| updatePermissionCard: ({ messageId, toolName, decision }) => updatePermissionCard(messageId, toolName, decision), | ||
| }); | ||
| } | ||
| /** | ||
| * 处理权限按钮点击(allow/deny) | ||
| */ | ||
| export function handlePermissionAction(requestId, decision) { | ||
| const resolvedId = resolvePermissionById(requestId, decision); | ||
| if (resolvedId) { | ||
| log.info(`Permission ${decision} via button for request ${requestId}`); | ||
| } | ||
| else { | ||
| log.warn(`No pending permission request found for requestId: ${requestId}`); | ||
| } | ||
| } |
| import { sendThinkingCard, streamContentUpdate, sendFinalCards, sendErrorCard, sendTextReply, uploadAndSendImage } from './message-sender.js'; | ||
| import { buildCardV2 } from './card-builder.js'; | ||
| import { destroySession, updateCardFull, disableStreaming } from './cardkit-manager.js'; | ||
| import { runClaudeTask } from '../shared/claude-task.js'; | ||
| import { CARDKIT_THROTTLE_MS } from '../constants.js'; | ||
| import { createLogger } from '../logger.js'; | ||
| const log = createLogger('FeishuTask'); | ||
| export async function executeClaudeTask(deps, userId, chatId, prompt, workDir, convId, threadCtx, mentionedBot, isGroup) { | ||
| const { config, sessionManager, userCosts, runningTasks } = deps; | ||
| const sessionId = threadCtx && threadCtx.threadId | ||
| ? sessionManager.getSessionIdForThread(userId, threadCtx.threadId) | ||
| : convId | ||
| ? sessionManager.getSessionIdForConv(userId, convId) | ||
| : undefined; | ||
| log.info(`Running Claude for user ${userId}, ${threadCtx ? `thread=${threadCtx.threadId}` : `convId=${convId}`}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`); | ||
| let cardHandle; | ||
| try { | ||
| cardHandle = await sendThinkingCard(chatId, threadCtx); | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send thinking card:', err); | ||
| return; | ||
| } | ||
| const { messageId, cardId } = cardHandle; | ||
| if (!cardId) { | ||
| log.error('No card_id returned for thinking card'); | ||
| return; | ||
| } | ||
| const taskKey = `${userId}:${cardId}`; | ||
| let waitingTimer = null; | ||
| await runClaudeTask({ config, sessionManager, userCosts }, { | ||
| userId, | ||
| chatId, | ||
| workDir, | ||
| sessionId, | ||
| convId, | ||
| threadId: threadCtx?.threadId, | ||
| threadRootMsgId: threadCtx?.rootMessageId, | ||
| platform: 'feishu', | ||
| taskKey, | ||
| }, prompt, { | ||
| throttleMs: CARDKIT_THROTTLE_MS, | ||
| streamUpdate: (content, toolNote) => { | ||
| streamContentUpdate(cardId, content, toolNote).catch((e) => log.warn('Stream update failed:', e?.message ?? e)); | ||
| }, | ||
| sendComplete: async (content, note, thinkingText) => { | ||
| try { | ||
| await sendFinalCards(chatId, messageId, cardId, content, note, threadCtx, thinkingText); | ||
| if (isGroup && mentionedBot) { | ||
| const replyText = `<at user_id="${userId}"></at> 任务已完成 ✅`; | ||
| await sendTextReply(chatId, replyText, threadCtx); | ||
| } | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send final cards:', err); | ||
| } | ||
| }, | ||
| sendError: async (error) => { | ||
| try { | ||
| await sendErrorCard(cardId, error); | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send error card:', err); | ||
| } | ||
| }, | ||
| onThinkingToText: (content, _thinkingText) => { | ||
| const resetCard = buildCardV2({ content: content || '...', status: 'streaming' }, cardId); | ||
| updateCardFull(cardId, resetCard) | ||
| .catch((e) => log.warn('Thinking→text transition update failed:', e?.message ?? e)); | ||
| }, | ||
| extraCleanup: () => { | ||
| if (waitingTimer) { | ||
| clearInterval(waitingTimer); | ||
| waitingTimer = null; | ||
| } | ||
| runningTasks.delete(taskKey); | ||
| }, | ||
| onTaskReady: (state) => { | ||
| runningTasks.set(taskKey, { ...state, cardId, messageId }); | ||
| const startTime = Date.now(); | ||
| waitingTimer = setInterval(() => { | ||
| const taskInfo = runningTasks.get(taskKey); | ||
| if (!taskInfo) { | ||
| if (waitingTimer) { | ||
| clearInterval(waitingTimer); | ||
| waitingTimer = null; | ||
| } | ||
| return; | ||
| } | ||
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | ||
| streamContentUpdate(cardId, `等待 Claude 响应... (${elapsed}s)`).catch(() => { }); | ||
| }, 3000); | ||
| }, | ||
| onFirstContent: () => { | ||
| if (waitingTimer) { | ||
| clearInterval(waitingTimer); | ||
| waitingTimer = null; | ||
| } | ||
| }, | ||
| sendImage: (imagePath) => uploadAndSendImage(chatId, imagePath, threadCtx), | ||
| }); | ||
| } | ||
| export function handleStopAction(runningTasks, userId, cardId) { | ||
| const taskKey = `${userId}:${cardId}`; | ||
| const taskInfo = runningTasks.get(taskKey); | ||
| if (taskInfo) { | ||
| log.info(`User ${userId} stopped task for card ${cardId}`); | ||
| const stoppedContent = taskInfo.latestContent || '(任务已停止,暂无输出)'; | ||
| runningTasks.delete(taskKey); | ||
| taskInfo.settle(); | ||
| taskInfo.handle.abort(); | ||
| const stoppedCard = buildCardV2({ content: stoppedContent, status: 'done', note: '⏹️ 已停止' }); | ||
| disableStreaming(cardId) | ||
| .then(() => updateCardFull(cardId, stoppedCard)) | ||
| .catch((e) => log.warn('Stop card update failed:', e?.message ?? e)) | ||
| .finally(() => destroySession(cardId)); | ||
| } | ||
| else { | ||
| log.warn(`No running task found for key: ${taskKey}`); | ||
| log.info(`Current running tasks: ${Array.from(runningTasks.keys()).join(', ')}`); | ||
| } | ||
| } |
@@ -8,3 +8,3 @@ import { resolveLatestPermission, getPendingCount } from '../hook/permission-server.js'; | ||
| import { homedir } from 'node:os'; | ||
| import { getHistory, formatHistoryPage } from '../shared/history.js'; | ||
| import { getHistory, formatHistoryPage, getSessionList, formatSessionList } from '../shared/history.js'; | ||
| /** | ||
@@ -63,2 +63,5 @@ * 共享的命令处理器 | ||
| } | ||
| if (trimmed === '/resume' || trimmed.startsWith('/resume ')) { | ||
| return this.handleResume(chatId, userId, trimmed.slice(7).trim(), threadCtx); | ||
| } | ||
| // 仅终端可用的命令 | ||
@@ -93,3 +96,4 @@ const cmdName = trimmed.split(/\s+/)[0]; | ||
| '/list - 列出所有工作区', | ||
| '/history [页码] - 浏览会话历史记录', | ||
| '/history [页码] - 查看当前会话聊天记录', | ||
| '/resume [序号] - 浏览/恢复历史会话', | ||
| threadsCmd, | ||
@@ -131,3 +135,3 @@ stopCmd, | ||
| async handleCd(chatId, userId, args, threadCtx) { | ||
| const dir = args.trim(); | ||
| let dir = args.trim(); | ||
| if (!dir) { | ||
@@ -146,2 +150,12 @@ const workDir = threadCtx | ||
| } | ||
| // 支持通过序号切换(对应 /list 的编号) | ||
| if (/^\d+$/.test(dir)) { | ||
| const index = parseInt(dir, 10) - 1; | ||
| const dirs = this.listClaudeProjects(); | ||
| if (index < 0 || index >= dirs.length) { | ||
| await this.deps.sender.sendTextReply(chatId, `无效的序号 ${dir},请使用 /list 查看可用工作区。`, threadCtx); | ||
| return true; | ||
| } | ||
| dir = dirs[index]; | ||
| } | ||
| try { | ||
@@ -181,4 +195,4 @@ const resolved = threadCtx | ||
| : this.deps.sessionManager.getWorkDir(userId); | ||
| const lines = dirs.map((d) => (d === current ? `▶ ${d}` : ` ${d}`)); | ||
| await this.deps.sender.sendTextReply(chatId, `Claude Code 工作区列表:\n${lines.join('\n')}\n\n使用 /cd <路径> 切换`, threadCtx); | ||
| const lines = dirs.map((d, i) => (d === current ? `▶ ${i + 1}. ${d}` : ` ${i + 1}. ${d}`)); | ||
| await this.deps.sender.sendTextReply(chatId, `Claude Code 工作区列表:\n${lines.join('\n')}\n\n使用 /cd <序号> 或 /cd <路径> 切换`, threadCtx); | ||
| } | ||
@@ -351,3 +365,3 @@ return true; | ||
| async handleHistory(chatId, userId, args, threadCtx) { | ||
| const page = parseInt(args, 10) || 1; | ||
| const page = args ? (parseInt(args, 10) || 1) : 0; | ||
| const workDir = threadCtx | ||
@@ -369,2 +383,35 @@ ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId) | ||
| /** | ||
| * 处理 /resume 命令 - 浏览/恢复历史会话 | ||
| */ | ||
| async handleResume(chatId, userId, args, threadCtx) { | ||
| const workDir = threadCtx | ||
| ? this.deps.sessionManager.getWorkDirForThread(userId, threadCtx.threadId) | ||
| : this.deps.sessionManager.getWorkDir(userId); | ||
| const currentSessionId = threadCtx | ||
| ? this.deps.sessionManager.getSessionIdForThread(userId, threadCtx.threadId) | ||
| : this.deps.sessionManager.getSessionIdForConv(userId, this.deps.sessionManager.getConvId(userId)); | ||
| const listResult = await getSessionList(workDir, currentSessionId); | ||
| if (!listResult.ok) { | ||
| await this.deps.sender.sendTextReply(chatId, listResult.error, threadCtx); | ||
| return true; | ||
| } | ||
| if (!args) { | ||
| await this.deps.sender.sendTextReply(chatId, formatSessionList(listResult.data), threadCtx); | ||
| return true; | ||
| } | ||
| const index = parseInt(args, 10) - 1; | ||
| if (isNaN(index) || index < 0 || index >= listResult.data.length) { | ||
| await this.deps.sender.sendTextReply(chatId, `无效的序号 ${args},共 ${listResult.data.length} 个会话。`, threadCtx); | ||
| return true; | ||
| } | ||
| const target = listResult.data[index]; | ||
| if (target.isCurrent) { | ||
| await this.deps.sender.sendTextReply(chatId, '该会话已是当前会话。', threadCtx); | ||
| return true; | ||
| } | ||
| this.deps.sessionManager.resumeSession(userId, target.sessionId); | ||
| await this.deps.sender.sendTextReply(chatId, `已恢复会话: ${target.preview}\n后续消息将延续该会话上下文。`, threadCtx); | ||
| return true; | ||
| } | ||
| /** | ||
| * 处理 /threads 命令 - 列出所有话题会话 | ||
@@ -371,0 +418,0 @@ */ |
@@ -32,3 +32,2 @@ import { join } from 'node:path'; | ||
| '/rewind', | ||
| '/resume', | ||
| '/copy', | ||
@@ -35,0 +34,0 @@ '/export', |
+10
-139
@@ -6,13 +6,11 @@ import { join } from 'node:path'; | ||
| import { RequestQueue } from '../queue/request-queue.js'; | ||
| import { sendThinkingCard, streamContentUpdate, sendFinalCards, sendErrorCard, sendTextReply, sendPermissionCard, updatePermissionCard, fetchThreadDescription, uploadAndSendImage } from './message-sender.js'; | ||
| import { sendTextReply, fetchThreadDescription } from './message-sender.js'; | ||
| import { getClient, getBotOpenId } from './client.js'; | ||
| import { buildCardV2 } from './card-builder.js'; | ||
| import { destroySession, updateCardFull, disableStreaming } from './cardkit-manager.js'; | ||
| import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js'; | ||
| import { registerFeishuPermissionSender, handlePermissionAction } from './permission-handler.js'; | ||
| import { CommandHandler } from '../commands/handler.js'; | ||
| import { safeStringify } from '../shared/utils.js'; | ||
| import { runClaudeTask } from '../shared/claude-task.js'; | ||
| import { startTaskCleanup } from '../shared/task-cleanup.js'; | ||
| import { MessageDedup } from '../shared/message-dedup.js'; | ||
| import { CARDKIT_THROTTLE_MS, IMAGE_DIR } from '../constants.js'; | ||
| import { IMAGE_DIR } from '../constants.js'; | ||
| import { executeClaudeTask, handleStopAction } from './task-executor.js'; | ||
| import { setActiveChatId } from '../shared/active-chats.js'; | ||
@@ -77,105 +75,4 @@ import { createLogger } from '../logger.js'; | ||
| // Register feishu permission sender | ||
| registerPermissionSender('feishu', { | ||
| sendPermissionCard, | ||
| updatePermissionCard: ({ messageId, toolName, decision }) => updatePermissionCard(messageId, toolName, decision), | ||
| }); | ||
| registerFeishuPermissionSender(); | ||
| // ─── 内部函数(闭包访问 runningTasks 等) ─── | ||
| async function handleClaudeRequest(userId, chatId, prompt, workDir, convId, threadCtx, mentionedBot, isGroup) { | ||
| const sessionId = threadCtx && threadCtx.threadId | ||
| ? sessionManager.getSessionIdForThread(userId, threadCtx.threadId) | ||
| : convId | ||
| ? sessionManager.getSessionIdForConv(userId, convId) | ||
| : undefined; | ||
| log.info(`Running Claude for user ${userId}, ${threadCtx ? `thread=${threadCtx.threadId}` : `convId=${convId}`}, workDir=${workDir}, sessionId=${sessionId ?? 'new'}`); | ||
| let cardHandle; | ||
| try { | ||
| cardHandle = await sendThinkingCard(chatId, threadCtx); | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send thinking card:', err); | ||
| return; | ||
| } | ||
| const { messageId, cardId } = cardHandle; | ||
| if (!cardId) { | ||
| log.error('No card_id returned for thinking card'); | ||
| return; | ||
| } | ||
| const finalThreadCtx = threadCtx; | ||
| const taskKey = `${userId}:${cardId}`; | ||
| let waitingTimer = null; | ||
| await runClaudeTask({ config, sessionManager, userCosts }, { | ||
| userId, | ||
| chatId, | ||
| workDir, | ||
| sessionId, | ||
| convId, | ||
| threadId: finalThreadCtx?.threadId, | ||
| threadRootMsgId: finalThreadCtx?.rootMessageId, | ||
| platform: 'feishu', | ||
| taskKey, | ||
| }, prompt, { | ||
| throttleMs: CARDKIT_THROTTLE_MS, | ||
| streamUpdate: (content, toolNote) => { | ||
| streamContentUpdate(cardId, content, toolNote).catch((e) => log.warn('Stream update failed:', e?.message ?? e)); | ||
| }, | ||
| sendComplete: async (content, note, thinkingText) => { | ||
| try { | ||
| await sendFinalCards(chatId, messageId, cardId, content, note, finalThreadCtx, thinkingText); | ||
| // 如果是群聊且被@了,任务完成后@回发送者 | ||
| if (isGroup && mentionedBot) { | ||
| const replyText = `<at user_id="${userId}"></at> 任务已完成 ✅`; | ||
| await sendTextReply(chatId, replyText, finalThreadCtx); | ||
| } | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send final cards:', err); | ||
| } | ||
| }, | ||
| sendError: async (error) => { | ||
| try { | ||
| await sendErrorCard(cardId, error); | ||
| } | ||
| catch (err) { | ||
| log.error('Failed to send error card:', err); | ||
| } | ||
| }, | ||
| onThinkingToText: (content, _thinkingText) => { | ||
| const resetCard = buildCardV2({ content: content || '...', status: 'streaming' }, cardId); | ||
| updateCardFull(cardId, resetCard) | ||
| .catch((e) => log.warn('Thinking→text transition update failed:', e?.message ?? e)); | ||
| }, | ||
| extraCleanup: () => { | ||
| if (waitingTimer) { | ||
| clearInterval(waitingTimer); | ||
| waitingTimer = null; | ||
| } | ||
| runningTasks.delete(taskKey); | ||
| }, | ||
| onTaskReady: (state) => { | ||
| runningTasks.set(taskKey, { ...state, cardId, messageId }); | ||
| // 等待首次内容期间,每3秒更新卡片显示已等待时间 | ||
| const startTime = Date.now(); | ||
| waitingTimer = setInterval(() => { | ||
| const taskInfo = runningTasks.get(taskKey); | ||
| if (!taskInfo) { | ||
| if (waitingTimer) { | ||
| clearInterval(waitingTimer); | ||
| waitingTimer = null; | ||
| } | ||
| return; | ||
| } | ||
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | ||
| streamContentUpdate(cardId, `等待 Claude 响应... (${elapsed}s)`).catch(() => { }); | ||
| }, 3000); | ||
| }, | ||
| onFirstContent: () => { | ||
| // 首次内容到达,清除等待计时器 | ||
| if (waitingTimer) { | ||
| clearInterval(waitingTimer); | ||
| waitingTimer = null; | ||
| } | ||
| }, | ||
| sendImage: (imagePath) => uploadAndSendImage(chatId, imagePath, finalThreadCtx), | ||
| }); | ||
| } | ||
| async function routeToThread(userId, chatId, threadId, rootMessageId, text, mentionedBot) { | ||
@@ -206,3 +103,3 @@ let threadSession = sessionManager.getThreadSession(userId, threadId); | ||
| const enqueueResult = requestQueue.enqueue(userId, threadId, text, async (prompt) => { | ||
| await handleClaudeRequest(userId, chatId, prompt, workDir, undefined, threadCtx, mentionedBot, true); | ||
| await executeClaudeTask({ config, sessionManager, userCosts, runningTasks }, userId, chatId, prompt, workDir, undefined, threadCtx, mentionedBot, true); | ||
| }); | ||
@@ -221,3 +118,3 @@ if (enqueueResult === 'rejected') { | ||
| const enqueueResult = requestQueue.enqueue(userId, convIdSnapshot, text, async (prompt) => { | ||
| await handleClaudeRequest(userId, chatId, prompt, workDirSnapshot, convIdSnapshot, undefined, mentionedBot, isGroup); | ||
| await executeClaudeTask({ config, sessionManager, userCosts, runningTasks }, userId, chatId, prompt, workDirSnapshot, convIdSnapshot, undefined, mentionedBot, isGroup); | ||
| }); | ||
@@ -279,26 +176,6 @@ if (enqueueResult === 'rejected') { | ||
| } | ||
| const taskKey = `${userId}:${cardId}`; | ||
| const taskInfo = runningTasks.get(taskKey); | ||
| log.debug(`Stop button clicked - taskKey: ${taskKey}, handle exists: ${!!taskInfo}`); | ||
| if (taskInfo) { | ||
| log.info(`User ${userId} stopped task for card ${cardId}`); | ||
| const stoppedContent = taskInfo.latestContent || '(任务已停止,暂无输出)'; | ||
| runningTasks.delete(taskKey); | ||
| taskInfo.settle(); | ||
| taskInfo.handle.abort(); | ||
| const stoppedCard = buildCardV2({ content: stoppedContent, status: 'done', note: '⏹️ 已停止' }); | ||
| disableStreaming(cardId) | ||
| .then(() => updateCardFull(cardId, stoppedCard)) | ||
| .catch((e) => log.warn('Stop card update failed:', e?.message ?? e)) | ||
| .finally(() => destroySession(cardId)); | ||
| } | ||
| else { | ||
| log.warn(`No running task found for key: ${taskKey}`); | ||
| log.info(`Current running tasks: ${Array.from(runningTasks.keys()).join(', ')}`); | ||
| } | ||
| handleStopAction(runningTasks, userId, cardId); | ||
| } | ||
| else if (actionData.action === 'allow' || actionData.action === 'deny') { | ||
| // 处理权限按钮点击 | ||
| const requestId = actionData.requestId; | ||
| const decision = actionData.action; | ||
| if (!requestId) { | ||
@@ -308,9 +185,3 @@ log.warn('No requestId in permission action'); | ||
| } | ||
| const resolvedId = resolvePermissionById(requestId, decision); | ||
| if (resolvedId) { | ||
| log.info(`Permission ${decision} via button for request ${requestId}`); | ||
| } | ||
| else { | ||
| log.warn(`No pending permission request found for requestId: ${requestId}`); | ||
| } | ||
| handlePermissionAction(requestId, actionData.action); | ||
| } | ||
@@ -454,3 +325,3 @@ }, | ||
| log.info(`User ${senderId}${isImageMessage ? ' [image]' : ''}: ${text.slice(0, 100)}${text.length > 100 ? '...' : ''}`); | ||
| if (!isImageMessage && await commandHandler.dispatch(text, chatId, senderId, 'feishu', handleClaudeRequest, threadCtx)) { | ||
| if (!isImageMessage && await commandHandler.dispatch(text, chatId, senderId, 'feishu', (userId, chatId, prompt, workDir, convId, threadCtx) => executeClaudeTask({ config, sessionManager, userCosts, runningTasks }, userId, chatId, prompt, workDir, convId, threadCtx), threadCtx)) { | ||
| return; | ||
@@ -457,0 +328,0 @@ } |
@@ -124,2 +124,6 @@ #!/usr/bin/env node | ||
| } | ||
| main(); | ||
| /* c8 ignore next 3 */ | ||
| const isDirectRun = process.argv[1]?.endsWith('hook-script.js'); | ||
| if (isDirectRun) | ||
| main(); | ||
| export { main, readStdin, httpPost }; |
@@ -143,2 +143,18 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; | ||
| } | ||
| resumeSession(userId, sessionId) { | ||
| const session = this.sessions.get(userId); | ||
| if (!session) | ||
| return false; | ||
| // 转存旧 convId 的 sessionId,供仍在运行的旧任务使用 | ||
| if (session.activeConvId && session.sessionId) { | ||
| this.convSessionMap.set(`${userId}:${session.activeConvId}`, session.sessionId); | ||
| this.pruneConvSessionMap(); | ||
| } | ||
| session.sessionId = sessionId; | ||
| session.activeConvId = this.generateConvId(); | ||
| session.totalTurns = 0; | ||
| this.flushSync(); | ||
| log.info(`Resumed session for user ${userId}: ${sessionId}`); | ||
| return true; | ||
| } | ||
| addTurns(userId, turns) { | ||
@@ -145,0 +161,0 @@ const session = this.sessions.get(userId); |
| /** | ||
| * 共享的 Claude 任务执行逻辑 | ||
| * 封装两个平台重复的节流更新、完成统计、竞态保护等代码 | ||
| * 封装各平台重复的节流更新、完成统计、竞态保护等代码 | ||
| */ | ||
@@ -5,0 +5,0 @@ import { access } from 'node:fs/promises'; |
+105
-6
@@ -68,3 +68,3 @@ import { readFile, readdir, stat } from 'node:fs/promises'; | ||
| const totalPages = Math.ceil(entries.length / PAGE_SIZE); | ||
| const p = Math.max(1, Math.min(page, totalPages)); | ||
| const p = page <= 0 ? totalPages : Math.max(1, Math.min(page, totalPages)); | ||
| const start = (p - 1) * PAGE_SIZE; | ||
@@ -74,2 +74,97 @@ const pageEntries = entries.slice(start, start + PAGE_SIZE); | ||
| } | ||
| function formatTimestamp(ts) { | ||
| if (!ts) | ||
| return ''; | ||
| try { | ||
| const d = new Date(ts); | ||
| const now = new Date(); | ||
| const pad = (n) => String(n).padStart(2, '0'); | ||
| const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`; | ||
| if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) { | ||
| return `[${time}]`; | ||
| } | ||
| return `[${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}]`; | ||
| } | ||
| catch { | ||
| return ''; | ||
| } | ||
| } | ||
| const MAX_SESSION_LIST = 10; | ||
| const PREVIEW_LENGTH = 100; | ||
| function parseSessionFile(raw) { | ||
| let messageCount = 0; | ||
| let preview = ''; | ||
| for (const line of raw.split('\n')) { | ||
| if (!line) | ||
| continue; | ||
| try { | ||
| const obj = JSON.parse(line); | ||
| if (obj.type !== 'user' && obj.type !== 'assistant') | ||
| continue; | ||
| const msg = obj.message; | ||
| if (!msg) | ||
| continue; | ||
| const text = extractText(msg.content).trim(); | ||
| if (!text) | ||
| continue; | ||
| if (text.startsWith('<local-command') || text.startsWith('<command-name>')) | ||
| continue; | ||
| messageCount++; | ||
| if (!preview && msg.role === 'user') { | ||
| preview = text.length > PREVIEW_LENGTH ? text.slice(0, PREVIEW_LENGTH) + '...' : text; | ||
| } | ||
| } | ||
| catch { /* skip */ } | ||
| } | ||
| return { messageCount, preview }; | ||
| } | ||
| export async function getSessionList(workDir, currentSessionId) { | ||
| const projectDir = join(homedir(), '.claude', 'projects', encodeWorkDir(workDir)); | ||
| let files; | ||
| try { | ||
| files = (await readdir(projectDir)).filter(f => f.endsWith('.jsonl')); | ||
| } | ||
| catch { | ||
| return { ok: false, error: '当前工作区未找到会话记录。' }; | ||
| } | ||
| if (files.length === 0) | ||
| return { ok: false, error: '当前工作区未找到会话记录。' }; | ||
| const withMtime = await Promise.all(files.map(async (f) => ({ | ||
| f, | ||
| mtime: (await stat(join(projectDir, f)).catch(() => ({ mtimeMs: 0 }))).mtimeMs, | ||
| }))); | ||
| withMtime.sort((a, b) => b.mtime - a.mtime); | ||
| const recent = withMtime.slice(0, MAX_SESSION_LIST); | ||
| const items = await Promise.all(recent.map(async ({ f, mtime }) => { | ||
| const sessionId = f.replace('.jsonl', ''); | ||
| let messageCount = 0; | ||
| let preview = ''; | ||
| try { | ||
| const raw = await readFile(join(projectDir, f), 'utf-8'); | ||
| ({ messageCount, preview } = parseSessionFile(raw)); | ||
| } | ||
| catch { /* skip */ } | ||
| return { | ||
| sessionId, | ||
| mtime, | ||
| messageCount, | ||
| preview: preview || '(空会话)', | ||
| isCurrent: sessionId === currentSessionId, | ||
| }; | ||
| })); | ||
| return { ok: true, data: items }; | ||
| } | ||
| export function formatSessionList(items) { | ||
| const lines = ['📋 会话列表', '']; | ||
| for (let i = 0; i < items.length; i++) { | ||
| const s = items[i]; | ||
| const d = new Date(s.mtime); | ||
| const pad = (n) => String(n).padStart(2, '0'); | ||
| const date = `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; | ||
| const current = s.isCurrent ? '\n ▶ 当前会话' : ''; | ||
| lines.push(`${i + 1}. ${date} | ${s.messageCount}条 | ${s.preview}${current}`); | ||
| } | ||
| lines.push('', '使用 /resume <序号> 恢复会话'); | ||
| return lines.join('\n'); | ||
| } | ||
| export function formatHistoryPage(result) { | ||
@@ -79,9 +174,13 @@ const lines = [`📜 会话历史 (${result.page}/${result.totalPages}) — ${result.sessionId.slice(-8)}`, '']; | ||
| const prefix = e.role === 'user' ? '👤' : '🤖'; | ||
| const preview = e.text.length > 300 ? e.text.slice(0, 297) + '...' : e.text; | ||
| lines.push(`${prefix} ${preview}`); | ||
| const ts = formatTimestamp(e.timestamp); | ||
| lines.push(ts ? `${ts} ${prefix} ${e.text}` : `${prefix} ${e.text}`); | ||
| } | ||
| if (result.page < result.totalPages) { | ||
| lines.push('', `使用 /history ${result.page + 1} 查看下一页`); | ||
| } | ||
| const nav = []; | ||
| if (result.page > 1) | ||
| nav.push(`/history ${result.page - 1} 上一页`); | ||
| if (result.page < result.totalPages) | ||
| nav.push(`/history ${result.page + 1} 下一页`); | ||
| if (nav.length > 0) | ||
| lines.push('', nav.join(' | ')); | ||
| return lines.join('\n'); | ||
| } |
@@ -19,4 +19,8 @@ import { createLogger } from '../logger.js'; | ||
| catch (err) { | ||
| if (err instanceof NonRetryableError || attempt >= maxRetries) | ||
| if (err instanceof NonRetryableError) | ||
| throw err; | ||
| if (opts?.shouldRetry && !opts.shouldRetry(err)) | ||
| throw err; | ||
| if (attempt >= maxRetries) | ||
| throw err; | ||
| const delay = Math.min(baseDelay * 2 ** attempt + Math.random() * 200, maxDelay); | ||
@@ -23,0 +27,0 @@ log.warn(`Retry ${attempt + 1}/${maxRetries} after ${Math.round(delay)}ms: ${err?.message ?? err}`); |
@@ -6,2 +6,3 @@ import { getBot } from './client.js'; | ||
| import { MAX_TELEGRAM_MESSAGE_LENGTH } from '../constants.js'; | ||
| import { withRetry } from '../shared/retry.js'; | ||
| const log = createLogger('TgSender'); | ||
@@ -62,20 +63,16 @@ const MAX_RETRIES = 3; | ||
| } | ||
| for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { | ||
| try { | ||
| const result = await fn(); | ||
| return result; | ||
| } | ||
| catch (err) { | ||
| return withRetry(fn, { | ||
| maxRetries: MAX_RETRIES - 1, // withRetry counts retries after first attempt | ||
| baseDelayMs: 1000, | ||
| maxDelayMs: RATE_LIMIT_MAX_WAIT_SEC * 1000, | ||
| shouldRetry: (err) => { | ||
| const retryAfter = parseRetryAfter(err); | ||
| if (retryAfter !== null && attempt < MAX_RETRIES) { | ||
| const waitSec = Math.min(retryAfter, RATE_LIMIT_MAX_WAIT_SEC); | ||
| if (retryAfter !== null) { | ||
| setCooldown(chatId, retryAfter); | ||
| log.warn(`${label}: rate limited, waiting ${waitSec}s before retry (attempt ${attempt}/${MAX_RETRIES})`); | ||
| await new Promise((r) => setTimeout(r, waitSec * 1000)); | ||
| continue; | ||
| log.warn(`${label}: rate limited, retry after ${retryAfter}s`); | ||
| return true; | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| throw new Error('unreachable'); | ||
| return false; // 非 429 错误不重试 | ||
| }, | ||
| }); | ||
| } | ||
@@ -82,0 +79,0 @@ const STATUS_ICONS = { |
@@ -169,3 +169,3 @@ import { join } from 'node:path'; | ||
| if (stallChecks >= 2) { | ||
| // 停滞 6s+ 才触发,避免正常节流间隔内的误判 | ||
| // 停滞 6~9s 才触发,避免正常节流间隔内的误判 | ||
| sender.sendStreamUpdate(state.latestContent, `⏳ 工具执行中... (${elapsed}s)`).catch(() => { }); | ||
@@ -172,0 +172,0 @@ } |
@@ -17,4 +17,4 @@ import { getWSClient } from './client.js'; | ||
| * 如果流式消息接近超时(330s),结束当前流并开始新流。 | ||
| * 企业微信 SDK 的 replyStream(finish=true) 会终结当前流内容并展示最终文本, | ||
| * 新流会以独立消息出现,因此重发完整内容不会导致用户侧重复显示。 | ||
| * 旧流结束时保留到截断点的内容,新流只发截断点之后的增量内容, | ||
| * 避免每次续接都重复显示之前的全部文本。 | ||
| */ | ||
@@ -27,4 +27,6 @@ async function renewStreamIfNeeded(content) { | ||
| log.info(`Stream ${session.streamId} elapsed ${Math.round(elapsed / 1000)}s, renewing`); | ||
| // 旧流结束,显示当前增量内容 + 续接标记 | ||
| const oldContent = content.slice(session.contentOffset); | ||
| try { | ||
| await wsClient.replyStream(session.frame, session.streamId, content, true); | ||
| await wsClient.replyStream(session.frame, session.streamId, oldContent + '\n\n---\n> _(续)_', true); | ||
| } | ||
@@ -34,2 +36,4 @@ catch (err) { | ||
| } | ||
| // 记录截断点,新流从此偏移开始 | ||
| session.contentOffset = content.length; | ||
| session.streamId = generateReqId('stream'); | ||
@@ -43,10 +47,8 @@ session.streamStartedAt = Date.now(); | ||
| return { | ||
| templateCard: { | ||
| card_type: 'button_interaction', | ||
| main_title: { title: 'Claude Code' }, | ||
| task_id: `stop_${taskKey}`, | ||
| button_list: [ | ||
| { text: '⏹️ 停止', style: 3, key: `stop_${taskKey}` }, | ||
| ], | ||
| }, | ||
| card_type: 'button_interaction', | ||
| main_title: { title: 'Claude Code' }, | ||
| task_id: `stop_${taskKey}`, | ||
| button_list: [ | ||
| { text: '⏹️ 停止', style: 3, key: `stop_${taskKey}` }, | ||
| ], | ||
| }; | ||
@@ -66,2 +68,20 @@ } | ||
| /** | ||
| * 首次流式更新后,单独发一条停止按钮卡片消息。 | ||
| * replyStreamWithCard 在手机端可能不渲染卡片,改为 sendMessage 独立发送。 | ||
| */ | ||
| async function sendStopCardIfNeeded() { | ||
| if (!session || !session.chatId || !session.taskKey || session.stopCardSent) | ||
| return; | ||
| session.stopCardSent = true; | ||
| try { | ||
| await wsClient.sendMessage(session.chatId, { | ||
| msgtype: 'template_card', | ||
| template_card: buildStopCard(session.taskKey), | ||
| }); | ||
| } | ||
| catch (err) { | ||
| log.warn('Failed to send stop card:', err); | ||
| } | ||
| } | ||
| /** | ||
| * 内部串行化的流式更新 | ||
@@ -74,10 +94,7 @@ * 保证同一时刻最多一个 replyStream 在飞行中 | ||
| await renewStreamIfNeeded(content); | ||
| const text = toolNote ? `${content}\n\n---\n> ${toolNote}` : content; | ||
| // 只发送截断点之后的增量内容 | ||
| const sliced = content.slice(session.contentOffset); | ||
| const text = toolNote ? `${sliced}\n\n---\n> ${toolNote}` : sliced; | ||
| try { | ||
| if (session.isFirstUpdate && session.taskKey) { | ||
| await wsClient.replyStreamWithCard(session.frame, session.streamId, text, false, buildStopCard(session.taskKey)); | ||
| } | ||
| else { | ||
| await wsClient.replyStream(session.frame, session.streamId, text, false); | ||
| } | ||
| await wsClient.replyStream(session.frame, session.streamId, text, false); | ||
| session.isFirstUpdate = false; | ||
@@ -113,2 +130,4 @@ } | ||
| taskKey: taskKey ?? '', | ||
| stopCardSent: false, | ||
| contentOffset: 0, | ||
| }; | ||
@@ -127,2 +146,4 @@ }, | ||
| await doStreamUpdate(content, toolNote); | ||
| // 首次流式更新成功后,单独发送停止按钮卡片 | ||
| await sendStopCardIfNeeded(); | ||
| // 发送期间如果有新的 pending 内容,循环处理(避免递归) | ||
@@ -156,14 +177,10 @@ while (pendingStreamUpdate) { | ||
| } | ||
| // 开启新流用于实际回答 | ||
| // 开启新流用于实际回答,重置偏移量(文本从头开始) | ||
| session.contentOffset = 0; | ||
| session.streamId = generateReqId('stream'); | ||
| session.streamStartedAt = Date.now(); | ||
| session.isFirstUpdate = true; | ||
| // 立即发送首条文本内容到新流(带停止按钮) | ||
| // 立即发送首条文本内容到新流 | ||
| try { | ||
| if (session.taskKey) { | ||
| await wsClient.replyStreamWithCard(session.frame, session.streamId, content || '...', false, buildStopCard(session.taskKey)); | ||
| } | ||
| else { | ||
| await wsClient.replyStream(session.frame, session.streamId, content || '...', false); | ||
| } | ||
| await wsClient.replyStream(session.frame, session.streamId, content || '...', false); | ||
| session.isFirstUpdate = false; | ||
@@ -185,3 +202,5 @@ } | ||
| streamBusy = true; | ||
| const parts = splitLongContent(content, MAX_WECOM_MESSAGE_LENGTH); | ||
| // 只发送截断点之后的增量内容 | ||
| const sliced = content.slice(session.contentOffset); | ||
| const parts = splitLongContent(sliced, MAX_WECOM_MESSAGE_LENGTH); | ||
| const firstPart = note ? `${parts[0]}\n\n---\n> ${note}` : parts[0]; | ||
@@ -188,0 +207,0 @@ try { |
+1
-1
| { | ||
| "name": "cc-im", | ||
| "version": "1.4.0", | ||
| "version": "1.5.0", | ||
| "description": "Multi-platform bot bridge (Feishu & Telegram) for Claude Code CLI", | ||
@@ -5,0 +5,0 @@ "repository": { |
Sorry, the diff of this file is not supported yet
251956
3.35%41
5.13%5951
3.53%