ollama-ruby 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +1 -0
  3. data/CHANGES.md +78 -0
  4. data/README.md +62 -23
  5. data/Rakefile +16 -4
  6. data/bin/ollama_chat +470 -90
  7. data/bin/ollama_console +3 -3
  8. data/bin/ollama_update +17 -0
  9. data/config/redis.conf +5 -0
  10. data/docker-compose.yml +11 -0
  11. data/lib/ollama/client.rb +7 -2
  12. data/lib/ollama/documents/memory_cache.rb +44 -0
  13. data/lib/ollama/documents/redis_cache.rb +57 -0
  14. data/lib/ollama/documents/splitters/character.rb +70 -0
  15. data/lib/ollama/documents/splitters/semantic.rb +90 -0
  16. data/lib/ollama/documents.rb +172 -0
  17. data/lib/ollama/dto.rb +4 -7
  18. data/lib/ollama/handlers/progress.rb +18 -5
  19. data/lib/ollama/image.rb +16 -7
  20. data/lib/ollama/options.rb +4 -0
  21. data/lib/ollama/utils/chooser.rb +30 -0
  22. data/lib/ollama/utils/colorize_texts.rb +42 -0
  23. data/lib/ollama/utils/fetcher.rb +105 -0
  24. data/lib/ollama/utils/math.rb +48 -0
  25. data/lib/ollama/utils/tags.rb +7 -0
  26. data/lib/ollama/utils/width.rb +1 -1
  27. data/lib/ollama/version.rb +1 -1
  28. data/lib/ollama.rb +12 -5
  29. data/ollama-ruby.gemspec +19 -9
  30. data/spec/assets/embeddings.json +1 -0
  31. data/spec/ollama/client_spec.rb +2 -2
  32. data/spec/ollama/commands/chat_spec.rb +2 -2
  33. data/spec/ollama/commands/copy_spec.rb +2 -2
  34. data/spec/ollama/commands/create_spec.rb +2 -2
  35. data/spec/ollama/commands/delete_spec.rb +2 -2
  36. data/spec/ollama/commands/embed_spec.rb +3 -3
  37. data/spec/ollama/commands/embeddings_spec.rb +2 -2
  38. data/spec/ollama/commands/generate_spec.rb +2 -2
  39. data/spec/ollama/commands/pull_spec.rb +2 -2
  40. data/spec/ollama/commands/push_spec.rb +2 -2
  41. data/spec/ollama/commands/show_spec.rb +2 -2
  42. data/spec/ollama/documents/memory_cache_spec.rb +63 -0
  43. data/spec/ollama/documents/redis_cache_spec.rb +78 -0
  44. data/spec/ollama/documents/splitters/character_spec.rb +96 -0
  45. data/spec/ollama/documents/splitters/semantic_spec.rb +56 -0
  46. data/spec/ollama/documents_spec.rb +119 -0
  47. data/spec/ollama/handlers/progress_spec.rb +2 -2
  48. data/spec/ollama/image_spec.rb +4 -0
  49. data/spec/ollama/message_spec.rb +3 -4
  50. data/spec/ollama/options_spec.rb +18 -0
  51. data/spec/ollama/tool_spec.rb +1 -6
  52. data/spec/ollama/utils/fetcher_spec.rb +74 -0
  53. data/spec/ollama/utils/tags_spec.rb +24 -0
  54. data/spec/spec_helper.rb +8 -0
  55. data/tmp/.keep +0 -0
  56. metadata +187 -5
data/bin/ollama_chat CHANGED
@@ -4,9 +4,80 @@ require 'ollama'
4
4
  include Ollama
5
5
  require 'term/ansicolor'
6
6
  include Term::ANSIColor
7
- require 'tins/go'
7
+ require 'tins'
8
8
  include Tins::GO
9
9
  require 'reline'
