Class: OllamaChat::FollowChat

Inherits:
Object
  • Object
show all
Includes:
Ollama, Ollama::Handlers::Concern, MessageFormat, Utils::ValueFormatter, Term::ANSIColor
Defined in:
lib/ollama_chat/follow_chat.rb

Overview

A class that handles chat responses and manages the flow of conversation between the user and Ollama models.

This class is responsible for processing Ollama API responses, updating message history, displaying formatted output to the terminal, and managing voice synthesis for spoken responses. It acts as a handler for streaming responses and ensures proper formatting and display of both regular content and thinking annotations.

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Utils::ValueFormatter

#format_bytes, #format_tokens

Methods included from MessageFormat

#display_sender, #message_type, #role_color, #role_template, #sender_name_displayed, #talk_annotate, #think_annotate

Constructor Details

#initialize(chat:, messages:, group_uuid: nil, voice: nil, output: STDOUT) ⇒ OllamaChat::FollowChat

Initializes a new instance of OllamaChat::FollowChat.

Parameters:

  • chat (OllamaChat::Chat)

    The chat object, which represents the conversation context.

  • messages (#to_a)

    A collection of message objects, representing the conversation history.

  • group_uuid (String, nil) (defaults to: nil)

    The group UUID for the conversation.

  • voice (String, nil) (defaults to: nil)

    (optional) The voice to speak with.

  • output (IO) (defaults to: STDOUT)

    (optional) The output stream where terminal output should be printed. Defaults to STDOUT.



28
29
30
31
32
33
34
35
36
# File 'lib/ollama_chat/follow_chat.rb', line 28

def initialize(chat:, messages:, group_uuid: nil, voice: nil, output: STDOUT)
  super(output:)
  @chat        = chat
  @output.sync = true
  @say         = voice ? Handlers::Say.new(voice:) : NOP
  @messages    = messages
  @group_uuid  = group_uuid
  @sender      = nil
end

Instance Attribute Details

#chatObject (readonly)

Returns the value of attribute chat.



38
39
40
# File 'lib/ollama_chat/follow_chat.rb', line 38

def chat
  @chat
end

#messagesOllamaChat::MessageList<OllamaChat::Message> (readonly)

Returns the conversation history (an array of message objects).

Returns:



44
45
46
# File 'lib/ollama_chat/follow_chat.rb', line 44

def messages
  @messages
end

Instance Method Details

#call(response) ⇒ OllamaChat::FollowChat

Invokes the chat flow based on the provided Ollama server response.

The response is expected to be a parsed JSON object containing information about the user input and the assistant's response.

If the response indicates an assistant message, this method:

1. Ensures that an assistant response exists in the message history (if
 not already present).
2. Updates the last message with the new content and thinking (if
 applicable).
3. Displays the formatted terminal output for the user.
4. Outputs the voice response (if configured).

Regardless of whether an assistant message is present, this method also outputs evaluation statistics (if applicable).

Parameters:

  • response (Ollama::Response)

    The parsed JSON response from the Ollama server.

Returns:



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/ollama_chat/follow_chat.rb', line 66

def call(response)
  debug_output(response)

  if response&.message&.role == 'assistant'
    ensure_assistant_response_exists
    update_last_message(response)
    if chat.stream.on?
      display_formatted_terminal_output
    else
      if display_output
        display_formatted_terminal_output
      end
    end
    @say.call(response)
  end

  output_eval_stats(response)

  handle_tool_calls(response)

  self
end

#debug_output(response) ⇒ Object (private)

The debug_output method conditionally outputs the response object using jj when debugging is enabled.

Parameters:

  • response (Object)

    the response object to be outputted



401
402
403
# File 'lib/ollama_chat/follow_chat.rb', line 401

def debug_output(response)
  chat.debug and chat.log(:debug, response.to_json)
end

#display_formatted_terminal_output(output = nil) ⇒ Object (private)

The display_formatted_terminal_output method formats and outputs the terminal content by processing the last message's content and thinking, then prints it to the output. It handles markdown parsing and annotation based on chat settings, and ensures proper formatting with clear screen and move home commands. The method takes into account whether markdown and thinking modes are enabled to determine how to process and display the content.



326
327
328
329
# File 'lib/ollama_chat/follow_chat.rb', line 326

def display_formatted_terminal_output(output = nil)
  output ||= @output
  output.print(*([ clear_screen, move_home, *last_message_with_user ].compact))
end

#display_outputnil, String (private)

The display_output method shows the last message in the conversation.

This method delegates to the messages object's show_last method, which displays the most recent non-user message in the conversation history. It is typically used to provide message to the user about the last response from the assistant.

Returns:

  • (nil, String)

    the pager command or nil if no paging was performed.



339
340
341
342
343
344
345
346
347
# File 'lib/ollama_chat/follow_chat.rb', line 339

def display_output
  @messages.use_pager do |output|
    if chat.markdown.on?
      display_formatted_terminal_output(output)
    else
      output.print(*last_message_with_user)
    end
  end
end

#ensure_assistant_response_existsObject (private)

The ensure_assistant_response_exists method ensures that the last message in the conversation is from the assistant role.

If the last message is not from an assistant, it adds a new assistant message with empty content and optionally includes thinking content if the chat's think mode is enabled. It also updates the sender display variable to reflect the assistant's message type and styling.



248
249
250
251
252
253
254
255
256
257
258
259
260
# File 'lib/ollama_chat/follow_chat.rb', line 248

def ensure_assistant_response_exists
  if @messages&.last&.role != 'assistant'
    message = Message.new(
      role:        'assistant',
      sender_name: chat.assistant,
      content:     '',
      thinking:    ('' if chat.think?),
      group_uuid:  @group_uuid
    )
    @messages << message
    @sender = display_sender(message)
  end
end

#eval_stats(stats) ⇒ String (private)

The eval_stats method processes a statistics hash and formats it into a colored, readable string output.

Parameters:

  • stats (Hash)

    the statistics hash containing evaluation metrics

Returns:

  • (String)

    a formatted string with statistical information about the evaluation process including durations, counts, and rates, styled with colors and formatting



377
378
379
380
381
382
383
384
# File 'lib/ollama_chat/follow_chat.rb', line 377

def eval_stats(stats)
  stats_text = stats.
    deep_transform(value: -> idx, v { idx =~ /_rate\z/ ? bold(v) + color(111) : v }).
    map { _1 * ?= } * ' '
  '📊 ' + color(111) {
    Kramdown::ANSI::Width.wrap(stats_text, percentage: 90).gsub(/(?<!\A)^/, '   ')
  }
end

#handle_tool_calls(response) ⇒ Object (private)

The handle_tool_calls method processes tool calls from a response and executes them.

This method checks if the response contains tool calls, and if so, iterates through each tool call to execute the corresponding tool from the registered tools. The results of the tool execution are stored in the chat's tool_call_results hash using the tool name as the key.

Parameters:

  • response (Object)

    the response object containing tool calls to process



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/ollama_chat/follow_chat.rb', line 101

def handle_tool_calls(response)
  return unless response.message.ask_and_send(:tool_calls)

  tools_used = {}

  response.message.tool_calls.each do |tool_call|
    name = tool_call.function.name
    if %r(.*[/:](?<new_name>.+)) =~ name
      msg = "Received namespaced tool call for #{name}, correcting to #{new_name}"
      chat.log(:warn, msg, data: { tool: name, new_name: })
      name = new_name
    end
    unless chat.tool_configured?(name)
      msg = "Error: Unconfigured tool named %s ignored => Skip.\n" % name
      chat.tool_call_results[name] << msg
      chat.log(:error, msg, data: { tool: name, reason: :unconfigured })
      next
    end
    unless chat.tool_registered?(name)
      msg = "Error: Unregistered tool named %s ignored => Skip.\n" % name
      chat.tool_call_results[name] << msg
      chat.log(:error, msg, data: { tool: name, reason: :unregistered })
      next
    end
    unless chat.tool_enabled?(name)
      msg = "Error: Disabled tool named %s ignored => Skip.\n" % name
      chat.tool_call_results[name] << msg
      chat.log(:error, msg, data: { tool: name, reason: :disabled })
      next
    end
    STDOUT.puts
    confirmed = :implicit
    function = JSON.pretty_generate(tool_call.function)
    chat.log(:info, "Tool call received", data: { tool: name, function: })
    if chat.tool_function(name).require_confirmation?
      prompt = "🔔 I want to execute tool %s\n%s\nConfirm? (y/n) " % [
        bold { name },
        italic { function },
      ]
      prompt.gsub!('%', '%%')
      if chat.confirm?(prompt:, yes: /\Ay/i)
        confirmed = :explicit
      else
        confirmed = :denied
      end
    else
      STDOUT.puts "Executing tool %s\n%s" % [
        bold { name },
        italic { function },
      ]
    end
    start  = Time.now
    result = nil
    case confirmed
    when :denied
      result = JSON(
        message: 'User denied confirmation!',
        resolve: 'You **MUST** ask the user for instructions on how to proceed!!!',
      )
      STDOUT.printf(
        "\n%s Execution of tool %s denied by user.\n\n", ?🚫, bold { name }
      )
      chat.log(:warn, "Tool execution denied", data: { tool: name, reason: :denied })
    else
      symbol = confirmed == :implicit ? '☑️ ' : ''
      STDOUT.printf(
        "\n%s Execution of tool %s confirmed.\n\n", symbol, bold { name }
      )
      result = OllamaChat::Tools.registered[name].execute(tool_call, chat:)
      if confirmed == :explicit
        chat.log(:info, "Tool execution confirmed", data: { tool: name, confirmed: })
      else
        chat.log(:info, "Tool execution confirmed", data: { tool: name, confirmed: })
      end
    end

    chat.tool_call_results[name] << result
    data    = nil
    message = begin
                 data = JSON.parse(result)
                 chat.log(:info, "Tool result", data: { result: data })
                 data.to_hash['message'] if data.respond_to?(:to_hash)
               rescue
                 chat.log(:info, "Tool result", data: { result: })
                 nil
               end
    warn =
      begin
        !!data.ask_and_send(:[], 'error') || data.ask_and_send(:[], 'success') == false
      rescue
        false
      end

    es = OllamaChat::TokenEstimator.estimate(result.to_s)

    tools_used[name] = {
      message:,
      warn:     ,
      size:     es.bytes_formatted,
      tokens:   es.tokens_formatted,
      duration: Tins::Duration.new(Time.now - start).to_s,
    }
  end

  if tools_used.full?
    infobar.reset
    tools_used.each do |name, info|
      feedback_message =
        if info[:message]
          "\n%s %s\n\n" % [ info[:warn] ? '⚠️' : '💡', info[:message] ]
        end
      STDOUT.puts <<~EOT.strip, ""
        🔧 Tool functions #{name} returned result (#{info[:size]}/#{info[:tokens]} in #{info[:duration]}).
        #{feedback_message}
      EOT
      timeout = chat.tool_function(name).result_display_timeout?
      chat.confirm?(prompt: '⏎  Press any key to continue (%s). ', timeout:)
    end
  end
end

#last_message_with_userArray (private)

The last_message_with_user method constructs a formatted message array by combining user information, newline characters, thinking annotations, and content for display in the terminal output.

Returns:

  • (Array)

    an array containing the user identifier, newline character, thinking annotation (if present), and content formatted for terminal display



310
311
312
313
314
315
316
317
# File 'lib/ollama_chat/follow_chat.rb', line 310

def last_message_with_user
  content, thinking = prepare_last_message
  if thinking.present?
    [ @sender + ?:, ?\n, thinking, ?\n, content ]
  else
    [ @sender + ?:, ?\n, content ]
  end
end

#output_eval_stats(response) ⇒ Object (private)

The output_eval_stats method extracts performance statistics from the response, logs them to the chat history, and prints a formatted summary to the terminal.

Parameters:

  • response (Object)

    the response object containing evaluation data



390
391
392
393
394
395
# File 'lib/ollama_chat/follow_chat.rb', line 390

def output_eval_stats(response)
  response.done or return
  stats = stats_hash(response)
  chat.log(:info, 'Ollama chat response received', data: { stats: })
  @output.puts "", eval_stats(stats)
end

#prepare_last_messageArray<String, String>, Array<String, nil> (private)

The prepare_last_message method processes and formats content and thinking annotations for display.

This method prepares the final content and thinking text by applying appropriate formatting based on the chat's markdown and think loud settings. It handles parsing of content through Kramdown::ANSI when markdown is enabled, and applies annotation formatting to both content and thinking text according to the chat's configuration.

Returns:

  • (Array<String, String>)

    an array containing the processed content and thinking text

  • (Array<String, nil>)

    an array containing the processed content and nil if thinking is disabled



289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/ollama_chat/follow_chat.rb', line 289

def prepare_last_message
  content, thinking = @messages.last.content, @messages.last.thinking
  if chat.markdown.on?
    content = talk_annotate { truncate_for_terminal chat.kramdown_ansi_parse(content) }
    if chat.think?
      thinking = think_annotate { truncate_for_terminal chat.kramdown_ansi_parse(thinking) }
    end
  else
    content = talk_annotate { content }
    chat.think? and thinking = think_annotate { thinking }
  end
  return content&.chomp, thinking&.chomp
end

#stats_hash(response) ⇒ Hash (private)

Extracts performance statistics from an Ollama response and formats them into a hash of human-readable strings and metrics.

Parameters:

  • response (Object)

    the response object containing evaluation metrics

Returns:

  • (Hash)

    a hash of formatted statistics including durations, counts, and rates



354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/ollama_chat/follow_chat.rb', line 354

def stats_hash(response)
  eval_duration        = response.eval_duration.to_f / 1e9
  prompt_eval_duration = response.prompt_eval_duration.to_f / 1e9
  {
    eval_duration:        Tins::Duration.new(eval_duration).to_s,
    eval_count:           response.eval_count.to_i,
    eval_rate:            "%.2f t/s" % (response.eval_count.to_i / eval_duration),
    prompt_eval_duration: Tins::Duration.new(prompt_eval_duration).to_s,
    prompt_eval_count:    response.prompt_eval_count.to_i,
    prompt_eval_rate:     "%.2f t/s" % (response.prompt_eval_count.to_i / prompt_eval_duration),
    total_duration:       Tins::Duration.new(response.total_duration / 1e9).to_s,
    load_duration:        Tins::Duration.new(response.load_duration / 1e9).to_s,
  }
end

#truncate_for_terminal(text, max_lines: Tins::Terminal.lines) ⇒ String (private)

The truncate_for_terminal method processes text to fit within a specified number of lines.

This method takes a text string and trims it to ensure it doesn't exceed the maximum number of lines allowed for terminal display. If the text exceeds the limit, only the last N lines are retained where N equals the maximum lines parameter.

Parameters:

  • text (String)

    the text content to be processed

  • max_lines (Integer) (defaults to: Tins::Terminal.lines)

    the maximum number of lines allowed (defaults to terminal lines)

Returns:

  • (String)

    the text truncated to fit within the specified line limit



234
235
236
237
238
239
# File 'lib/ollama_chat/follow_chat.rb', line 234

def truncate_for_terminal(text, max_lines: Tins::Terminal.lines)
  max_lines = max_lines.clamp(1..)
  lines = text.lines
  return text if lines.size <= max_lines
  lines[-max_lines..-1].join('')
end

#update_last_message(response) ⇒ Object (private)

The update_last_message method appends the content of a response to the last message in the conversation. It also appends thinking content to the last message if thinking is enabled and thinking content is present.

Parameters:

  • response (Object)

    the response object containing message content and thinking



268
269
270
271
272
273
# File 'lib/ollama_chat/follow_chat.rb', line 268

def update_last_message(response)
  @messages.last.content << response.message&.content
  if chat.think? and response_thinking = response.message&.thinking.full?
    @messages.last.thinking << response_thinking
  end
end