📄 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('')
}