packages feed

louter-0.1.0.0: app/CLI.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- | Interactive CLI for chatting with LLMs via Louter client API
-- Supports MCP (Model Context Protocol) for external tools and resources
module Main where

import Control.Monad (forever, when, unless)
import Data.Aeson (Value(..), Object, encode, eitherDecode, object, (.=))
import qualified Data.Aeson.KeyMap as HM
import qualified Data.ByteString.Lazy as BL
import Data.IORef
import Data.List (isPrefixOf)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Options.Applicative
import System.IO (hFlush, stdout, hSetBuffering, BufferMode(..))
import System.Exit (exitSuccess)

import Louter.Client
import Louter.Client.OpenAI
import Louter.Types.Request
import Louter.Types.Streaming

-- | CLI configuration
data CLIConfig = CLIConfig
  { cliBackendUrl :: Text
  , cliModel :: Text
  , cliApiKey :: Maybe Text
  , cliMcpServers :: [Text]  -- MCP server URLs/commands
  , cliTemperature :: Double
  , cliMaxTokens :: Int
  , cliStreaming :: Bool
  } deriving (Show)

-- | MCP Tool definition
data MCPTool = MCPTool
  { mcpToolName :: Text
  , mcpToolDescription :: Text
  , mcpToolServer :: Text
  , mcpToolSchema :: Value
  } deriving (Show)

-- | CLI state
data CLIState = CLIState
  { stateClient :: Client
  , stateConfig :: CLIConfig
  , stateHistory :: [Message]  -- Conversation history
  , stateMCPTools :: [MCPTool]  -- Available MCP tools
  }

-- | Parse CLI arguments
cliParser :: Parser CLIConfig
cliParser = CLIConfig
  <$> strOption
      ( long "backend"
     <> short 'b'
     <> metavar "URL"
     <> value "http://localhost:11211"
     <> help "Backend LLM server URL (default: llama-server on 11211)" )
  <*> strOption
      ( long "model"
     <> short 'm'
     <> metavar "MODEL"
     <> value "gpt-oss"
     <> help "Model name (default: gpt-oss)" )
  <*> optional (strOption
      ( long "api-key"
     <> short 'k'
     <> metavar "KEY"
     <> help "API key (if required)" ))
  <*> many (strOption
      ( long "mcp-server"
     <> metavar "SERVER"
     <> help "MCP server to connect (can specify multiple)" ))
  <*> option auto
      ( long "temperature"
     <> short 't'
     <> metavar "TEMP"
     <> value 0.7
     <> help "Temperature (default: 0.7)" )
  <*> option auto
      ( long "max-tokens"
     <> metavar "TOKENS"
     <> value 2000
     <> help "Max tokens (default: 2000)" )
  <*> switch
      ( long "stream"
     <> short 's'
     <> help "Enable streaming responses" )

main :: IO ()
main = do
  config <- execParser opts
  runCLI config
  where
    opts = info (cliParser <**> helper)
      ( fullDesc
     <> progDesc "Interactive CLI for LLMs with MCP support"
     <> header "louter-cli - Chat with local/remote LLMs" )

-- | Run the interactive CLI
runCLI :: CLIConfig -> IO ()
runCLI config@CLIConfig{..} = do
  -- Initialize client
  client <- llamaServerClient cliBackendUrl

  -- Initialize MCP tools
  mcpTools <- initMCPServers cliMcpServers

  let initialState = CLIState
        { stateClient = client
        , stateConfig = config
        , stateHistory = []
        , stateMCPTools = mcpTools
        }

  -- Set unbuffered input for interactive experience
  hSetBuffering stdout NoBuffering

  -- Print welcome message
  printWelcome config mcpTools

  -- Start REPL
  repl initialState

-- | Print welcome message
printWelcome :: CLIConfig -> [MCPTool] -> IO ()
printWelcome CLIConfig{..} mcpTools = do
  TIO.putStrLn "╔═══════════════════════════════════════════════════════════╗"
  TIO.putStrLn "║           Louter CLI - LLM Chat with MCP Support         ║"
  TIO.putStrLn "╚═══════════════════════════════════════════════════════════╝"
  TIO.putStrLn ""
  TIO.putStrLn $ "Backend: " <> cliBackendUrl
  TIO.putStrLn $ "Model:   " <> cliModel
  TIO.putStrLn $ "Streaming: " <> (if cliStreaming then "enabled" else "disabled")

  unless (null mcpTools) $ do
    TIO.putStrLn ""
    TIO.putStrLn "MCP Tools available:"
    mapM_ (\tool -> TIO.putStrLn $ "  • " <> mcpToolName tool <> " - " <> mcpToolDescription tool) mcpTools

  TIO.putStrLn ""
  TIO.putStrLn "Commands:"
  TIO.putStrLn "  /help     - Show this help"
  TIO.putStrLn "  /clear    - Clear conversation history"
  TIO.putStrLn "  /history  - Show conversation history"
  TIO.putStrLn "  /tools    - List available MCP tools"
  TIO.putStrLn "  /exit     - Exit the CLI"
  TIO.putStrLn ""

