📄 repl.ts • 15289 bytes
/**
* CmdCode V0.5 - REPL 主循环
* 从 cli.ts 提取的交互式命令分发和对话处理逻辑
*/
import { t, switchLang } from '../i18n.js'
import { ChatEngine, setMaxHistoryMessages, getMaxHistoryMessages } from '../chat.js'
import type { UserInfo } from '../user.js'
import { saveSession } from '../session.js'
import { isSuperUser } from '../tools.js'
import { startDailyKeyPoolReset } from '../crypto-util.js'
import { saveWorkspaceSnapshot } from '../user.js'
import {
getDirSize, printBanner,
buildWorkspaceSnapshot,
} from './workspace.js'
import { loadConfig, updateAppConfig, getAppConfig } from '../config.js'
import type { UserModelConfig } from '../user-models.js'
import { handleKeypoolCommand } from './keypool.js'
import { handleModelCommand, switchModelDirect } from './model.js'
import { handleSessionCommand } from './session.js'
import { handleSetCommand } from './set.js'
import { handleMemoryCommand } from './memory.js'
import { isGlobalPAVREnabled, setGlobalPAVREnabled } from '../chat-factory.js'
import { closeDb } from '../memory/memoryManager.js'
import { writeFileSync, existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { homedir } from 'node:os'
/** 长任务文件缓冲阈值(800字符)*/
const LONG_INPUT_THRESHOLD = 800
/** 长任务缓冲文件路径 */
const TASK_BUFFER_FILE = join(homedir(), '.cmdcode', 'task_buffer.txt')
/**
* 检测并处理长任务输入
* - 超长输入(>800字符)→ 写入临时文件,REPL 读取文件内容
* - 正常输入 → 原样返回
* 防止超长多行内容在 tmux send-keys 场景下被重复解析
*/
function resolveLongInput(input: string): string {
if (input.length <= LONG_INPUT_THRESHOLD) return input
// 写入缓冲文件
try {
writeFileSync(TASK_BUFFER_FILE, input, 'utf-8')
const lines = input.split('\n')
const preview = lines[0].slice(0, 60) + (lines.length > 1 ? ` ... (+${lines.length - 1}行)` : '')
console.log(` ${t('input.buffered', { size: input.length, preview })}`)
// 读取返回(内容透传,由 engine.chat() 自己决定是否读文件)
return `[任务内容已缓冲到文件,长度${input.length}字符]\n\n${input}`
} catch {
// 写入失败,降级原样返回
return input
}
}
/** REPL 循环所需的上下文 */
export interface ReplContext {
userInfo: UserInfo
engine: ChatEngine
config: { apiKey: string; baseUrl: string; model: string; timeoutMs: number }
modelOnline: boolean
modelLatencyMs: number | undefined
userDefaultModel: UserModelConfig | undefined
defaultSystemPrompt: string
// 颜色常量
color: any
BRAND: string
ACCENT: string
MUTED: string
SUCCESS: string
ERROR: string
WARN: string
// 输入函数
askREPL: (prompt: string) => Promise<{ input: string; action: 'submit' | 'exit' }>
askQuestion: (prompt: string) => Promise<string>
askPassword: (prompt: string) => Promise<string>
// 信号退出回调注册(Ctrl+C 时外部可调用保存逻辑)
registerExitHandler: (handler: () => Promise<void>) => void
}
/**
* 启动 REPL 主循环
* 处理所有交互式命令分发和对话
*/
export async function replLoop(ctx: ReplContext): Promise<void> {
let { engine, config } = ctx
let appConfig = getAppConfig()
// 启动密钥池定时重置
startDailyKeyPoolReset()
// 自动保存定时器(每5分钟保存一次工作区快照)
let autoSaveTimer: ReturnType<typeof setInterval> | null = null
function startAutoSave() {
if (autoSaveTimer) clearInterval(autoSaveTimer)
autoSaveTimer = setInterval(async () => {
try {
const snapshot = buildWorkspaceSnapshot(ctx.userInfo, engine.getHistory())
await saveWorkspaceSnapshot(ctx.userInfo, snapshot)
} catch { /* 静默失败 */ }
}, 5 * 60 * 1000)
}
startAutoSave()
// 退出前保存
async function saveAndExit() {
if (autoSaveTimer) clearInterval(autoSaveTimer)
try {
const snapshot = buildWorkspaceSnapshot(ctx.userInfo, engine.getHistory())
await saveWorkspaceSnapshot(ctx.userInfo, snapshot)
console.log(` ${ctx.BRAND}${t('session.workspace_saved')}${ctx.color.reset}`)
} catch {
console.log(` ${ctx.WARN}${t('session.save_failed')}${ctx.color.reset}`)
}
saveSession(engine.getHistory())
// 关闭记忆系统数据库
try { closeDb() } catch { /* P5: 数据库可能未初始化或已关闭 */ }
}
// 注册退出处理器(供信号处理器调用)
ctx.registerExitHandler(saveAndExit)
// REPL 主循环
while (true) {
const result = await ctx.askREPL(`${ctx.BRAND}›${ctx.color.reset} `)
if (result.action === 'exit') {
await saveAndExit()
break
}
const trimmed = result.input.trim()
if (!trimmed) continue
// 内置命令
if (trimmed === '/exit' || trimmed === '/quit') {
await saveAndExit()
break
}
if (trimmed === '/clear') {
Object.assign(engine, new ChatEngine(config))
console.log(` ${ctx.ACCENT}${t('session.context_cleared')}${ctx.color.reset}`)
continue
}
if (trimmed.startsWith('/keypool')) {
if (!isSuperUser()) {
console.log(` ${ctx.MUTED}${t('keypool.no_permission')}${ctx.color.reset}`)
console.log(` ${ctx.MUTED}${t('keypool.alternative')}${ctx.color.reset}`)
continue
}
const args = trimmed.slice(9).trim().split(/\s+/)
await handleKeypoolCommand(args, ctx.color, ctx.MUTED, ctx.SUCCESS, ctx.ERROR, ctx.WARN, ctx.askPassword, ctx.askQuestion)
continue
}
if (trimmed === '/help') {
console.log('')
console.log(` ${ctx.ACCENT}${t('help.interactive_commands')}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}──────────────────────────────────────────────────${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/exit ${t('help.cmd_exit')}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/clear ${t('help.cmd_clear')}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/set ${t("help.cmd_set")}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/set mem ${t("help.cmd_set_key")}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/model ${t('help.cmd_model')}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/card ${t('help.cmd_card')}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/memory ${t("help.cmd_model")}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/system Set/view system prompt (prepended to all messages)${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/en ${t('help.cmd_en')}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/cn ${t('help.cmd_cn')}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/pavr ${t("help.cmd_pavr")}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}/help ${t('help.cmd_help')}${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}──────────────────────────────────────────────────${ctx.color.reset}`)
console.log(` ${ctx.MUTED}Shortcuts: Ctrl+C Copy | Ctrl+X Cut | Ctrl+V Paste | Ctrl+L Clear | Ctrl+E Exit${ctx.color.reset}`)
console.log('')
continue
}
// 固定提示词命令
if (trimmed === '/system' || trimmed.startsWith('/system ')) {
const arg = trimmed.slice(7).trim()
if (!arg) {
// 显示当前固定提示词
const currentPrompt = appConfig?.systemPrompt || ''
console.log('')
console.log(` ${ctx.ACCENT}📝 System Prompt${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}──────────────────────────────────────────────────${ctx.color.reset}`)
if (currentPrompt) {
console.log(` ${ctx.MUTED}${t("system.custom")} ${currentPrompt.slice(0, 100)}${currentPrompt.length > 100 ? '...' : ''}${ctx.color.reset}`)
} else {
console.log(` ${ctx.MUTED}${t("system.using_default")}${ctx.color.reset}`)
console.log(` ${ctx.MUTED}${ctx.defaultSystemPrompt.slice(0, 80)}...${ctx.color.reset}`)
}
console.log('')
console.log(` ${ctx.MUTED}${t("system.set_usage")}${ctx.color.reset}`)
console.log(` ${ctx.MUTED}${t("system.usage")}${ctx.color.reset}`)
console.log('')
} else if (arg.toLowerCase() === 'clear') {
// 清除固定提示词
updateAppConfig({ systemPrompt: '' })
appConfig = getAppConfig()
console.log(` ${ctx.SUCCESS}✓ ${t("system.cleared")}${ctx.color.reset}`)
} else {
// 设置固定提示词
updateAppConfig({ systemPrompt: arg })
appConfig = getAppConfig()
console.log(` ${ctx.SUCCESS}✓ ${t("system.set")} ${arg}${ctx.color.reset}`)
}
continue
}
// 设置最大历史消息数
if (trimmed.startsWith('/maxhistory ')) {
const num = parseInt(trimmed.slice(12).trim(), 10)
if (num >= 10 && num <= 1000) {
setMaxHistoryMessages(num)
console.log(` ${ctx.SUCCESS}最大历史消息数已设置为 ${num}${ctx.color.reset}`)
} else {
console.log(` ${ctx.WARN}请输入 10-1000 之间的数字${ctx.color.reset}`)
}
continue
}
if (trimmed === '/maxhistory') {
console.log(` ${ctx.ACCENT}当前最大历史消息数: ${getMaxHistoryMessages()}${ctx.color.reset}`)
console.log(` ${ctx.MUTED}用法: /maxhistory <数字> 范围 10-1000${ctx.color.reset}`)
continue
}
// PAVR 功能开关命令
if (trimmed === '/pavr') {
const current = isGlobalPAVREnabled()
console.log('')
console.log(` ${ctx.ACCENT}🔄 PAVR 循环 (Plan-Act-Verify-Respond)${ctx.color.reset}`)
console.log(` ${ctx.ACCENT}──────────────────────────────────────────────────${ctx.color.reset}`)
console.log(` ${ctx.MUTED}当前状态: ${current ? '✅ 已启用' : '❌ 已禁用'}${ctx.color.reset}`)
console.log('')
console.log(` ${ctx.MUTED}用法: /pavr on - 启用 PAVR 循环${ctx.color.reset}`)
console.log(` ${ctx.MUTED} /pavr off - 禁用 PAVR 循环${ctx.color.reset}`)
console.log(` ${ctx.MUTED} /pavr - 查看当前状态${ctx.color.reset}`)
console.log('')
continue
}
if (trimmed === '/pavr on' || trimmed === '/pavr off') {
const enabled = trimmed === '/pavr on'
setGlobalPAVREnabled(enabled)
console.log(` ${ctx.SUCCESS}✓ PAVR 循环已${enabled ? '启用' : '禁用'}${ctx.color.reset}`)
continue
}
// 语言切换命令
if (trimmed === '/en' || trimmed === '/EN') {
const msg = switchLang('en')
console.log(` ${ctx.SUCCESS}${msg}${ctx.color.reset}`)
// 刷新状态面板
const modelName = ctx.userDefaultModel?.name || config.model
printBanner(ctx.userInfo, { name: modelName, model: config.model, online: ctx.modelOnline, latencyMs: ctx.modelLatencyMs }, engine.getHistory().length, getDirSize(ctx.userInfo.workspaceDir), { BRAND: ctx.BRAND, ACCENT: ctx.ACCENT, MUTED: ctx.MUTED, SUCCESS: ctx.SUCCESS, WARN: ctx.WARN, color: ctx.color })
continue
}
if (trimmed === '/cn' || trimmed === '/CN') {
const msg = switchLang('zh')
console.log(` ${ctx.SUCCESS}${msg}${ctx.color.reset}`)
// 刷新状态面板
const modelName = ctx.userDefaultModel?.name || config.model
printBanner(ctx.userInfo, { name: modelName, model: config.model, online: ctx.modelOnline, latencyMs: ctx.modelLatencyMs }, engine.getHistory().length, getDirSize(ctx.userInfo.workspaceDir), { BRAND: ctx.BRAND, ACCENT: ctx.ACCENT, MUTED: ctx.MUTED, SUCCESS: ctx.SUCCESS, WARN: ctx.WARN, color: ctx.color })
continue
}
// 向量记忆搜索命令
if (await handleMemoryCommand(trimmed, ctx.ACCENT, ctx.MUTED, ctx.SUCCESS, ctx.ERROR, ctx.color)) {
continue
}
// /set 命令(/set mem, /set interactive, /set <key>)
{
const setResult = await handleSetCommand(trimmed, config, engine, ctx.color, ctx.ACCENT, ctx.MUTED, ctx.SUCCESS, ctx.ERROR, ctx.WARN, ctx.askQuestion)
if (setResult.handled) {
config = setResult.config
Object.assign(engine, setResult.engine)
continue
}
}
if (trimmed === '/model') {
const result = await handleModelCommand(ctx.userInfo.username, config, engine, ctx.color, ctx.BRAND, ctx.MUTED, ctx.SUCCESS, ctx.ERROR, ctx.ACCENT, ctx.WARN, ctx.askQuestion)
if (result.changed) {
config = result.config
Object.assign(engine, new ChatEngine(config))
}
continue
}
// /model <model-id> 非交互式快速切换(避免交互菜单在 tmux 下崩溃)
if (trimmed.startsWith('/model ')) {
const modelId = trimmed.slice(7).trim()
if (modelId) {
const result = await switchModelDirect(modelId, config, ctx.color, ctx.SUCCESS, ctx.ERROR, ctx.WARN)
if (result.changed) {
config = result.config
Object.assign(engine, new ChatEngine(config))
console.log(` ${ctx.SUCCESS}✓ 已切换到 ${modelId}${ctx.color.reset}`)
}
}
continue
}
// 会话管理命令(/session list/read/delete/cleanup, /card, /sessions, /history)
if (await handleSessionCommand(trimmed, ctx.color, ctx.ACCENT, ctx.MUTED, ctx.SUCCESS, ctx.ERROR, ctx.WARN, ctx.askQuestion)) {
continue
}
try {
// 拼接系统提示词(用户自定义 > 默认)
const systemPrompt = appConfig?.systemPrompt || ctx.defaultSystemPrompt
// 长任务自动缓冲,防止 tmux send-keys 多行内容被重复解析
const resolvedInput = resolveLongInput(trimmed)
const finalMessage = `${systemPrompt}\n\n${resolvedInput}`
await engine.chat(finalMessage)
saveSession(engine.getHistory())
// 每次对话后异步更新云端快照
const snapshot = buildWorkspaceSnapshot(ctx.userInfo, engine.getHistory())
saveWorkspaceSnapshot(ctx.userInfo, snapshot).catch(() => {})
} catch (e: any) {
if (e.status === 429) {
console.error(`\n ${ctx.WARN}${t("error.429_single")}${ctx.color.reset}`)
console.error(` ${ctx.MUTED}${t("error.switch_model_hint")}${ctx.color.reset}`)
} else if (e.code === 'ECONNREFUSED' || e.code === 'ENOTFOUND' || e.code === 'ETIMEDOUT') {
console.error(`\n ${ctx.ERROR}${t("error.network_single")} ${e.message}${ctx.color.reset}`)
console.error(` ${ctx.MUTED}${t("error.check_network_hint")}${ctx.color.reset}`)
} else {
console.error(`\n ${ctx.ERROR}${t("error.general_single")} ${e.message}${ctx.color.reset}`)
console.error(` ${ctx.MUTED}${t("error.model_hint_single")}${ctx.color.reset}`)
}
}
}
console.log('')
console.log(` ${ctx.ACCENT}${t('misc.goodbye')}${ctx.color.reset}`)
console.log('')
}