mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(cli): tool-call display
This commit is contained in:
@@ -54,6 +54,7 @@ const app = new Elysia()
|
||||
.listen({
|
||||
port: config.agent_gateway.port ?? 8081,
|
||||
hostname: config.agent_gateway.host ?? "127.0.0.1",
|
||||
idleTimeout: 255, // max allowed by Bun, to accommodate long-running tool calls
|
||||
});
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -43,6 +43,35 @@ ${Bun.YAML.stringify(headers)}
|
||||
---
|
||||
You are an AI agent, and now you wake up.
|
||||
|
||||
${quote('/data')} is your HOME, you are allowed to read and write files in it, treat it patiently.
|
||||
|
||||
## Basic Tools
|
||||
- ${quote('read')}: read file content
|
||||
- ${quote('write')}: write file content
|
||||
- ${quote('list')}: list directory entries
|
||||
- ${quote('edit')}: apply unified diff patch. Format:
|
||||
|
||||
${block([
|
||||
'@@ -<orig_start>,<orig_count> +<new_start>,<new_count> @@',
|
||||
'-old line',
|
||||
'+new line',
|
||||
'',
|
||||
'@@ -3,1 +3,2 @@',
|
||||
' existing line 3',
|
||||
'+added line after 3',
|
||||
'',
|
||||
'@@ -2,1 +2,0 @@',
|
||||
'-deleted line',
|
||||
].join('\n'))}
|
||||
|
||||
Rules:
|
||||
- Lines prefixed with ${quote(' ')} (space) are context (unchanged) lines
|
||||
- Lines prefixed with ${quote('-')} are removed, ${quote('+')} are added
|
||||
- ${quote('orig_count')} / ${quote('new_count')} must match the actual number of lines (context + removed / context + added)
|
||||
- Multiple hunks allowed in one patch
|
||||
|
||||
- ${quote('exec')}: execute command
|
||||
|
||||
## Memory
|
||||
|
||||
Your context is loaded from the recent of ${maxContextLoadTime} minutes (${(maxContextLoadTime / 60).toFixed(2)} hours).
|
||||
|
||||
@@ -5,11 +5,10 @@ import ora from 'ora'
|
||||
import { table } from 'table'
|
||||
import readline from 'node:readline/promises'
|
||||
import { stdin as input, stdout as output } from 'node:process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { apiRequest } from '../core/api'
|
||||
import { readConfig, getBaseURL, TokenInfo } from '../utils/store'
|
||||
import { readConfig, TokenInfo } from '../utils/store'
|
||||
import { ensureAuth, getErrorMessage, resolveBotId, BotSummary } from './shared'
|
||||
import { streamChat } from './stream'
|
||||
|
||||
type Bot = BotSummary & {
|
||||
metadata?: Record<string, unknown>
|
||||
@@ -60,91 +59,6 @@ const renderBotsTable = (items: BotSummary[]) => {
|
||||
return table(rows)
|
||||
}
|
||||
|
||||
const streamChat = async (query: string, botId: string, sessionId: string, token: TokenInfo) => {
|
||||
const config = readConfig()
|
||||
const baseURL = getBaseURL(config)
|
||||
const resp = await fetch(`${baseURL}/bots/${botId}/chat/stream?session_id=${encodeURIComponent(sessionId)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
}).catch(() => null)
|
||||
if (!resp || !resp.ok || !resp.body) return false
|
||||
|
||||
const stream = resp.body
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let printed = false
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let idx
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, idx).trim()
|
||||
buffer = buffer.slice(idx + 1)
|
||||
if (!line.startsWith('data:')) continue
|
||||
const payload = line.slice(5).trim()
|
||||
if (!payload || payload === '[DONE]') continue
|
||||
const text = extractTextFromEvent(payload)
|
||||
if (text) {
|
||||
process.stdout.write(text)
|
||||
printed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (printed) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const extractTextFromMessage = (message: unknown) => {
|
||||
if (typeof message === 'string') return message
|
||||
if (message && typeof message === 'object') {
|
||||
const value = message as { text?: unknown; parts?: unknown[] }
|
||||
if (typeof value.text === 'string') return value.text
|
||||
if (Array.isArray(value.parts)) {
|
||||
const lines = value.parts
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== 'object') return ''
|
||||
const typed = part as { text?: unknown; url?: unknown; emoji?: unknown }
|
||||
if (typeof typed.text === 'string' && typed.text.trim()) return typed.text
|
||||
if (typeof typed.url === 'string' && typed.url.trim()) return typed.url
|
||||
if (typeof typed.emoji === 'string' && typed.emoji.trim()) return typed.emoji
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (lines.length) return lines.join('\n')
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const extractTextFromEvent = (payload: string) => {
|
||||
try {
|
||||
const event = JSON.parse(payload)
|
||||
if (typeof event === 'string') return event
|
||||
if (typeof event?.text === 'string') return event.text
|
||||
const messageText = extractTextFromMessage(event?.message)
|
||||
if (messageText) return messageText
|
||||
if (typeof event?.delta === 'string') return event.delta
|
||||
if (typeof event?.delta?.content === 'string') return event.delta.content
|
||||
if (typeof event?.content === 'string') return event.content
|
||||
if (typeof event?.data === 'string') return event.data
|
||||
if (typeof event?.data?.text === 'string') return event.data.text
|
||||
if (typeof event?.data?.delta?.content === 'string') return event.data.delta.content
|
||||
const nestedMessageText = extractTextFromMessage(event?.data?.message)
|
||||
if (nestedMessageText) return nestedMessageText
|
||||
return null
|
||||
} catch {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
export const registerBotCommands = (program: Command) => {
|
||||
const bot = program.command('bot').description('Bot management')
|
||||
|
||||
@@ -297,7 +211,8 @@ export const registerBotCommands = (program: Command) => {
|
||||
await ensureModelsReady()
|
||||
const token = ensureAuth()
|
||||
const botId = await resolveBotId(token, id)
|
||||
const sessionId = String(opts.session || `cli:${randomUUID()}`)
|
||||
const config = readConfig()
|
||||
const sessionId = String(opts.session || config.session_id)
|
||||
const rl = readline.createInterface({ input, output })
|
||||
console.log(chalk.green(`Chatting with ${chalk.bold(botId)} (session ${sessionId}). Type \`exit\` to quit.`))
|
||||
while (true) {
|
||||
|
||||
@@ -6,12 +6,12 @@ import ora from 'ora'
|
||||
import { table } from 'table'
|
||||
import readline from 'node:readline/promises'
|
||||
import { stdin as input, stdout as output } from 'node:process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import packageJson from '../../package.json'
|
||||
import { apiRequest } from '../core/api'
|
||||
import { registerBotCommands } from './bot'
|
||||
import { registerChannelCommands } from './channel'
|
||||
import { streamChat } from './stream'
|
||||
import {
|
||||
readConfig,
|
||||
writeConfig,
|
||||
@@ -710,7 +710,8 @@ program
|
||||
await ensureModelsReady()
|
||||
const token = ensureAuth()
|
||||
const botId = await resolveBotId(token, program.opts().bot)
|
||||
const sessionId = `cli:${randomUUID()}`
|
||||
const config = readConfig()
|
||||
const sessionId = config.session_id
|
||||
|
||||
const rl = readline.createInterface({ input, output })
|
||||
console.log(chalk.green(`Chatting with ${chalk.bold(botId)}. Type \`exit\` to quit.`))
|
||||
@@ -756,93 +757,11 @@ program
|
||||
|
||||
program.parseAsync(process.argv)
|
||||
|
||||
const streamChat = async (query: string, botId: string, sessionId: string, token: TokenInfo) => {
|
||||
const config = readConfig()
|
||||
const baseURL = getBaseURL(config)
|
||||
const resp = await fetch(`${baseURL}/bots/${botId}/chat/stream?session_id=${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
}).catch(() => null)
|
||||
if (!resp || !resp.ok || !resp.body) return false
|
||||
|
||||
const stream = resp.body
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let printed = false
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let idx
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, idx).trim()
|
||||
buffer = buffer.slice(idx + 1)
|
||||
if (!line.startsWith('data:')) continue
|
||||
const payload = line.slice(5).trim()
|
||||
if (!payload || payload === '[DONE]') continue
|
||||
const text = extractTextFromEvent(payload)
|
||||
if (text) {
|
||||
process.stdout.write(text)
|
||||
printed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (printed) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const extractTextFromMessage = (message: unknown) => {
|
||||
if (typeof message === 'string') return message
|
||||
if (message && typeof message === 'object') {
|
||||
const value = message as { text?: unknown; parts?: unknown[] }
|
||||
if (typeof value.text === 'string') return value.text
|
||||
if (Array.isArray(value.parts)) {
|
||||
const lines = value.parts
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== 'object') return ''
|
||||
const typed = part as { text?: unknown; url?: unknown; emoji?: unknown }
|
||||
if (typeof typed.text === 'string' && typed.text.trim()) return typed.text
|
||||
if (typeof typed.url === 'string' && typed.url.trim()) return typed.url
|
||||
if (typeof typed.emoji === 'string' && typed.emoji.trim()) return typed.emoji
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (lines.length) return lines.join('\n')
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const extractTextFromEvent = (payload: string) => {
|
||||
try {
|
||||
const event = JSON.parse(payload)
|
||||
if (typeof event === 'string') return event
|
||||
if (typeof event?.text === 'string') return event.text
|
||||
const messageText = extractTextFromMessage(event?.message)
|
||||
if (messageText) return messageText
|
||||
if (typeof event?.delta === 'string') return event.delta
|
||||
if (typeof event?.delta?.content === 'string') return event.delta.content
|
||||
if (typeof event?.content === 'string') return event.content
|
||||
if (typeof event?.data === 'string') return event.data
|
||||
if (typeof event?.data?.text === 'string') return event.data.text
|
||||
if (typeof event?.data?.delta?.content === 'string') return event.data.delta.content
|
||||
const nestedMessageText = extractTextFromMessage(event?.data?.message)
|
||||
if (nestedMessageText) return nestedMessageText
|
||||
return null
|
||||
} catch {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
// streamChat is imported from ./stream
|
||||
|
||||
const runTui = async (botId: string, token: TokenInfo) => {
|
||||
const sessionId = `cli:${randomUUID()}`
|
||||
const config = readConfig()
|
||||
const sessionId = config.session_id
|
||||
|
||||
const rl = readline.createInterface({ input, output })
|
||||
console.log(chalk.green(`TUI session (line mode) with ${chalk.bold(botId)}. Type \`exit\` to quit.`))
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import chalk from 'chalk'
|
||||
import { readConfig, getBaseURL, TokenInfo } from '../utils/store'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool display configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ToolDisplayMode = 'inline' | 'expanded'
|
||||
|
||||
interface ToolDisplayConfig {
|
||||
mode: ToolDisplayMode
|
||||
/** For expanded mode: which parameter to show as detail content */
|
||||
expandParam?: string
|
||||
/** Label shown in the display header */
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools listed here will be displayed in "expanded" mode with a box showing
|
||||
* the specified parameter content. Everything else defaults to single-line.
|
||||
* exec uses a custom single-line format instead of the box.
|
||||
*/
|
||||
const TOOL_DISPLAY: Record<string, ToolDisplayConfig> = {
|
||||
exec: { mode: 'expanded', label: 'exec' },
|
||||
write: { mode: 'expanded', expandParam: 'content', label: 'write' },
|
||||
edit: { mode: 'expanded', expandParam: 'patch', label: 'edit' },
|
||||
}
|
||||
|
||||
const getToolDisplay = (toolName: string): ToolDisplayConfig => {
|
||||
return TOOL_DISPLAY[toolName] ?? { mode: 'inline' }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool call formatting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BOX_WIDTH = 60
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// exec-specific helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extract the actual shell command from exec input like { command: "bash", args: ["-lc", "echo hi"] } */
|
||||
const extractExecCommand = (toolInput: unknown): string => {
|
||||
if (!toolInput || typeof toolInput !== 'object') return ''
|
||||
const input = toolInput as Record<string, unknown>
|
||||
const command = typeof input.command === 'string' ? input.command : ''
|
||||
const args = Array.isArray(input.args) ? input.args.map(String) : []
|
||||
// If shell + -c/-lc flag, extract the actual script
|
||||
if (/^(bash|sh|zsh)$/.test(command) && args.length >= 2) {
|
||||
const flag = args[0]
|
||||
if (flag === '-c' || flag === '-lc') {
|
||||
return args.slice(1).join(' ')
|
||||
}
|
||||
}
|
||||
return [command, ...args].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const formatExecCall = (toolInput: unknown) => {
|
||||
const cmd = extractExecCommand(toolInput)
|
||||
return chalk.dim(' ▶ ') + chalk.white('$ ') + chalk.bold.white(cmd)
|
||||
}
|
||||
|
||||
/** Try to unwrap MCP content-block results into a plain object */
|
||||
const unwrapToolResult = (result: unknown): Record<string, unknown> | null => {
|
||||
if (!result) return null
|
||||
|
||||
// Helper to extract from MCP content blocks array
|
||||
const extractFromContentBlocks = (arr: unknown[]): Record<string, unknown> | null => {
|
||||
for (const block of arr) {
|
||||
if (block && typeof block === 'object') {
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'text' && typeof b.text === 'string') {
|
||||
try { return JSON.parse(b.text) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// MCP content array: [{ type: "text", text: "{...}" }]
|
||||
if (Array.isArray(result)) {
|
||||
return extractFromContentBlocks(result)
|
||||
}
|
||||
|
||||
// Object - might be MCP wrapper { content: [...] } or direct result
|
||||
if (typeof result === 'object') {
|
||||
const obj = result as Record<string, unknown>
|
||||
// MCP wrapper: { content: [{ type: "text", text: "{...}" }], isError: ... }
|
||||
if (Array.isArray(obj.content)) {
|
||||
const extracted = extractFromContentBlocks(obj.content)
|
||||
if (extracted) return extracted
|
||||
}
|
||||
// Direct object with known result fields
|
||||
return obj
|
||||
}
|
||||
|
||||
// JSON string
|
||||
if (typeof result === 'string') {
|
||||
try { return JSON.parse(result) } catch { /* ignore */ }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const formatExecResult = (result: unknown) => {
|
||||
const r = unwrapToolResult(result)
|
||||
if (!r) return chalk.dim(' ╰─ done')
|
||||
|
||||
const exitCode = typeof r.exit_code === 'number' ? r.exit_code : (r.ok ? 0 : 1)
|
||||
const ok = exitCode === 0
|
||||
const stdout = typeof r.stdout === 'string' ? r.stdout.trim() : ''
|
||||
const stderr = typeof r.stderr === 'string' ? r.stderr.trim() : ''
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(chalk.dim(' ╰─ ') + (ok ? chalk.green(`✓ exit ${exitCode}`) : chalk.red(`✗ exit ${exitCode}`)))
|
||||
|
||||
const output = ok ? stdout : (stderr || stdout)
|
||||
if (output) {
|
||||
const outputLines = output.split('\n')
|
||||
const maxLines = 8
|
||||
const shown = outputLines.slice(0, maxLines)
|
||||
for (const ol of shown) {
|
||||
const truncated = ol.length > 72 ? ol.slice(0, 69) + '...' : ol
|
||||
lines.push(chalk.dim(' ') + (ok ? chalk.white(truncated) : chalk.yellow(truncated)))
|
||||
}
|
||||
if (outputLines.length > maxLines) {
|
||||
lines.push(chalk.dim(` ... (${outputLines.length - maxLines} more lines)`))
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic tool formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const formatToolCallInline = (toolName: string, toolInput: unknown) => {
|
||||
let params = ''
|
||||
if (toolInput && typeof toolInput === 'object') {
|
||||
const entries = Object.entries(toolInput as Record<string, unknown>)
|
||||
params = entries
|
||||
.map(([k, v]) => {
|
||||
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
const short = s.length > 40 ? s.slice(0, 37) + '...' : s
|
||||
return `${k}=${short}`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
return chalk.dim(` ◆ ${toolName}`) + (params ? chalk.dim(`(${params})`) : '')
|
||||
}
|
||||
|
||||
const formatToolCallExpanded = (config: ToolDisplayConfig, toolName: string, toolInput: unknown) => {
|
||||
const label = config.label ?? toolName
|
||||
const inputObj = (toolInput && typeof toolInput === 'object' ? toolInput : {}) as Record<string, unknown>
|
||||
|
||||
const summaryParts: string[] = []
|
||||
for (const [k, v] of Object.entries(inputObj)) {
|
||||
if (k === config.expandParam) continue
|
||||
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
summaryParts.push(`${k}: ${s.length > 50 ? s.slice(0, 47) + '...' : s}`)
|
||||
}
|
||||
const summary = summaryParts.length ? ' ' + summaryParts.join(', ') : ''
|
||||
|
||||
let detail = ''
|
||||
if (config.expandParam && config.expandParam in inputObj) {
|
||||
const raw = inputObj[config.expandParam]
|
||||
if (typeof raw === 'string') {
|
||||
detail = raw
|
||||
} else if (Array.isArray(raw)) {
|
||||
detail = raw.join(' ')
|
||||
} else {
|
||||
detail = JSON.stringify(raw, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
const topBorder = '┌' + '─'.repeat(BOX_WIDTH - 2) + '┐'
|
||||
const botBorder = '└' + '─'.repeat(BOX_WIDTH - 2) + '┘'
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(chalk.cyan(topBorder))
|
||||
lines.push(chalk.cyan('│ ') + chalk.bold.white(label) + chalk.gray(summary))
|
||||
|
||||
if (detail) {
|
||||
lines.push(chalk.cyan('│ ') + chalk.dim('─'.repeat(BOX_WIDTH - 4)))
|
||||
const detailLines = detail.split('\n')
|
||||
const maxLines = 20
|
||||
const shown = detailLines.slice(0, maxLines)
|
||||
for (const dl of shown) {
|
||||
const truncated = dl.length > BOX_WIDTH - 4 ? dl.slice(0, BOX_WIDTH - 7) + '...' : dl
|
||||
lines.push(chalk.cyan('│ ') + chalk.white(truncated))
|
||||
}
|
||||
if (detailLines.length > maxLines) {
|
||||
lines.push(chalk.cyan('│ ') + chalk.dim(`... (${detailLines.length - maxLines} more lines)`))
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(chalk.cyan(botBorder))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const formatToolResult = (toolName: string, result: unknown) => {
|
||||
// exec has its own result formatter
|
||||
if (toolName === 'exec') {
|
||||
return formatExecResult(result)
|
||||
}
|
||||
const config = getToolDisplay(toolName)
|
||||
if (config.mode === 'expanded') {
|
||||
const r = unwrapToolResult(result)
|
||||
if (r) {
|
||||
if ('ok' in r) {
|
||||
return chalk.dim(` ╰─ `) + (r.ok ? chalk.green('✓ ok') : chalk.red('✗ failed'))
|
||||
}
|
||||
}
|
||||
return chalk.dim(` ╰─ done`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text extraction helpers (fallback for unknown event formats)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const extractTextFromMessage = (message: unknown) => {
|
||||
if (typeof message === 'string') return message
|
||||
if (message && typeof message === 'object') {
|
||||
const value = message as { text?: unknown; parts?: unknown[] }
|
||||
if (typeof value.text === 'string') return value.text
|
||||
if (Array.isArray(value.parts)) {
|
||||
const lines = value.parts
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== 'object') return ''
|
||||
const typed = part as { text?: unknown; url?: unknown; emoji?: unknown }
|
||||
if (typeof typed.text === 'string' && typed.text.trim()) return typed.text
|
||||
if (typeof typed.url === 'string' && typed.url.trim()) return typed.url
|
||||
if (typeof typed.emoji === 'string' && typed.emoji.trim()) return typed.emoji
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (lines.length) return lines.join('\n')
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const extractTextFromEvent = (payload: string) => {
|
||||
try {
|
||||
const event = JSON.parse(payload)
|
||||
if (typeof event === 'string') return event
|
||||
if (typeof event?.text === 'string') return event.text
|
||||
const messageText = extractTextFromMessage(event?.message)
|
||||
if (messageText) return messageText
|
||||
if (typeof event?.delta === 'string') return event.delta
|
||||
if (typeof event?.delta?.content === 'string') return event.delta.content
|
||||
if (typeof event?.content === 'string') return event.content
|
||||
if (typeof event?.data === 'string') return event.data
|
||||
if (typeof event?.data?.text === 'string') return event.data.text
|
||||
if (typeof event?.data?.delta?.content === 'string') return event.data.delta.content
|
||||
const nestedMessageText = extractTextFromMessage(event?.data?.message)
|
||||
if (nestedMessageText) return nestedMessageText
|
||||
return null
|
||||
} catch {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream chat
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const streamChat = async (query: string, botId: string, sessionId: string, token: TokenInfo) => {
|
||||
const config = readConfig()
|
||||
const baseURL = getBaseURL(config)
|
||||
const resp = await fetch(`${baseURL}/bots/${botId}/chat/stream?session_id=${encodeURIComponent(sessionId)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
}).catch(() => null)
|
||||
if (!resp || !resp.ok || !resp.body) return false
|
||||
|
||||
const stream = resp.body
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let printedText = false
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
let idx
|
||||
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, idx).trim()
|
||||
buffer = buffer.slice(idx + 1)
|
||||
if (!line.startsWith('data:')) continue
|
||||
const payload = line.slice(5).trim()
|
||||
if (!payload || payload === '[DONE]') continue
|
||||
|
||||
let event: Record<string, unknown>
|
||||
try {
|
||||
const parsed = JSON.parse(payload)
|
||||
if (typeof parsed === 'string') {
|
||||
process.stdout.write(parsed)
|
||||
printedText = true
|
||||
continue
|
||||
}
|
||||
event = parsed
|
||||
} catch {
|
||||
process.stdout.write(payload)
|
||||
printedText = true
|
||||
continue
|
||||
}
|
||||
|
||||
const eventType = event.type as string | undefined
|
||||
|
||||
switch (eventType) {
|
||||
case 'text_start':
|
||||
break
|
||||
|
||||
case 'text_delta':
|
||||
if (typeof event.delta === 'string') {
|
||||
process.stdout.write(event.delta)
|
||||
printedText = true
|
||||
}
|
||||
break
|
||||
|
||||
case 'text_end':
|
||||
if (printedText) {
|
||||
process.stdout.write('\n')
|
||||
printedText = false
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_call_start': {
|
||||
if (printedText) {
|
||||
process.stdout.write('\n')
|
||||
printedText = false
|
||||
}
|
||||
const toolName = event.toolName as string
|
||||
const toolInput = event.input
|
||||
if (toolName === 'exec') {
|
||||
console.log(formatExecCall(toolInput))
|
||||
} else {
|
||||
const displayConfig = getToolDisplay(toolName)
|
||||
if (displayConfig.mode === 'expanded') {
|
||||
console.log(formatToolCallExpanded(displayConfig, toolName, toolInput))
|
||||
} else {
|
||||
console.log(formatToolCallInline(toolName, toolInput))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_call_end': {
|
||||
const toolName = event.toolName as string
|
||||
const result = event.result
|
||||
const resultLine = formatToolResult(toolName, result)
|
||||
if (resultLine) {
|
||||
console.log(resultLine)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'reasoning_start':
|
||||
case 'reasoning_delta':
|
||||
case 'reasoning_end':
|
||||
case 'agent_start':
|
||||
case 'agent_end':
|
||||
break
|
||||
|
||||
default: {
|
||||
const text = extractTextFromEvent(payload)
|
||||
if (text) {
|
||||
process.stdout.write(text)
|
||||
printedText = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (printedText) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export type CliConfig = {
|
||||
host: string
|
||||
port: number
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export type TokenInfo = {
|
||||
@@ -18,6 +20,7 @@ export type TokenInfo = {
|
||||
const defaultConfig: CliConfig = {
|
||||
host: '127.0.0.1',
|
||||
port: 8080,
|
||||
session_id: '',
|
||||
}
|
||||
|
||||
const memohDir = () => join(homedir(), '.memoh')
|
||||
@@ -46,24 +49,33 @@ const parseTomlConfig = (raw: string): CliConfig => {
|
||||
} else if (key === 'port') {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(parsed)) result.port = parsed
|
||||
} else if (key === 'session_id') {
|
||||
result.session_id = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const serializeTomlConfig = (config: CliConfig) => {
|
||||
return `host = "${config.host}"\nport = ${config.port}\n`
|
||||
return `host = "${config.host}"\nport = ${config.port}\nsession_id = "${config.session_id}"\n`
|
||||
}
|
||||
|
||||
export const readConfig = (): CliConfig => {
|
||||
ensureStore()
|
||||
const path = configPath()
|
||||
let config: CliConfig
|
||||
if (!existsSync(path)) {
|
||||
writeFileSync(path, serializeTomlConfig(defaultConfig), 'utf-8')
|
||||
return { ...defaultConfig }
|
||||
config = { ...defaultConfig }
|
||||
} else {
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
config = parseTomlConfig(raw)
|
||||
}
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
return parseTomlConfig(raw)
|
||||
// Auto-generate session_id on first run
|
||||
if (!config.session_id) {
|
||||
config.session_id = `cli:${randomUUID()}`
|
||||
writeFileSync(path, serializeTomlConfig(config), 'utf-8')
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
export const writeConfig = (config: CliConfig) => {
|
||||
|
||||
Reference in New Issue
Block a user