-- | REPL loop
repl :: CLIState -> IO ()
repl state = do
  TIO.putStr "You: "
  hFlush stdout
  input <- TIO.getLine

  let inputText = T.strip input

  -- Handle commands
  if T.null inputText
    then repl state
    else if "/" `T.isPrefixOf` inputText
      then do
        newState <- handleCommand inputText state
        if T.toLower inputText == "/exit"
          then pure ()
          else repl newState
      else do
        -- Handle regular chat message
        newState <- handleMessage inputText state
        repl newState

-- | Handle special commands
handleCommand :: Text -> CLIState -> IO CLIState
handleCommand cmd state@CLIState{..}
  | cmd == "/help" = do
      printWelcome stateConfig stateMCPTools
      pure state

  | cmd == "/clear" = do
      TIO.putStrLn "Conversation history cleared."
      pure state { stateHistory = [] }

  | cmd == "/history" = do
      TIO.putStrLn "\nConversation History:"
      mapM_ printMessage stateHistory
      pure state

  | cmd == "/tools" = do
      TIO.putStrLn "\nAvailable MCP Tools:"
      if null stateMCPTools
        then TIO.putStrLn "  (none)"
        else mapM_ (\tool -> TIO.putStrLn $ "  • " <> mcpToolName tool <> " - " <> mcpToolDescription tool) stateMCPTools
      pure state

  | cmd == "/exit" = do
      TIO.putStrLn "Goodbye!"
      pure state

  | otherwise = do
      TIO.putStrLn $ "Unknown command: " <> cmd
      TIO.putStrLn "Type /help for available commands"
      pure state

-- | Print a message
printMessage :: Message -> IO ()
printMessage msg = do
  let roleStr = case msgRole msg of
        RoleUser -> "You"
        RoleAssistant -> "Assistant"
        RoleSystem -> "System"
        RoleTool -> "Tool"
      content = contentPartsToText (msgContent msg)
  TIO.putStrLn $ roleStr <> ": " <> content

-- | Convert ContentPart list to Text (for display)
contentPartsToText :: [ContentPart] -> Text
contentPartsToText parts = T.intercalate " " [txt | TextPart txt <- parts]

-- | Handle a chat message
handleMessage :: Text -> CLIState -> IO CLIState
handleMessage userInput state@CLIState{..} = do
  let CLIConfig{..} = stateConfig
      userMessage = Message RoleUser [TextPart userInput]
      newHistory = stateHistory ++ [userMessage]

      -- Build tools list from MCP tools
      tools = map mcpToolToTool stateMCPTools

      request = ChatRequest
        { reqModel = cliModel
        , reqMessages = newHistory
        , reqTools = tools
        , reqToolChoice = ToolChoiceAuto
        , reqTemperature = Just cliTemperature
        , reqMaxTokens = Just cliMaxTokens
        , reqStream = cliStreaming
        }

  TIO.putStr "Assistant: "
  hFlush stdout

  -- Make request
  if cliStreaming
    then do
      -- Streaming response
      assistantContent <- handleStreamingResponse stateClient request
      TIO.putStrLn ""  -- Newline after streaming
      let assistantMessage = Message RoleAssistant [TextPart assistantContent]
      pure state { stateHistory = newHistory ++ [assistantMessage] }
    else do
      -- Non-streaming response
      result <- chatCompletion stateClient request
      case result of
        Left err -> do
          TIO.putStrLn $ "Error: " <> err
          pure state
        Right response -> do
          let content = case respChoices response of
                (choice:_) -> choiceMessage choice
                [] -> ""
          TIO.putStrLn content
          let assistantMessage = Message RoleAssistant [TextPart content]
          pure state { stateHistory = newHistory ++ [assistantMessage] }

-- | Handle streaming response
handleStreamingResponse :: Client -> ChatRequest -> IO Text
handleStreamingResponse client request = do
  contentRef <- newIORef ""

  streamChatWithCallback client request $ \event -> do
    case event of
      StreamContent txt -> do
        TIO.putStr txt
        hFlush stdout
        modifyIORef' contentRef (<> txt)

      StreamReasoning txt -> do
        -- Show reasoning in different color if terminal supports it
        TIO.putStr $ "[thinking: " <> txt <> "] "
        hFlush stdout

      StreamToolCall toolCall -> do
        TIO.putStrLn $ "\n[Tool call: " <> T.pack (show toolCall) <> "]"
        -- TODO: Execute MCP tool call here

      StreamFinish reason -> do
        pure ()

      StreamError err -> do
        TIO.putStrLn $ "\nError: " <> err

  readIORef contentRef

-- | Convert MCP tool to Louter Tool
mcpToolToTool :: MCPTool -> Tool
mcpToolToTool MCPTool{..} = Tool
  { toolName = mcpToolName
  , toolDescription = Just mcpToolDescription
  , toolParameters = mcpToolSchema
  }

-- | Initialize MCP servers
initMCPServers :: [Text] -> IO [MCPTool]
initMCPServers serverSpecs = do
  -- TODO: Implement actual MCP protocol connection
  -- For now, return empty list
  -- In full implementation:
  -- 1. Connect to each MCP server via stdio or HTTP+SSE
  -- 2. Send 'tools/list' request
  -- 3. Parse tool definitions
  -- 4. Return as MCPTool list

  unless (null serverSpecs) $ do
    TIO.putStrLn "Note: MCP support is placeholder - not yet fully implemented"
    TIO.putStrLn "Will connect to:"
    mapM_ (\spec -> TIO.putStrLn $ "  - " <> spec) serverSpecs

  pure []