10
+ require 'reverse_markdown'
11
+ require 'complex_config'
12
+ require 'fileutils'
13
+ require 'uri'
14
+ require 'nokogiri'
15
+
16
+ class OllamaChatConfig
17
+ include ComplexConfig
18
+ include FileUtils
19
+
20
+ DEFAULT_CONFIG = <<~EOT
21
+ ---
22
+ url: <%= ENV['OLLAMA_URL'] || 'http://%s' % ENV.fetch('OLLAMA_HOST') %>
23
+ model:
24
+ name: <%= ENV.fetch('OLLAMA_CHAT_MODEL', 'llama3.1') %>
25
+ options:
26
+ num_ctx: 8192
27
+ system: <%= ENV.fetch('OLLAMA_CHAT_SYSTEM', 'null') %>
28
+ voice: Samantha
29
+ markdown: true
30
+ embedding:
31
+ enabled: true
32
+ model:
33
+ name: mxbai-embed-large
34
+ options: {}
35
+ # Retrieval prompt template:
36
+ prompt: 'Represent this sentence for searching relevant passages: %s'
37
+ collection: <%= ENV.fetch('OLLAMA_CHAT_COLLECTION', 'ollama_chat') %>
38
+ found_texts_size: 4096
39
+ splitter:
40
+ name: RecursiveCharacter
41
+ chunk_size: 1024
42
+ cache: Ollama::Documents::RedisCache
43
+ redis:
44
+ url: <%= ENV.fetch('REDIS_URL', 'null') %>
45
+ debug: <%= ENV['OLLAMA_CHAT_DEBUG'].to_i == 1 ? true : false %>
46
+ EOT
47
+
48
+ def initialize(filename = nil)
49
+ @filename = filename || default_path
50
+ @config = Provider.config(@filename)
51
+ retried = false
52
+ rescue ConfigurationFileMissing
53
+ if @filename == default_path && !retried
54
+ retried = true
55
+ mkdir_p File.dirname(default_path)
56
+ File.secure_write(default_path, DEFAULT_CONFIG)
57
+ retry
58
+ else
59
+ raise
60
+ end
61
+ end
62
+
63
+ attr_reader :filename
64
+
65
+ attr_reader :config
66
+
67
+ def default_path
68
+ File.join(config_dir_path, 'config.yml')
69
+ end
70
+
71
+ def config_dir_path
72
+ File.join(
73
+ ENV.fetch(
74
+ 'XDG_CONFIG_HOME',
75
+ File.join(ENV.fetch('HOME'), '.config')
76
+ ),
77
+ 'ollama_chat'
78
+ )
79
+ end
80
+ end
10
81
 
11
82
  class FollowChat
12
83
  include Ollama::Handlers::Concern
@@ -16,16 +87,16 @@ class FollowChat
16
87
  super(output:)
17
88
  @output.sync = true
18
89
  @markdown = markdown
19
- @say = voice ? Ollama::Handlers::Say.new(voice:) : NOP
90
+ @say = voice ? Handlers::Say.new(voice:) : NOP
20
91
  @messages = messages
21
92
  @user = nil
22
93
  end
23
94
 
24
95
  def call(response)
25
- ENV['DEBUG'].to_i == 1 and jj response
96
+ $config.debug and jj response
26
97
  if response&.message&.role == 'assistant'
27
98
  if @messages.last.role != 'assistant'
28
- @messages << Ollama::Message.new(role: 'assistant', content: '')
99
+ @messages << Message.new(role: 'assistant', content: '')
29
100
  @user = message_type(@messages.last.images) + " " +
30
101
  bold { color(111) { 'assistant:' } }
31
102
  puts @user unless @markdown
@@ -33,49 +104,75 @@ class FollowChat
33
104
  content = response.message&.content
34
105
  @messages.last.content << content
35
106
  if @markdown and @messages.last.content.present?
36
- markdown_content = Ollama::Utils::ANSIMarkdown.parse(@messages.last.content)
107
+ markdown_content = Utils::ANSIMarkdown.parse(@messages.last.content)
37
108
  @output.print clear_screen, move_home, @user, ?\n, markdown_content
38
109
  else
39
110
  @output.print content
40
111
  end
41
112
  @say.call(response)
42
113
  end
43
- response.done and @output.puts
114
+ if response.done
115
+ @output.puts
116
+ eval_stats = {
117
+ eval_duration: Tins::Duration.new(response.eval_duration / 1e9),
118
+ eval_count: response.eval_count,
119
+ prompt_eval_duration: Tins::Duration.new(response.prompt_eval_duration / 1e9),
120
+ prompt_eval_count: response.prompt_eval_count,
121
+ total_duration: Tins::Duration.new(response.total_duration / 1e9),
122
+ load_duration: Tins::Duration.new(response.load_duration / 1e9),
123
+ }.map { _1 * '=' } * ' '
124
+ @output.puts '📊 ' + color(111) { Utils::Width.wrap(eval_stats, percentage: 90) }
125
+ end
44
126
  self
