Ctrl K
ComunidadGuías…

scripts/chat-cli.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env tsx

import { config as dotenvConfig } from 'dotenv'
import { Readable } from 'stream'
import type { ReadableStream as NodeReadableStream } from 'stream/web'

// Load environment variables from .env.local
dotenvConfig({ path: '.env.local' })

// Constants
const DEFAULT_MESSAGE = 'Hello, how are you?'

interface ChatApiConfig {
  apiUrl: string
  message: string
  chatId?: string
  searchMode?: 'quick' | 'adaptive' | boolean
  trigger?: 'submit-message' | 'regenerate-message'
  messageId?: string
}

interface UIMessage {
  id: string
  role: 'user' | 'assistant' | 'system'
  content?: string
  parts: Array<{
    type: string
    text?: string
    [key: string]: any
  }>
  createdAt: Date
}

interface ChatPayload {
  chatId: string
  trigger: 'submit-message' | 'regenerate-message'
  message?: UIMessage
  messageId?: string
  isNewChat?: boolean
}

class ChatApiTester {
  private config: ChatApiConfig

  private validateUrl(url?: string): string | undefined {
    if (!url) return undefined

    try {
      const parsed = new URL(url)
      // Only allow localhost and local network
      if (
        parsed.hostname === 'localhost' ||
        parsed.hostname === '127.0.0.1' ||
        parsed.hostname.startsWith('192.168.') ||
        parsed.hostname.startsWith('10.0.') ||
        parsed.hostname.startsWith('172.16.') ||
        parsed.hostname.startsWith('172.17.') ||
        parsed.hostname.startsWith('172.18.') ||
        parsed.hostname.startsWith('172.19.') ||
        parsed.hostname.startsWith('172.20.') ||
        parsed.hostname.startsWith('172.21.') ||
        parsed.hostname.startsWith('172.22.') ||
        parsed.hostname.startsWith('172.23.') ||
        parsed.hostname.startsWith('172.24.') ||
        parsed.hostname.startsWith('172.25.') ||
        parsed.hostname.startsWith('172.26.') ||
        parsed.hostname.startsWith('172.27.') ||
        parsed.hostname.startsWith('172.28.') ||
        parsed.hostname.startsWith('172.29.') ||
        parsed.hostname.startsWith('172.30.') ||
        parsed.hostname.startsWith('172.31.') ||
        parsed.hostname.endsWith('.local')
      ) {
        return url
      }
      console.error('❌ Only local URLs are allowed for security')
      process.exit(1)
    } catch {
      console.error('❌ Invalid URL format')
      process.exit(1)
    }
  }

  constructor(config: Partial<ChatApiConfig> = {}) {
    this.config = {
      apiUrl:
        this.validateUrl(config.apiUrl) || 'http://localhost:3000/api/chat',
      message: config.message || DEFAULT_MESSAGE,
      chatId: config.chatId || this.generateId(),
      searchMode: config.searchMode ?? 'adaptive',
      trigger: config.trigger || 'submit-message',
      messageId: config.messageId
    }
  }

