ollama-ruby 0.0.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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