45
127
  end
46
128
  end
47
129
 
48
- def pull_model_unless_present(client, model, options)
49
- retried = false
50
- begin
51
- client.show(name: model) { |response|
52
- puts green {
53
- "Model with architecture #{response.model_info['general.architecture']} found."
54
- }
55
- if options
56
- puts "Model options are:"
57
- jj options
58
- end
59
- if system = response.system
60
- puts "Configured model system prompt is:\n#{italic { system }}"
61
- return system
130
+ def search_web(query, n = 5)
131
+ query = URI.encode_uri_component(query)
132
+ url = "https://www.duckduckgo.com/html/?q=#{query}"
133
+ Ollama::Utils::Fetcher.new.get(url) do |tmp|
134
+ result = []
135
+ doc = Nokogiri::HTML(tmp)
136
+ doc.css('.results_links').each do |link|
137
+ if n > 0
138
+ url = link.css('.result__a').first&.[]('href')
139
+ url.sub!(%r(\A/l/\?uddg=), '')
140
+ url.sub!(%r(&rut=.*), '')
141
+ url = URI.decode_uri_component(url)
142
+ url = URI.parse(url)
143
+ url.host =~ /duckduckgo\.com/ and next
144
+ result << url
145
+ n -= 1
62
146
  else
63
- return
147
+ break
64
148
  end
65
- }
66
- rescue Errors::NotFoundError
67
- puts "Model #{model} not found, attempting to pull it now…"
68
- client.pull(name: model)
69
- if retried
70
- exit 1
149
+ end
150
+ result
151
+ end
152
+ end
153
+
154
+ def pull_model_unless_present(model, options, retried = false)
155
+ ollama.show(name: model) { |response|
156
+ puts "Model #{bold{model}} with architecture #{response.model_info['general.architecture']} found."
157
+ if system = response.system
158
+ puts "Configured model system prompt is:\n#{italic { system }}"
159
+ return system
71
160
  else
72
- retried = true
73
- retry
161
+ return
74
162
  end
75
- rescue Errors::Error => e
76
- warn "Caught #{e.class}: #{e} => Exiting."
163
+ }
164
+ rescue Errors::NotFoundError
165
+ puts "Model #{bold{model}} not found, attempting to pull it now…"
166
+ ollama.pull(name: model)
167
+ if retried
77
168
  exit 1
169
+ else
170
+ retried = true
171
+ retry
78
172
  end
173
+ rescue Errors::Error => e
174
+ warn "Caught #{e.class}: #{e} => Exiting."
175
+ exit 1
79
176
  end
80
177
 
81
178
  def load_conversation(filename)
@@ -84,7 +181,7 @@ def load_conversation(filename)
84
181
  return
85
182
  end
86
183
  File.open(filename, 'r') do |output|
87
- return JSON(output.read, create_additions: true)
184
+ return JSON(output.read).map { Ollama::Message.from_hash(_1) }
88
185
  end
89
186
  end
90
187
 
@@ -115,27 +212,198 @@ def list_conversation(messages, markdown)
115
212
  else 210
116
213
  end
117
214
  content = if markdown && m.content.present?
118
- Ollama::Utils::ANSIMarkdown.parse(m.content)
215
+ Utils::ANSIMarkdown.parse(m.content)
119
216
  else
120
217
  m.content
121
218
  end