  private generateId(): string {
    return `chat_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
  }

  private createUserMessage(text: string, messageId?: string): UIMessage {
    // Limit message length for safety
    const sanitizedText = text.slice(0, 10000)

    return {
      id: messageId || this.generateId(),
      role: 'user',
      content: sanitizedText,
      parts: [
        {
          type: 'text',
          text: sanitizedText
        }
      ],
      createdAt: new Date()
    }
  }

  private loadCookiesFromEnv(): string | undefined {
    if (process.env.MORPHIC_COOKIES) {
      console.log('🍪 Using cookies from MORPHIC_COOKIES environment variable')
      return process.env.MORPHIC_COOKIES
    }
    return undefined
  }

  async sendMessage(message?: string): Promise<void> {
    // Load cookies from env
    const cookies = this.loadCookiesFromEnv()

    const payload: ChatPayload = {
      chatId: this.config.chatId!,
      trigger: this.config.trigger || 'submit-message'
    }

    // Add message for submit trigger or messageId for regenerate
    if (this.config.trigger === 'regenerate-message') {
      if (!this.config.messageId) {
        console.error('❌ Message ID is required for regeneration')
        process.exit(1)
      }
      payload.messageId = this.config.messageId
      // Only include message if we're editing a user message
      if (message && message !== DEFAULT_MESSAGE) {
        // Use the same messageId for the edited message
        const userMessage = this.createUserMessage(
          message,
          this.config.messageId
        )
        payload.message = userMessage
      }
    } else {
      const userMessage = this.createUserMessage(message || this.config.message)
      payload.message = userMessage
      // Add isNewChat flag - always true for CLI since we generate new chatId each time
      payload.isNewChat = true
    }

    console.log('🚀 Sending request to:', this.config.apiUrl)
    console.log('📦 Payload:', JSON.stringify(payload, null, 2))
    console.log('🔍 Search Mode:', this.config.searchMode)
    console.log('💬 Chat ID:', this.config.chatId)
    console.log('\n---\n')

    // Build cookie string
    let cookieString = ''
    if (cookies) {
      // If cookies from env exist, append our settings to them
      cookieString = cookies
      if (!cookieString.includes('searchMode=')) {
        const searchModeValue =
          this.config.searchMode === false ? 'disabled' : this.config.searchMode
        cookieString += `; searchMode=${searchModeValue}`
      }
    } else {
      // If no cookies from env, just use our settings
      const searchModeValue =
        this.config.searchMode === false ? 'disabled' : this.config.searchMode
      cookieString = [`searchMode=${searchModeValue}`].join('; ')
    }

    const headers = {
      'Content-Type': 'application/json',
      Cookie: cookieString
    }

    // Only show headers in debug mode, without sensitive data
    if (process.env.DEBUG) {
      console.log('🔐 Request headers:')
      console.log(`   Content-Type: ${headers['Content-Type']}`)
      console.log(`   Cookie: [REDACTED]`)
    }

    try {
      const response = await fetch(this.config.apiUrl, {
        method: 'POST',
        headers,
        body: JSON.stringify(payload)
      })

      if (!response.ok) {
        const errorText = await response.text()
        throw new Error(
          `HTTP ${response.status}: ${response.statusText}\n${errorText}`
        )
      }

      if (!response.body) {
        throw new Error('No response body')
      }

      console.log('📡 Response received, starting stream...\n')

      // Convert Web Streams API to Node.js stream
      // Type assertion needed due to Node.js/Web Streams API type mismatch
      // response.body is guaranteed to be a ReadableStream at this point
      const webStream = response.body as unknown as NodeReadableStream<any>
      const nodeReadable = Readable.fromWeb(webStream)

      // Create parser stream

      // Process SSE stream
      const textDecoder = new TextDecoder()
      let buffer = ''
      let hasReceivedData = false

      for await (const chunk of nodeReadable) {
        buffer += textDecoder.decode(chunk, { stream: true })
        const lines = buffer.split('\n')
        buffer = lines.pop() || ''

        for (const line of lines) {
          if (line.trim() === '') continue

          // Debug: log raw lines
          if (process.env.DEBUG) {
            console.log(`🔍 Raw line: ${line}`)
          }

          if (line.startsWith('data: ')) {
            hasReceivedData = true
            const data = line.slice(6)
            if (data === '[DONE]') {
              console.log('\n\n✅ Stream finished')
              continue
            }

            try {
              const parsed = JSON.parse(data)

              if (parsed.type === 'text') {
                process.stdout.write(parsed.text || '')
              } else if (parsed.type === 'text-delta') {
                process.stdout.write(parsed.delta || '')
              } else if (parsed.type === 'reasoning') {
                console.log(`\n🤔 Reasoning: ${parsed.text}`)
              } else if (parsed.type?.startsWith('tool-')) {
                console.log(`\n🔧 Tool: ${parsed.type}`)
                console.log(`   State: ${parsed.state}`)
                if (parsed.input) {
                  console.log(`   Input: ${JSON.stringify(parsed.input)}`)
                }
                if (parsed.output) {
                  console.log(`   Output: ${JSON.stringify(parsed.output)}`)
                }
              } else if (parsed.type === 'finish') {
                console.log('\n\n✅ Stream completed')
              } else if (parsed.type === 'error') {
                console.error(
                  `\n❌ Error: ${parsed.error || parsed.errorText || JSON.stringify(parsed)}`
                )
              } else if (process.env.DEBUG) {
                // Only show raw event types in debug mode
                console.log(`\n📦 Event type: ${parsed.type || 'unknown'}`)
              }
            } catch (error) {
              if (process.env.DEBUG) {
                console.log(`📄 Parse error for data`)
              }
            }
          } else if (line.startsWith('event: ')) {
            const eventName = line.slice(7)
            console.log(`📨 Event: ${eventName}`)
          }
        }
      }

      if (!hasReceivedData) {
        console.log('\n⚠️  No data received from stream')
      }

      console.log('\n\n✨ Request completed successfully')
    } catch (error) {
      console.error(
        '\n❌ Error:',
        error instanceof Error ? error.message : error
      )
      process.exit(1)
    }
  }
}

// Parse command line arguments
function parseArgs(): Partial<ChatApiConfig> {
  const args = process.argv.slice(2)
  const config: Partial<ChatApiConfig> = {}

  for (let i = 0; i < args.length; i++) {
    switch (args[i]) {
      case '-m':
      case '--message':
        config.message = args[++i]
        break
      case '-u':
      case '--url':
        config.apiUrl = args[++i]
        break
      case '-c':
      case '--chat-id':
        config.chatId = args[++i]
        break
      case '-s':
      case '--search':
        config.searchMode = 'adaptive'
        break
      case '--no-search':
        config.searchMode = false
        break
      case '--search-mode':
        const searchMode = args[++i]
        if (['quick', 'adaptive'].includes(searchMode)) {
          config.searchMode = searchMode as 'quick' | 'adaptive'
        } else {
          console.error('❌ Invalid search mode. Use: quick or adaptive')
          process.exit(1)
        }
        break
      case '-t':
      case '--trigger':
        const trigger = args[++i]
        if (trigger === 'regenerate' || trigger === 'regenerate-message') {
          config.trigger = 'regenerate-message'
        } else {
          config.trigger = 'submit-message'
        }
        break
      case '--message-id':
        config.messageId = args[++i]
        break
      case '-h':
      case '--help':
        console.log(`
Chat API Test Script

Usage: bun scripts/chat-cli.ts [options]

Options:
  -m, --message <text>    Message to send (default: "Hello, how are you?")
  -u, --url <url>         API URL (default: http://localhost:3000/api/chat)
  -c, --chat-id <id>      Chat ID (default: auto-generated)
  -s, --search            Enable search mode with adaptive strategy (default)
  --no-search             Disable search mode
  --search-mode <type>    Search strategy: quick or adaptive
  -t, --trigger <type>    Trigger type: submit (default) or regenerate
  --message-id <id>       Message ID (required for regenerate)
  -h, --help              Show this help message

Examples:
  # Simple test
  bun scripts/chat-cli.ts -m "What is the weather like?"
  
  # Without search mode
  bun scripts/chat-cli.ts -m "Tell me a joke" --no-search
  
  # Continue existing chat
  bun scripts/chat-cli.ts -c "chat_123" -m "Tell me more"
  
  # Regenerate assistant message
  bun scripts/chat-cli.ts -c "chat_123" -t regenerate --message-id "msg_123"
  
  # Edit user message and regenerate
  bun scripts/chat-cli.ts -c "chat_123" -t regenerate --message-id "msg_123" -m "New message"
  
Note: Without authentication, you may get "User not authenticated" errors.

Authentication:

1. Add to .env.local: MORPHIC_COOKIES="your-cookie-string"
2. The script will automatically load cookies when it runs

To get cookies:
1. Open DevTools > Network tab
2. Make any request in Morphic
3. Click on the request > Headers > Request Headers > Cookie
4. Copy the entire Cookie value
5. Add to .env.local as MORPHIC_COOKIES="copied-value"
`)
        process.exit(0)
    }
  }

  return config
}

// Main execution
async function main() {
  const config = parseArgs()
  const tester = new ChatApiTester(config)

  await tester.sendMessage(config.message)
}

// Run the script
main().catch(error => {
  console.error('Fatal error:', error)
  process.exit(1)
})