mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix(adapters): fix IM message delivery issues and improve UX
- Fix WebSocket race condition: wait for connection to open before sending first message, preventing silent message loss after pairing or project selection - Fix response text appearing above tool calls: finalize placeholder before tool_use blocks so post-tool text gets a new message - Remove noisy tool_use and tool_result messages from IM output; thinking indicator is preserved, details visible in Desktop - Fix /new command using default project dir instead of always showing project picker - Fix duplicate projects in list by deduping on realPath instead of projectPath - Improve formatToolUse with human-readable summaries for common tools - Add send failure feedback to users Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6085447277
commit
824b1967ff
@ -33,10 +33,78 @@ export function splitMessage(text: string, limit: number): string[] {
|
||||
|
||||
/** Format tool use info for display in IM. */
|
||||
export function formatToolUse(toolName: string, input: unknown): string {
|
||||
const inp = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
|
||||
const summary = formatToolSummary(toolName, inp)
|
||||
if (summary) return `🔧 ${toolName} ${summary}`
|
||||
const preview = truncateInput(input, 200)
|
||||
return `🔧 ${toolName}\n${preview}`
|
||||
}
|
||||
|
||||
/** Generate a concise human-readable summary for common tools. */
|
||||
function formatToolSummary(tool: string, inp: Record<string, unknown>): string | null {
|
||||
switch (tool) {
|
||||
case 'Bash': {
|
||||
const desc = inp.description as string | undefined
|
||||
const cmd = inp.command as string | undefined
|
||||
if (desc) return desc
|
||||
if (cmd) return truncate(cmd, 120)
|
||||
return null
|
||||
}
|
||||
case 'Read': {
|
||||
const fp = inp.file_path as string | undefined
|
||||
if (fp) return shortPath(fp)
|
||||
return null
|
||||
}
|
||||
case 'Edit': {
|
||||
const fp = inp.file_path as string | undefined
|
||||
if (fp) return shortPath(fp)
|
||||
return null
|
||||
}
|
||||
case 'Write': {
|
||||
const fp = inp.file_path as string | undefined
|
||||
if (fp) return shortPath(fp)
|
||||
return null
|
||||
}
|
||||
case 'Grep': {
|
||||
const pat = inp.pattern as string | undefined
|
||||
const p = inp.path as string | undefined
|
||||
if (pat) return `"${truncate(pat, 60)}"` + (p ? ` in ${shortPath(p)}` : '')
|
||||
return null
|
||||
}
|
||||
case 'Glob': {
|
||||
const pat = inp.pattern as string | undefined
|
||||
return pat ? `"${pat}"` : null
|
||||
}
|
||||
case 'Skill': {
|
||||
const skill = inp.skill as string | undefined
|
||||
return skill || null
|
||||
}
|
||||
case 'Agent': {
|
||||
const desc = inp.description as string | undefined
|
||||
return desc || null
|
||||
}
|
||||
case 'WebFetch': {
|
||||
const url = inp.url as string | undefined
|
||||
return url ? truncate(url, 120) : null
|
||||
}
|
||||
case 'WebSearch': {
|
||||
const q = inp.query as string | undefined
|
||||
return q ? `"${truncate(q, 80)}"` : null
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function shortPath(fp: string): string {
|
||||
const parts = fp.split('/')
|
||||
return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : fp
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + '…' : s
|
||||
}
|
||||
|
||||
/** Format a permission request for display in IM. */
|
||||
export function formatPermissionRequest(toolName: string, input: unknown, requestId: string): string {
|
||||
const preview = truncateInput(input, 300)
|
||||
|
||||
@ -152,9 +152,37 @@ export class WsBridge {
|
||||
})
|
||||
}
|
||||
|
||||
/** Wait until the WebSocket for chatId is open. Resolves false on timeout or error. */
|
||||
waitForOpen(chatId: string, timeoutMs = 10_000): Promise<boolean> {
|
||||
const session = this.sessions.get(chatId)
|
||||
if (!session) return Promise.resolve(false)
|
||||
if (session.ws.readyState === WebSocket.OPEN) return Promise.resolve(true)
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(false)
|
||||
}, timeoutMs)
|
||||
const onOpen = () => { cleanup(); resolve(true) }
|
||||
const onError = () => { cleanup(); resolve(false) }
|
||||
const onClose = () => { cleanup(); resolve(false) }
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
session.ws.removeListener('open', onOpen)
|
||||
session.ws.removeListener('error', onError)
|
||||
session.ws.removeListener('close', onClose)
|
||||
}
|
||||
session.ws.once('open', onOpen)
|
||||
session.ws.once('error', onError)
|
||||
session.ws.once('close', onClose)
|
||||
})
|
||||
}
|
||||
|
||||
private send(chatId: string, message: Record<string, unknown>): boolean {
|
||||
const session = this.sessions.get(chatId)
|
||||
if (!session || session.ws.readyState !== WebSocket.OPEN) return false
|
||||
if (!session || session.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn(`[WsBridge] Cannot send to ${chatId}: session not ready`)
|
||||
return false
|
||||
}
|
||||
session.ws.send(JSON.stringify(message))
|
||||
return true
|
||||
}
|
||||
|
||||
@ -210,7 +210,7 @@ async function ensureSession(chatId: string): Promise<boolean> {
|
||||
if (stored) {
|
||||
bridge.connectSession(chatId, stored.sessionId)
|
||||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||||
return true
|
||||
return await bridge.waitForOpen(chatId)
|
||||
}
|
||||
|
||||
const workDir = config.defaultProjectDir
|
||||
@ -228,6 +228,11 @@ async function createSessionForChat(chatId: string, workDir: string): Promise<bo
|
||||
sessionStore.set(chatId, sessionId, workDir)
|
||||
bridge.connectSession(chatId, sessionId)
|
||||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||||
const opened = await bridge.waitForOpen(chatId)
|
||||
if (!opened) {
|
||||
await sendText(chatId, '⚠️ 连接服务器超时,请重试。')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
|
||||
@ -274,11 +279,27 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
|
||||
break
|
||||
|
||||
case 'content_start':
|
||||
if (msg.blockType === 'text' && !state.replyMessageId) {
|
||||
const mid = await sendText(chatId, '▍')
|
||||
if (mid) {
|
||||
state.replyMessageId = mid
|
||||
accumulatedText.set(chatId, '')
|
||||
if (msg.blockType === 'text') {
|
||||
if (!state.replyMessageId) {
|
||||
const mid = await sendText(chatId, '▍')
|
||||
if (mid) {
|
||||
state.replyMessageId = mid
|
||||
accumulatedText.set(chatId, '')
|
||||
}
|
||||
}
|
||||
} else if (msg.blockType === 'tool_use') {
|
||||
// Finalize current text before tool calls,
|
||||
// so text after tools gets a fresh message
|
||||
await buf.complete()
|
||||
// If reply still exists (buffer was already empty), clean up directly
|
||||
if (state.replyMessageId) {
|
||||
const text = accumulatedText.get(chatId)
|
||||
if (text?.trim()) {
|
||||
await patchMessage(state.replyMessageId, text)
|
||||
}
|
||||
accumulatedText.delete(chatId)
|
||||
chatStates.delete(chatId)
|
||||
buffers.get(chatId)?.reset()
|
||||
}
|
||||
}
|
||||
break
|
||||
@ -295,16 +316,13 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_use_complete': {
|
||||
const info = formatToolUse(msg.toolName, msg.input)
|
||||
await sendText(chatId, info)
|
||||
case 'tool_use_complete':
|
||||
// Tool details are noise for IM users; visible in Desktop if needed.
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_result':
|
||||
if (msg.isError) {
|
||||
await sendText(chatId, '❌ 工具执行失败')
|
||||
}
|
||||
// Tool errors are handled internally by the AI (retries etc.)
|
||||
// No need to notify the user for every failed attempt.
|
||||
break
|
||||
|
||||
case 'permission_request': {
|
||||
@ -416,7 +434,16 @@ async function handleMessage(data: any): Promise<void> {
|
||||
buffers.get(chatId)?.reset()
|
||||
buffers.delete(chatId)
|
||||
pendingProjectSelection.delete(chatId)
|
||||
await showProjectPicker(chatId)
|
||||
|
||||
const workDir = config.defaultProjectDir
|
||||
if (workDir) {
|
||||
const ok = await createSessionForChat(chatId, workDir)
|
||||
if (ok) {
|
||||
await sendText(chatId, '✅ 已新建会话,可以开始对话了。')
|
||||
}
|
||||
} else {
|
||||
await showProjectPicker(chatId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (text === '/stop' || text === '停止') {
|
||||
@ -452,7 +479,10 @@ async function handleMessage(data: any): Promise<void> {
|
||||
enqueue(chatId, async () => {
|
||||
const ready = await ensureSession(chatId)
|
||||
if (ready) {
|
||||
bridge.sendUserMessage(chatId, text!)
|
||||
const sent = bridge.sendUserMessage(chatId, text!)
|
||||
if (!sent) {
|
||||
await sendText(chatId, '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -100,7 +100,7 @@ async function ensureSession(chatId: string): Promise<boolean> {
|
||||
if (stored) {
|
||||
bridge.connectSession(chatId, stored.sessionId)
|
||||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||||
return true
|
||||
return await bridge.waitForOpen(chatId)
|
||||
}
|
||||
|
||||
const workDir = config.defaultProjectDir
|
||||
@ -119,6 +119,11 @@ async function createSessionForChat(chatId: string, workDir: string): Promise<bo
|
||||
sessionStore.set(chatId, sessionId, workDir)
|
||||
bridge.connectSession(chatId, sessionId)
|
||||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||||
const opened = await bridge.waitForOpen(chatId)
|
||||
if (!opened) {
|
||||
await bot.api.sendMessage(numericChatId, '⚠️ 连接服务器超时,请重试。')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
await bot.api.sendMessage(numericChatId,
|
||||
@ -168,10 +173,28 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
|
||||
break
|
||||
|
||||
case 'content_start':
|
||||
if (msg.blockType === 'text' && !placeholders.has(chatId)) {
|
||||
const sent = await bot.api.sendMessage(numericChatId, '▍')
|
||||
placeholders.set(chatId, { chatId, messageId: sent.message_id })
|
||||
accumulatedText.set(chatId, '')
|
||||
if (msg.blockType === 'text') {
|
||||
if (!placeholders.has(chatId)) {
|
||||
const sent = await bot.api.sendMessage(numericChatId, '▍')
|
||||
placeholders.set(chatId, { chatId, messageId: sent.message_id })
|
||||
accumulatedText.set(chatId, '')
|
||||
}
|
||||
} else if (msg.blockType === 'tool_use') {
|
||||
// Finalize current text placeholder before tool calls,
|
||||
// so text after tools gets a fresh message
|
||||
await buf.complete()
|
||||
// If placeholder still exists (buffer was already empty), clean up directly
|
||||
if (placeholders.has(chatId)) {
|
||||
const text = accumulatedText.get(chatId)
|
||||
if (text?.trim()) {
|
||||
try {
|
||||
await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, text)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
placeholders.delete(chatId)
|
||||
accumulatedText.delete(chatId)
|
||||
buffers.get(chatId)?.reset()
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@ -193,16 +216,13 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_use_complete': {
|
||||
const info = formatToolUse(msg.toolName, msg.input)
|
||||
await bot.api.sendMessage(numericChatId, info)
|
||||
case 'tool_use_complete':
|
||||
// Tool details are noise for IM users; visible in Desktop if needed.
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_result':
|
||||
if (msg.isError) {
|
||||
await bot.api.sendMessage(numericChatId, `❌ 工具执行失败`)
|
||||
}
|
||||
// Tool errors are handled internally by the AI (retries etc.)
|
||||
// No need to notify the user for every failed attempt.
|
||||
break
|
||||
|
||||
case 'permission_request': {
|
||||
@ -245,7 +265,16 @@ bot.command('new', async (ctx) => {
|
||||
buffers.get(chatId)?.reset()
|
||||
buffers.delete(chatId)
|
||||
pendingProjectSelection.delete(chatId)
|
||||
await showProjectPicker(chatId)
|
||||
|
||||
const workDir = config.defaultProjectDir
|
||||
if (workDir) {
|
||||
const ok = await createSessionForChat(chatId, workDir)
|
||||
if (ok) {
|
||||
await bot.api.sendMessage(Number(chatId), '✅ 已新建会话,可以开始对话了。')
|
||||
}
|
||||
} else {
|
||||
await showProjectPicker(chatId)
|
||||
}
|
||||
})
|
||||
|
||||
bot.command('projects', async (ctx) => {
|
||||
@ -308,7 +337,10 @@ bot.on('message:text', (ctx) => {
|
||||
// Normal message flow
|
||||
const ready = await ensureSession(chatId)
|
||||
if (ready) {
|
||||
bridge.sendUserMessage(chatId, text)
|
||||
const sent = bridge.sendUserMessage(chatId, text)
|
||||
if (!sent) {
|
||||
await bot.api.sendMessage(Number(chatId), '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -248,13 +248,23 @@ async function getRecentProjects(): Promise<Response> {
|
||||
const { sessions } = await sessionService.listSessions({ limit: 200 })
|
||||
const validSessions = sessions.filter((session) => session.workDirExists && session.workDir)
|
||||
|
||||
// Group sessions by projectPath and find most recent per project
|
||||
const projectMap = new Map<string, { modifiedAt: string; sessionCount: number; sessionId: string }>()
|
||||
// First pass: resolve realPath for each session and group by realPath to dedup
|
||||
const realPathMap = new Map<string, { projectPath: string; modifiedAt: string; sessionCount: number; sessionId: string }>()
|
||||
for (const s of validSessions) {
|
||||
const existing = projectMap.get(s.projectPath)
|
||||
// Resolve the real path for dedup
|
||||
let realPath: string
|
||||
try {
|
||||
const workDir = await sessionService.getSessionWorkDir(s.id)
|
||||
realPath = workDir || sessionService.desanitizePath(s.projectPath)
|
||||
} catch {
|
||||
realPath = sessionService.desanitizePath(s.projectPath)
|
||||
}
|
||||
|
||||
const existing = realPathMap.get(realPath)
|
||||
if (!existing || s.modifiedAt > existing.modifiedAt) {
|
||||
projectMap.set(s.projectPath, {
|
||||
modifiedAt: existing ? s.modifiedAt : s.modifiedAt,
|
||||
realPathMap.set(realPath, {
|
||||
projectPath: s.projectPath,
|
||||
modifiedAt: s.modifiedAt,
|
||||
sessionCount: (existing?.sessionCount ?? 0) + 1,
|
||||
sessionId: s.id,
|
||||
})
|
||||
@ -263,7 +273,7 @@ async function getRecentProjects(): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// Build project list with real paths
|
||||
// Build project list with git info
|
||||
const projects: Array<{
|
||||
projectPath: string
|
||||
realPath: string
|
||||
@ -275,17 +285,8 @@ async function getRecentProjects(): Promise<Response> {
|
||||
sessionCount: number
|
||||
}> = []
|
||||
|
||||
for (const [projectPath, info] of projectMap) {
|
||||
// Try to get real workDir from the most recent session
|
||||
let realPath: string
|
||||
try {
|
||||
const workDir = await sessionService.getSessionWorkDir(info.sessionId)
|
||||
realPath = workDir || sessionService.desanitizePath(projectPath)
|
||||
} catch {
|
||||
realPath = sessionService.desanitizePath(projectPath)
|
||||
}
|
||||
|
||||
const projectName = realPath.split('/').filter(Boolean).pop() || projectPath
|
||||
for (const [realPath, info] of realPathMap) {
|
||||
const projectName = realPath.split('/').filter(Boolean).pop() || info.projectPath
|
||||
|
||||
// Check if it's a git repo
|
||||
let isGit = false
|
||||
@ -316,7 +317,7 @@ async function getRecentProjects(): Promise<Response> {
|
||||
} catch { /* not a git repo or dir doesn't exist */ }
|
||||
|
||||
projects.push({
|
||||
projectPath, realPath, projectName, isGit, repoName, branch,
|
||||
projectPath: info.projectPath, realPath, projectName, isGit, repoName, branch,
|
||||
modifiedAt: info.modifiedAt, sessionCount: info.sessionCount,
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user