122
- puts message_type(m.images) + " " +
123
- bold { color(role_color) { m.role } } + ":\n#{content}"
219
+ message_text = message_type(m.images) + " "
220
+ message_text += bold { color(role_color) { m.role } }
221
+ message_text += ":\n#{content}"
222
+ if m.images.present?
223
+ message_text += "\nImages: " + italic { m.images.map(&:path) * ', ' }
224
+ end
225
+ puts message_text
226
+ end
227
+ end
228
+
229
+ def parse_source(source_io)
230
+ case source_io&.content_type&.sub_type
231
+ when 'html'
232
+ ReverseMarkdown.convert(
233
+ source_io.read,
234
+ unknown_tags: :bypass,
235
+ github_flavored: true,
236
+ tag_border: ''
237
+ )
238
+ when 'plain', 'csv', 'xml'
239
+ source_io.read
240
+ else
241
+ STDERR.puts "Cannot import #{source_io&.content_type} document."
242
+ return
243
+ end
244
+ end
245
+
246
+ def import_document(source_io, source)
247
+ unless $config.embedding.enabled
248
+ STDOUT.puts "Embedding disabled, I won't import any documents, try: /summarize"
249
+ return
250
+ end
251
+ infobar.puts "Importing #{italic { source_io.content_type }} document #{source.to_s.inspect}."
252
+ text = parse_source(source_io) or return
253
+ text.downcase!
254
+ splitter_config = $config.embedding.splitter
255
+ inputs = case splitter_config.name
256
+ when 'Character'
257
+ Ollama::Documents::Splitters::Character.new(
258
+ chunk_size: splitter_config.chunk_size,
259
+ ).split(text)
260
+ when 'RecursiveCharacter'
261
+ Ollama::Documents::Splitters::RecursiveCharacter.new(
262
+ chunk_size: splitter_config.chunk_size,
263
+ ).split(text)
264
+ when 'Semantic'
265
+ Ollama::Documents::Splitters::Semantic.new(
266
+ ollama:, model: $config.embedding.model.name,
267
+ chunk_size: splitter_config.chunk_size,
268
+ ).split(
269
+ text,
270
+ breakpoint: splitter_config.breakpoint.to_sym,
271
+ percentage: splitter_config.percentage?,
272
+ percentile: splitter_config.percentile?,
273
+ )
274
+ end
275
+ $documents.add(inputs, source: source.to_s)
276
+ end
277
+
278
+ def add_image(images, source_io, source)
279
+ STDERR.puts "Adding #{source_io.content_type} image #{source.to_s.inspect}."
280
+ image = Image.for_io(source_io, path: source.to_s)
281
+ (images << image).uniq!
282
+ end
283
+
284
+ def fetch_source(source, &block)
285
+ case source
286
+ when %r(\Ahttps?://\S+)
287
+ Utils::Fetcher.get(source) do |tmp|
288
+ block.(tmp)
289
+ end
290
+ when %r(\Afile://(?:(?:[.-]|[[:alnum:]])*)(/\S*)|([~.]?/\S*))
291
+ filename = $~.captures.compact.first
292
+ filename = File.expand_path(filename)
293
+ Utils::Fetcher.read(filename) do |tmp|
294
+ block.(tmp)
295
+ end
296
+ else
297
+ raise "invalid source"
298
+ end
299
+ rescue => e
300
+ STDERR.puts "Cannot add source #{source.to_s.inspect}: #{e}\n#{e.backtrace * ?\n}"
301
+ end
302
+
303
+ def summarize(source)
304
+ puts "Now summarizing #{source.inspect}."
305
+ source_content =
306
+ fetch_source(source) do |source_io|
307
+ parse_source(source_io) or return
308
+ end
309
+ <<~end
310
+ # Generate an abstract summary of the content in this document:
311
+
312
+ #{source_content}
313
+ end
314
+ end
315
+
316
+ def parse_content(content, images)
317
+ images.clear
318
+ tags = Utils::Tags.new
319
+
320
+ content.scan(%r([.~]?/\S+|https?://\S+|#\S+)).each do |source|
321
+ case source
322
+ when /\A#(\S+)/
323
+ tags << $1
324
+ else
325
+ source = source.sub(/(["')]|\*+)\z/, '')
326
+ fetch_source(source) do |source_io|
327
+ case source_io&.content_type&.media_type
328
+ when 'image'
329
+ add_image(images, source_io, source)
330
+ when 'text'
331
+ import_document(source_io, source)
332
+ else
333
+ STDERR.puts(
334
+ "Cannot fetch #{source.to_s.inspect} with content type "\
335
+ "#{source_io&.content_type.inspect}"
336
+ )
337
+ end
338
+ end
339
+ end
340
+ end
341
+
342
+ return content, (tags unless tags.empty?)
343
+ end
344
+
345
+ def choose_model(cli_model, default_model)
346
+ models = ollama.tags.models.map(&:name).sort
347
+ model = if cli_model == ''
348
+ Ollama::Utils::Chooser.choose(models) || default_model
349
+ else
350
+ cli_model || default_model
351
+ end
352
+ ensure
353
+ puts green { "Connecting to #{model}@#{ollama.base_url} now…" }
354
+ end
355
+
356
+ def choose_collection(default_collection)
357
+ collections = [ default_collection ] + $documents.collections
358
+ collections = collections.uniq.sort
359
+ $documents.collection = collection =
360
+ Ollama::Utils::Chooser.choose(collections) || default_collection
361
+ ensure
362
+ puts "Changing to collection #{bold{collection}}."
363
+ collection_stats
364
+ end
365
+
366
+ def collection_stats
367
+ puts <<~end
368
+ Collection
369
+ Name: #{bold{$documents.collection}}
370
+ #Embeddings: #{$documents.size}
371
+ Tags: #{$documents.tags}
372
+ end
373
+ end
374
+
375
+ def configure_cache
376
+ Object.const_get($config.cache)
377
+ rescue => e
378
+ STDERR.puts "Caught #{e.class}: #{e} => Falling back to MemoryCache."
379
+ Ollama::Documents::MemoryCache
380
+ end
381
+
382
+ def set_markdown(value)
383
+ if value
384
+ puts "Using ANSI markdown to output content."
385
+ true
386
+ else
387
+ puts "Using plaintext for outputting content."
388
+ false
124
389
  end
125
390
  end
126
391
 
127
392
  def display_chat_help
128
393
  puts <<~end
129
- /paste to paste content
130
- /list list the messages of the conversation
131
- /clear clear the conversation messages
132
- /pop n pop the last n message, defaults to 1
133
- /regenerate the last answer message
134
- /save filename store conversation messages
135
- /load filename load conversation messages
136
- /image filename attach image to the next message
137
- /quit to quit.
138
- /help to view this help.
394
+ /paste to paste content
395
+ /markdown toggle markdown output
396
+ /list list the messages of the conversation
397
+ /clear clear the conversation messages
398
+ /pop [n] pop the last n exchanges, defaults to 1
399
+ /model change the model
400
+ /regenerate the last answer message
401
+ /collection clear|stats|change|new clear or show stats of current collection
402
+ /summarize source summarize the URL/file source's content
403
+ /save filename store conversation messages
404
+ /load filename load conversation messages
405
+ /quit to quit
406
+ /help to view this help
139
407
  end
140
408
  end
141
409
 
@@ -143,36 +411,88 @@ def usage
143
411
  puts <<~end
144
412
  #{File.basename($0)} [OPTIONS]
145
413
 
146
- -u URL the ollama base url, OLLAMA_URL
147
- -m MODEL the ollama model to chat with, OLLAMA_MODEL
148
- -M OPTIONS the model options as JSON file, see Ollama::Options
149
- -s SYSTEM the system prompt to use as a file
150
- -c CHAT a saved chat conversation to load
151
- -v VOICE use VOICE (e. g. Samantha) to speak with say command
152
- -d use markdown to display the chat messages
153
- -h this help
414
+ -f CONFIG config file to read
415
+ -u URL the ollama base url, OLLAMA_URL
416
+ -m MODEL the ollama model to chat with, OLLAMA_CHAT_MODEL
417
+ -s SYSTEM the system prompt to use as a file, OLLAMA_CHAT_SYSTEM
418
+ -c CHAT a saved chat conversation to load
419
+ -C COLLECTION name of the collection used in this conversation
420
+ -D DOCUMENT load document and add to collection (multiple)
421
+ -v use voice output
422
+ -h this help
154
423
 
155
424
  end
156
425
  exit 0
157
426
  end
158
427
 
159
- opts = go 'u:m:M:s:c:v:dh'
428
+ def ollama
429
+ $ollama
430
+ end
431
+
432
+ opts = go 'f:u:m:s:c:C:D:vh'
433
+
434
+ config = OllamaChatConfig.new(opts[?f])
435
+ $config = config.config
160
436
 
161
437
  opts[?h] and usage
162
438
 
163
- base_url = opts[?u] || ENV['OLLAMA_URL'] || 'http://%s' % ENV.fetch('OLLAMA_HOST')
164
- model = opts[?m] || ENV.fetch('OLLAMA_MODEL', 'llama3.1')
165
- options = if options_file = opts[?M]
166
- JSON(File.read(options_file), create_additions: true)
167
- end
439
+ puts "Configuration read from #{config.filename.inspect} is:"
440
+ y $config.to_h
168
441
 
169
- client = Client.new(base_url:)
442
+ base_url = opts[?u] || $config.url
443
+ $ollama = Client.new(base_url:, debug: $config.debug)
170
444
 
171
- model_system = pull_model_unless_present(client, model, options)
445
+ model = choose_model(opts[?m], $config.model.name)
446
+ options = Options[$config.model.options]
447
+ model_system = pull_model_unless_present(model, options)
448
+ messages = []
172
449
 
173
- puts green { "Connecting to #{model}@#{base_url} now…" }
450
+ if $config.embedding.enabled
451
+ embedding_model = $config.embedding.model.name
452
+ embedding_model_options = Options[$config.embedding.model.options]
453
+ pull_model_unless_present(embedding_model, embedding_model_options)
454
+ collection = opts[?C] || $config.embedding.collection
455
+ $documents = Documents.new(
456
+ ollama:,
457
+ model: $config.embedding.model.name,
458
+ model_options: $config.embedding.model.options,
459
+ collection:,
460
+ cache: configure_cache,
461
+ redis_url: $config.redis.url?,
462
+ )
463
+
464
+ document_list = opts[?D].to_a
465
+ if document_list.any?(&:empty?)
466
+ puts "Clearing collection #{bold{collection}}."
467
+ $documents.clear
468
+ document_list.reject!(&:empty?)
469
+ end
470
+ unless document_list.empty?
471
+ document_list.map! do |doc|
472
+ if doc =~ %r(\Ahttps?://)
473
+ doc
474
+ else
475
+ File.expand_path(doc)
476
+ end
477
+ end
478
+ infobar.puts "Collection #{bold{collection}}: Adding #{document_list.size} documents…"
479
+ document_list.each_slice(25) do |docs|
480
+ docs.each do |doc|
481
+ fetch_source(doc) do |doc_io|
482
+ import_document(doc_io, doc)
483
+ end
484
+ end
485
+ end
486
+ end
487
+ collection_stats
488
+ else
489
+ $documents = Documents.new(ollama:, model:)
490
+ end
174
491
 
175
- messages = []
492
+ if voice = ($config.voice if opts[?v])
493
+ puts "Using voice #{bold{voice}} to speak."
494
+ end
495
+ markdown = set_markdown($config.markdown)
176
496
 
177
497
  if opts[?c]
178
498
  messages.concat load_conversation(opts[?c])
@@ -181,7 +501,7 @@ else
181
501
  if system_prompt_file = opts[?s]
182
502
  system = File.read(system_prompt_file)
183
503
  end
184
- system ||= ENV['OLLAMA_SYSTEM']
504
+ system ||= $config.system
185
505
 
186
506
  if system
187
507
  messages << Message.new(role: 'system', content: system)
@@ -191,68 +511,128 @@ else
191
511
  end
192
512
  end
193
513
 
194
- puts "Type /help to display the chat help."
514
+ puts "\nType /help to display the chat help."
195
515
 
196
- images = nil
516
+ images = []
197
517
  loop do
198
- prompt = bold { color(172) { message_type(images) + " user" } } + bold { "> " }
199
- case content = Reline.readline(prompt, true)&.chomp
518
+ parse_content = true
519
+ input_prompt = bold { color(172) { message_type(images) + " user" } } + bold { "> " }
520
+ content = Reline.readline(input_prompt, true)&.chomp
521
+
522
+ case content
200
523
  when %r(^/paste$)
201
524
  puts bold { "Paste your content and then press C-d!" }
202
525
  content = STDIN.read
203
526
  when %r(^/quit$)
204
527
  puts "Goodbye."
205
528
  exit 0
529
+ when %r(^/markdown)
530
+ markdown = set_markdown(!markdown)
531
+ next
206
532
  when %r(^/list$)
207
- list_conversation(messages, opts[?d])
533
+ list_conversation(messages, markdown)
208
534
  next
209
535
  when %r(^/clear$)
210
536
  messages.clear
211
537
  puts "Cleared messages."
212
538
  next
213
- when %r(^/pop\s*(\d*)$)
539
+ when %r(^/collection (clear|stats|change|new)$)
540
+ case $1
541
+ when 'clear'
542
+ $documents.clear
543
+ puts "Cleared collection #{bold{collection}}."
544
+ when 'stats'
545
+ collection_stats
546
+ when 'change'
547
+ choose_collection(collection)
548
+ when 'new'
549
+ print "Enter name of the new collection: "
550
+ $documents.collection = collection = STDIN.gets.chomp
551
+ collection_stats
552
+ end
553
+ next
554
+ when %r(^/pop?(?:\s+(\d*))?$)
214
555
  n = $1.to_i.clamp(1, Float::INFINITY)
215
- messages.pop(n)
216
- puts "Popped the last #{n} messages."
556
+ r = messages.pop(2 * n)
557
+ m = r.size / 2
558
+ puts "Popped the last #{m} exchanges."
559
+ next
560
+ when %r(^/model$)
561
+ model = choose_model('', model)
217
562
  next
218
563
  when %r(^/regenerate$)
219
564
  if content = messages[-2]&.content
220
- images = messages[-2]&.images
565
+ content.gsub!(/\nConsider these chunks for your answer.*\z/, '')
221
566
  messages.pop(2)
222
567
  else
223
568
  puts "Not enough messages in this conversation."
224
569
  redo
225
570
  end
226
- when %r(^/save (.+)$)
571
+ when %r(^/summarize\s+(.+))
572
+ parse_content = false
573
+ content = summarize($1) or next
574
+ when %r(^/web\s+(?:(\d+)\s+)(.+)$)
575
+ parse_content = true
576
+ urls = search_web($2, $1.to_i)
577
+ content = <<~end
578
+ Answer the the query #{$2.inspect} using these sources:
579
+
580
+ #{urls * ?\n}
581
+ end
582
+ when %r(^/save\s+(.+)$)
227
583
  save_conversation($1, messages)
228
584
  puts "Saved conversation to #$1."
229
585
  next
230
- when %r(^/load (.+)$)
586
+ when %r(^/load\s+(.+)$)
231
587
  messages = load_conversation($1)
232
588
  puts "Loaded conversation from #$1."
233
589
  next
234
- when %r(^/image (.+)$)
235
- filename = File.expand_path($1)
236
- if File.exist?(filename)
237
- images = Image.for_filename(filename)
238
- puts "Attached image #$1 to the next message."
239
- redo
240
- else
241
- puts "Filename #$1 doesn't exist. Choose another one."
242
- next
243
- end
244
590
  when %r(^/help$)
245
591
  display_chat_help
246
592
  next
247
- when nil
593
+ when nil, ''
248
594
  puts "Type /quit to quit."
249
595
  next
250
596
  end
597
+
598
+ content, tags = if parse_content
599
+ parse_content(content, images.clear)
600
+ else
601
+ [ content, Utils::Tags.new ]
602
+ end
603
+
604
+ if $config.embedding.enabled && content
605
+ records = $documents.find(
606
+ content.downcase,
607
+ tags:,
608
+ prompt: $config.embedding.model.prompt?
609
+ )
610
+ s, found_texts_size = 0, $config.embedding.found_texts_size
611
+ records = records.take_while {
612
+ (s += _1.text.size) <= found_texts_size
613
+ }
614
+ found_texts = records.map(&:text)
615
+ unless found_texts.empty?
616
+ content += "\nConsider these chunks for your answer:\n"\
617
+ "#{found_texts.join("\n\n---\n\n")}"
618
+ end
619
+ end
620
+
251
621
  messages << Message.new(role: 'user', content:, images:)
252
- handler = FollowChat.new(messages:, markdown: opts[?d], voice: opts[?v])
253
- client.chat(model:, messages:, options:, stream: true, &handler)
254
- ENV['DEBUG'].to_i == 1 and jj messages
255
- images = nil
622
+ handler = FollowChat.new(messages:, markdown:, voice:)
623
+ ollama.chat(model:, messages:, options:, stream: true, &handler)
624
+
625
+ if records
626
+ puts records.map { |record|
627
+ link = if record.source =~ %r(\Ahttps?://)
628
+ record.source
629
+ else
630
+ 'file://%s' % File.expand_path(record.source)
631
+ end
632
+ [ link, record.tags.first ]
633
+ }.uniq.map { |l, t| hyperlink(l, t) }.join(' ')
634
+ $config.debug and jj messages
635
+ end
256
636
  rescue Interrupt
257
637
  puts "Type /quit to quit."
258
638
  end