ollama-ruby 0.13.0 → 0.14.1

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.
data/bin/ollama_chat DELETED
@@ -1,1249 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require 'ollama'
4
- include Ollama
5
- include Tins::GO
6
- require 'term/ansicolor'
7
- include Term::ANSIColor
8
- require 'reline'
9
- require 'reverse_markdown'
10
- require 'complex_config'
11
- require 'fileutils'
12
- require 'uri'
13
- require 'nokogiri'
14
- require 'rss'
15
- require 'pdf/reader'
16
- require 'csv'
17
- require 'xdg'
18
- require 'documentrix'
19
-
20
- class OllamaChatConfig
21
- include ComplexConfig
22
- include FileUtils
23
-
24
- DEFAULT_CONFIG = <<~EOT
25
- ---
26
- url: <%= ENV['OLLAMA_URL'] || 'http://%s' % ENV.fetch('OLLAMA_HOST') %>
27
- proxy: null # http://localhost:8080
28
- model:
29
- name: <%= ENV.fetch('OLLAMA_CHAT_MODEL', 'llama3.1') %>
30
- options:
31
- num_ctx: 8192
32
- location:
33
- enabled: false
34
- name: Berlin
35
- decimal_degrees: [ 52.514127, 13.475211 ]
36
- units: SI (International System of Units) # or USCS (United States Customary System)
37
- prompts:
38
- embed: "This source was now embedded: %{source}"
39
- summarize: |
40
- Generate an abstract summary of the content in this document using
41
- %{words} words:
42
-
43
- %{source_content}
44
- web: |
45
- Answer the the query %{query} using these sources and summaries:
46
-
47
- %{results}
48
- system_prompts:
49
- default: <%= ENV.fetch('OLLAMA_CHAT_SYSTEM', 'null') %>
50
- voice:
51
- enabled: false
52
- default: Samantha
53
- list: <%= `say -v ? 2>/dev/null`.lines.map { _1[/^(.+?)\s+[a-z]{2}_[a-zA-Z0-9]{2,}/, 1] }.uniq.sort.to_s.force_encoding('ASCII-8BIT') %>
54
- markdown: true
55
- stream: true
56
- document_policy: importing
57
- embedding:
58
- enabled: true
59
- model:
60
- name: mxbai-embed-large
61
- embedding_length: 1024
62
- options: {}
63
- # Retrieval prompt template:
64
- prompt: 'Represent this sentence for searching relevant passages: %s'
65
- batch_size: 10
66
- database_filename: null # ':memory:'
67
- collection: <%= ENV['OLLAMA_CHAT_COLLECTION'] %>
68
- found_texts_size: 4096
69
- found_texts_count: 10
70
- splitter:
71
- name: RecursiveCharacter
72
- chunk_size: 1024
73
- cache: Ollama::Documents::SQLiteCache
74
- redis:
75
- documents:
76
- url: <%= ENV.fetch('REDIS_URL', 'null') %>
77
- expiring:
78
- url: <%= ENV.fetch('REDIS_EXPIRING_URL', 'null') %>
79
- ex: 86400
80
- debug: <%= ENV['OLLAMA_CHAT_DEBUG'].to_i == 1 ? true : false %>
81
- ssl_no_verify: []
82
- copy: pbcopy
83
- EOT
84
-
85
- def initialize(filename = nil)
86
- @filename = filename || default_path
87
- unless File.directory?(cache_dir_path)
88
- mkdir_p cache_dir_path.to_s
89
- end
90
- @config = Provider.config(@filename, '⚙️')
91
- retried = false
92
- rescue ConfigurationFileMissing
93
- if @filename == default_path && !retried
94
- retried = true
95
- mkdir_p config_dir_path.to_s
96
- File.secure_write(default_path, DEFAULT_CONFIG)
97
- retry
98
- else
99
- raise
100
- end
101
- end
102
-
103
- attr_reader :filename
104
-
105
- attr_reader :config
106
-
107
- def default_path
108
- config_dir_path + 'config.yml'
109
- end
110
-
111
- def config_dir_path
112
- XDG.new.config_home + 'ollama_chat'
113
- end
114
-
115
- def cache_dir_path
116
- XDG.new.cache_home + 'ollama_chat'
117
- end
118
-
119
- def database_path
120
- cache_dir_path + 'documents.db'
121
- end
122
- end
123
-
124
- class FollowChat
125
- include Handlers::Concern
126
- include Term::ANSIColor
127
-
128
- def initialize(messages:, markdown: false, voice: nil, output: $stdout)
129
- super(output:)
130
- @output.sync = true
131
- @markdown = markdown
132
- @say = voice ? Handlers::Say.new(voice:) : NOP
133
- @messages = messages
134
- @user = nil
135
- end
136
-
137
- def call(response)
138
- $config.debug and jj response
139
- if response&.message&.role == 'assistant'
140
- if @messages.last.role != 'assistant'
141
- @messages << Message.new(role: 'assistant', content: '')
142
- @user = message_type(@messages.last.images) + " " +
143
- bold { color(111) { 'assistant:' } }
144
- puts @user unless @markdown
145
- end
146
- content = response.message&.content
147
- @messages.last.content << content
148
- if @markdown and content = @messages.last.content.full?
149
- markdown_content = Kramdown::ANSI.parse(content)
150
- @output.print clear_screen, move_home, @user, ?\n, markdown_content
151
- else
152
- @output.print content
153
- end
154
- @say.call(response)
155
- end
156
- if response.done
157
- @output.puts "", eval_stats(response)
158
- end
159
- self
160
- end
161
-
162
- def eval_stats(response)
163
- eval_duration = response.eval_duration / 1e9
164
- prompt_eval_duration = response.prompt_eval_duration / 1e9
165
- stats_text = {
166
- eval_duration: Tins::Duration.new(eval_duration),
167
- eval_count: response.eval_count.to_i,
168
- eval_rate: bold { "%.2f c/s" % (response.eval_count.to_i / eval_duration) } + color(111),
169
- prompt_eval_duration: Tins::Duration.new(prompt_eval_duration),
170
- prompt_eval_count: response.prompt_eval_count.to_i,
171
- prompt_eval_rate: bold { "%.2f c/s" % (response.prompt_eval_count.to_i / prompt_eval_duration) } + color(111),
172
- total_duration: Tins::Duration.new(response.total_duration / 1e9),
173
- load_duration: Tins::Duration.new(response.load_duration / 1e9),
174
- }.map { _1 * '=' } * ' '
175
- '📊 ' + color(111) {
176
- Kramdown::ANSI::Width.wrap(stats_text, percentage: 90).gsub(/(?<!\A)^/, ' ')
177
- }
178
- end
179
- end
180
-
181
- module Switches
182
- module CheckSwitch
183
- extend Tins::Concern
184
-
185
- included do
186
- alias_method :on?, :value
187
- end
188
-
189
- def off?
190
- !on?
191
- end
192
-
193
- def show
194
- puts @msg[value]
195
- end
196
- end
197
-
198
- class Switch
199
- def initialize(name, msg:, config: $config)
200
- @value = [ false, true ].include?(config) ? config : !!config.send("#{name}?")
201
- @msg = msg
202
- end
203
-
204
- attr_reader :value
205
-
206
- def set(value, show: false)
207
- @value = !!value
208
- show && self.show
209
- end
210
-
211
- def toggle(show: true)
212
- @value = !@value
213
- show && self.show
214
- end
215
-
216
- include CheckSwitch
217
- end
218
-
219
- class CombinedSwitch
220
- def initialize(value:, msg:)
221
- @value = value
222
- @msg = msg
223
- end
224
-
225
- def value
226
- @value.()
227
- end
228
-
229
- include CheckSwitch
230
- end
231
-
232
- def setup_switches
233
- $markdown = Switch.new(
234
- :markdown,
235
- msg: {
236
- true => "Using #{italic{'ANSI'}} markdown to output content.",
237
- false => "Using plaintext for outputting content.",
238
- }
239
- )
240
-
241
- $stream = Switch.new(
242
- :stream,
243
- msg: {
244
- true => "Streaming enabled.",
245
- false => "Streaming disabled.",
246
- }
247
- )
248
-
249
- $voice = Switch.new(
250
- :stream,
251
- msg: {
252
- true => "Voice output enabled.",
253
- false => "Voice output disabled.",
254
- },
255
- config: $config.voice
256
- )
257
-
258
- $embedding_enabled = Switch.new(
259
- :embedding_enabled,
260
- msg: {
261
- true => "Embedding enabled.",
262
- false => "Embedding disabled.",
263
- }
264
- )
265
-
266
- $embedding_paused = Switch.new(
267
- :embedding_paused,
268
- msg: {
269
- true => "Embedding paused.",
270
- false => "Embedding resumed.",
271
- }
272
- )
273
-
274
- $embedding = CombinedSwitch.new(
275
- value: -> { $embedding_enabled.on? && $embedding_paused.off? },
276
- msg: {
277
- true => "Embedding is currently performed.",
278
- false => "Embedding is currently not performed.",
279
- }
280
- )
281
-
282
- $location = Switch.new(
283
- :location,
284
- msg: {
285
- true => "Location and localtime enabled.",
286
- false => "Location and localtime disabled.",
287
- },
288
- config: $config.location.enabled
289
- )
290
- end
291
- end
292
- include Switches
293
-
294
- def pull_model_unless_present(model, options, retried = false)
295
- ollama.show(name: model) { |response|
296
- puts "Model #{bold{model}} with architecture "\
297
- "#{response.model_info['general.architecture']} found."
298
- if system = response.system
299
- puts "Configured model system prompt is:\n#{italic { system }}"
300
- return system
301
- else
302
- return
303
- end
304
- }
305
- rescue Errors::NotFoundError
306
- puts "Model #{bold{model}} not found locally, attempting to pull it from remote now…"
307
- ollama.pull(name: model)
308
- if retried
309
- exit 1
310
- else
311
- retried = true
312
- retry
313
- end
314
- rescue Errors::Error => e
315
- warn "Caught #{e.class} while pulling model: #{e} => Exiting."
316
- exit 1
317
- end
318
-
319
- def search_web(query, n = nil)
320
- if l = at_location
321
- query += " #{at_location}"
322
- end
323
- n = n.to_i
324
- n < 1 and n = 1
325
- query = URI.encode_uri_component(query)
326
- url = "https://www.duckduckgo.com/html/?q=#{query}"
327
- Ollama::Utils::Fetcher.get(url, debug: $config.debug) do |tmp|
328
- result = []
329
- doc = Nokogiri::HTML(tmp)
330
- doc.css('.results_links').each do |link|
331
- if n > 0
332
- url = link.css('.result__a').first&.[]('href')
333
- url.sub!(%r(\A(//duckduckgo\.com)?/l/\?uddg=), '')
334
- url.sub!(%r(&rut=.*), '')
335
- url = URI.decode_uri_component(url)
336
- url = URI.parse(url)
337
- url.host =~ /duckduckgo\.com/ and next
338
- $links.add(url.to_s)
339
- result << url
340
- n -= 1
341
- else
342
- break
343
- end
344
- end
345
- result
346
- end
347
- end
348
-
349
- def load_conversation(filename)
350
- unless File.exist?(filename)
351
- puts "File #{filename} doesn't exist. Choose another filename."
352
- return
353
- end
354
- File.open(filename, 'r') do |output|
355
- return JSON(output.read).map { Message.from_hash(_1) }
356
- end
357
- end
358
-
359
- def save_conversation(filename, messages)
360
- if File.exist?(filename)
361
- puts "File #{filename} already exists. Choose another filename."
362
- return
363
- end
364
- File.open(filename, 'w') do |output|
365
- output.puts JSON(messages)
366
- end
367
- end
368
-
369
- def message_type(images)
370
- images.present? ? ?📸 : ?📨
371
- end
372
-
373
- def list_conversation(messages, last = nil)
374
- last = (last || messages.size).clamp(0, messages.size)
375
- messages[-last..-1].to_a.each do |m|
376
- role_color = case m.role
377
- when 'user' then 172
378
- when 'assistant' then 111
379
- when 'system' then 213
380
- else 210
381
- end
382
- content = m.content.full? { $markdown.on? ? Kramdown::ANSI.parse(_1) : _1 }
383
- message_text = message_type(m.images) + " "
384
- message_text += bold { color(role_color) { m.role } }
385
- message_text += ":\n#{content}"
386
- m.images.full? { |images|
387
- message_text += "\nImages: " + italic { images.map(&:path) * ', ' }
388
- }
389
- puts message_text
390
- end
391
- end
392
-
393
- module SourceParsing
394
- def parse_source(source_io)
395
- case source_io&.content_type
396
- when 'text/html'
397
- reverse_markdown(source_io.read)
398
- when 'text/xml'
399
- if source_io.readline =~ %r(^\s*<rss\s)
400
- source_io.rewind
401
- return parse_rss(source_io)
402
- end
403
- source_io.rewind
404
- source_io.read
405
- when 'text/csv'
406
- parse_csv(source_io)
407
- when 'application/rss+xml'
408
- parse_rss(source_io)
409
- when 'application/atom+xml'
410
- parse_atom(source_io)
411
- when 'application/postscript'
412
- ps_read(source_io)
413
- when 'application/pdf'
414
- pdf_read(source_io)
415
- when %r(\Aapplication/(json|ld\+json|x-ruby|x-perl|x-gawk|x-python|x-javascript|x-c?sh|x-dosexec|x-shellscript|x-tex|x-latex|x-lyx|x-bibtex)), %r(\Atext/), nil
416
- source_io.read
417
- else
418
- STDERR.puts "Cannot embed #{source_io&.content_type} document."
419
- return
420
- end
421
- end
422
-
423
- def parse_csv(source_io)
424
- result = +''
425
- CSV.table(File.new(source_io), col_sep: ?,).each do |row|
426
- next if row.fields.select(&:present?).size == 0
427
- result << row.map { |pair|
428
- pair.compact.map { _1.to_s.strip } * ': ' if pair.last.present?
429
- }.select(&:present?).map { _1.prepend(' ') } * ?\n
430
- result << "\n\n"
431
- end
432
- result
433
- end
434
-
435
- def parse_rss(source_io)
436
- feed = RSS::Parser.parse(source_io, false, false)
437
- title = <<~EOT
438
- # #{feed&.channel&.title}
439
-
440
- EOT
441
- feed.items.inject(title) do |text, item|
442
- text << <<~EOT
443
- ## [#{item&.title}](#{item&.link})
444
-
445
- updated on #{item&.pubDate}
446
-
447
- #{reverse_markdown(item&.description)}
448
-
449
- EOT
450
- end
451
- end
452
-
453
- def parse_atom(source_io)
454
- feed = RSS::Parser.parse(source_io, false, false)
455
- title = <<~EOT
456
- # #{feed.title.content}
457
-
458
- EOT
459
- feed.items.inject(title) do |text, item|
460
- text << <<~EOT
461
- ## [#{item&.title&.content}](#{item&.link&.href})
462
-
463
- updated on #{item&.updated&.content}
464
-
465
- #{reverse_markdown(item&.content&.content)}
466
-
467
- EOT
468
- end
469
- end
470
-
471
- def pdf_read(io)
472
- reader = PDF::Reader.new(io)
473
- reader.pages.inject(+'') { |result, page| result << page.text }
474
- end
475
-
476
- def ps_read(io)
477
- gs = `which gs`.chomp
478
- if gs.present?
479
- Tempfile.create do |tmp|
480
- IO.popen("#{gs} -q -sDEVICE=pdfwrite -sOutputFile=#{tmp.path} -", 'wb') do |gs_io|
481
- until io.eof?
482
- buffer = io.read(1 << 17)
483
- IO.select(nil, [ gs_io ], nil)
484
- gs_io.write buffer
485
- end
486
- gs_io.close
487
- File.open(tmp.path, 'rb') do |pdf|
488
- pdf_read(pdf)
489
- end
490
- end
491
- end
492
- else
493
- STDERR.puts "Cannot convert #{io&.content_type} whith ghostscript, gs not in path."
494
- end
495
- end
496
-
497
- def reverse_markdown(html)
498
- ReverseMarkdown.convert(
499
- html,
500
- unknown_tags: :bypass,
501
- github_flavored: true,
502
- tag_border: ''
503
- )
504
- end
505
- end
506
- include SourceParsing
507
-
508
- def http_options(url)
509
- options = {}
510
- if ssl_no_verify = $config.ssl_no_verify?
511
- hostname = URI.parse(url).hostname
512
- options |= { ssl_verify_peer: !ssl_no_verify.include?(hostname) }
513
- end
514
- if proxy = $config.proxy?
515
- options |= { proxy: }
516
- end
517
- options
518
- end
519
-
520
- def fetch_source(source, &block)
521
- case source
522
- when %r(\A!(.*))
523
- command = $1
524
- Ollama::Utils::Fetcher.execute(command) do |tmp|
525
- block.(tmp)
526
- end
527
- when %r(\Ahttps?://\S+)
528
- $links.add(source.to_s)
529
- Utils::Fetcher.get(
530
- source,
531
- cache: $cache,
532
- debug: $config.debug,
533
- http_options: http_options(Utils::Fetcher.normalize_url(source))
534
- ) do |tmp|
535
- block.(tmp)
536
- end
537
- when %r(\Afile://(/\S*)|\A((?:\.\.|[~.]?)/\S*))
538
- filename = $~.captures.compact.first
539
- filename = File.expand_path(filename)
540
- Utils::Fetcher.read(filename) do |tmp|
541
- block.(tmp)
542
- end
543
- else
544
- raise "invalid source"
545
- end
546
- rescue => e
547
- STDERR.puts "Cannot fetch source #{source.to_s.inspect}: #{e.class} #{e}\n#{e.backtrace * ?\n}"
548
- end
549
-
550
- def add_image(images, source_io, source)
551
- STDERR.puts "Adding #{source_io&.content_type} image #{source.to_s.inspect}."
552
- image = Image.for_io(source_io, path: source.to_s)
553
- (images << image).uniq!
554
- end
555
-
556
- def import_source(source_io, source)
557
- source = source.to_s
558
- puts "Importing #{italic { source_io&.content_type }} document #{source.to_s.inspect} now."
559
- source_content = parse_source(source_io)
560
- "Imported #{source.inspect}:\n#{source_content}\n\n"
561
- end
562
-
563
- def import(source)
564
- fetch_source(source) do |source_io|
565
- content = import_source(source_io, source) or return
566
- source_io.rewind
567
- content
568
- end
569
- end
570
-
571
- def summarize_source(source_io, source, words: nil)
572
- puts "Summarizing #{italic { source_io&.content_type }} document #{source.to_s.inspect} now."
573
- words = words.to_i
574
- words < 1 and words = 100
575
- source_content = parse_source(source_io)
576
- source_content.present? or return
577
- $config.prompts.summarize % { source_content:, words: }
578
- end
579
-
580
- def summarize(source, words: nil)
581
- fetch_source(source) do |source_io|
582
- content = summarize_source(source_io, source, words:) or return
583
- source_io.rewind
584
- content
585
- end
586
- end
587
-
588
- def embed_source(source_io, source, count: nil)
589
- $embedding.on? or return parse_source(source_io)
590
- m = "Embedding #{italic { source_io&.content_type }} document #{source.to_s.inspect}."
591
- if count
592
- puts '%u. %s' % [ count, m ]
593
- else
594
- puts m
595
- end
596
- text = parse_source(source_io) or return
597
- text.downcase!
598
- splitter_config = $config.embedding.splitter
599
- inputs = nil
600
- case splitter_config.name
601
- when 'Character'
602
- splitter = Documentrix::Documents::Splitters::Character.new(
603
- chunk_size: splitter_config.chunk_size,
604
- )
605
- inputs = splitter.split(text)
606
- when 'RecursiveCharacter'
607
- splitter = Documentrix::Documents::Splitters::RecursiveCharacter.new(
608
- chunk_size: splitter_config.chunk_size,
609
- )
610
- inputs = splitter.split(text)
611
- when 'Semantic'
612
- splitter = Documentrix::Documents::Splitters::Semantic.new(
613
- ollama:, model: $config.embedding.model.name,
614
- chunk_size: splitter_config.chunk_size,
615
- )
616
- inputs = splitter.split(
617
- text,
618
- breakpoint: splitter_config.breakpoint.to_sym,
619
- percentage: splitter_config.percentage?,
620
- percentile: splitter_config.percentile?,
621
- )
622
- end
623
- inputs or return
624
- source = source.to_s
625
- if source.start_with?(?!)
626
- source = Kramdown::ANSI::Width.truncate(
627
- source[1..-1].gsub(/\W+/, ?_),
628
- length: 10
629
- )
630
- end
631
- $documents.add(inputs, source:, batch_size: $config.embedding.batch_size?)
632
- end
633
-
634
- def embed(source)
635
- if $embedding.on?
636
- puts "Now embedding #{source.to_s.inspect}."
637
- fetch_source(source) do |source_io|
638
- content = parse_source(source_io)
639
- content.present? or return
640
- source_io.rewind
641
- embed_source(source_io, source)
642
- end
643
- $config.prompts.embed % { source: }
644
- else
645
- puts "Embedding is off, so I will just give a small summary of this source."
646
- summarize(source)
647
- end
648
- end
649
-
650
- def parse_content(content, images)
651
- images.clear
652
- tags = Documentrix::Utils::Tags.new
653
-
654
- contents = [ content ]
655
- content.scan(%r((https?://\S+)|(#\S+)|(?:file://)?(\S*\/\S+))).each do |url, tag, file|
656
- case
657
- when tag
658
- tags.add(tag)
659
- next
660
- when file
661
- file = file.sub(/#.*/, '')
662
- file =~ %r(\A[~./]) or file.prepend('./')
663
- File.exist?(file) or next
664
- source = file
665
- when url
666
- $links.add(url.to_s)
667
- source = url
668
- end
669
- fetch_source(source) do |source_io|
670
- case source_io&.content_type&.media_type
671
- when 'image'
672
- add_image(images, source_io, source)
673
- when 'text', 'application', nil
674
- case $document_policy
675
- when 'ignoring'
676
- nil
677
- when 'importing'
678
- contents << import_source(source_io, source)
679
- when 'embedding'
680
- embed_source(source_io, source)
681
- when 'summarizing'
682
- contents << summarize_source(source_io, source)
683
- end
684
- else
685
- STDERR.puts(
686
- "Cannot fetch #{source.to_s.inspect} with content type "\
687
- "#{source_io&.content_type.inspect}"
688
- )
689
- end
690
- end
691
- end
692
- new_content = contents.select { _1.present? rescue nil }.compact * "\n\n"
693
- return new_content, (tags unless tags.empty?)
694
- end
695
-
696
- def choose_model(cli_model, current_model)
697
- models = ollama.tags.models.map(&:name).sort
698
- model = if cli_model == ''
699
- Ollama::Utils::Chooser.choose(models) || current_model
700
- else
701
- cli_model || current_model
702
- end
703
- ensure
704
- puts green { "Connecting to #{model}@#{ollama.base_url} now…" }
705
- end
706
-
707
- def ask?(prompt:)
708
- print prompt
709
- STDIN.gets.chomp
710
- end
711
-
712
- def choose_collection(current_collection)
713
- collections = [ current_collection ] + $documents.collections
714
- collections = collections.compact.map(&:to_s).uniq.sort
715
- collections.unshift('[EXIT]').unshift('[NEW]')
716
- collection = Ollama::Utils::Chooser.choose(collections) || current_collection
717
- case collection
718
- when '[NEW]'
719
- $documents.collection = ask?(prompt: "Enter name of the new collection: ")
720
- when nil, '[EXIT]'
721
- puts "Exiting chooser."
722
- when /./
723
- $documents.collection = collection
724
- end
725
- ensure
726
- puts "Using collection #{bold{$documents.collection}}."
727
- info
728
- end
729
-
730
- def choose_document_policy
731
- policies = %w[ importing embedding summarizing ignoring ].sort
732
- current = if policies.index($document_policy)
733
- $document_policy
734
- elsif policies.index($config.document_policy)
735
- $config.document_policy
736
- else
737
- policies.first
738
- end
739
- policies.unshift('[EXIT]')
740
- policy = Ollama::Utils::Chooser.choose(policies)
741
- case policy
742
- when nil, '[EXIT]'
743
- puts "Exiting chooser."
744
- policy = current
745
- end
746
- $document_policy = policy
747
- ensure
748
- puts "Using document policy #{bold{$document_policy}}."
749
- info
750
- end
751
-
752
- def collection_stats
753
- puts <<~EOT
754
- Current Collection
755
- Name: #{bold{$documents.collection}}
756
- #Embeddings: #{$documents.size}
757
- #Tags: #{$documents.tags.size}
758
- Tags: #{$documents.tags}
759
- EOT
760
- end
761
-
762
- def configure_cache
763
- if $opts[?M]
764
- Documentrix::Documents::MemoryCache
765
- else
766
- Object.const_get($config.cache)
767
- end
768
- rescue => e
769
- STDERR.puts "Caught #{e.class}: #{e} => Falling back to MemoryCache."
770
- Documentrix::Documents::MemoryCache
771
- end
772
-
773
- def show_system_prompt
774
- puts <<~EOT
775
- Configured system prompt is:
776
- #{Kramdown::ANSI.parse($system.to_s).gsub(/\n+\z/, '').full? || 'n/a'}
777
- EOT
778
- end
779
-
780
- def at_location
781
- if $location.on?
782
- location_name = $config.location.name
783
- location_decimal_degrees = $config.location.decimal_degrees * ', '
784
- localtime = Time.now.iso8601
785
- units = $config.location.units
786
- $config.prompts.location % {
787
- location_name:, location_decimal_degrees:, localtime:, units:,
788
- }
789
- end.to_s
790
- end
791
-
792
- def set_system_prompt(messages, system)
793
- $system = system
794
- messages.clear
795
- messages << Message.new(role: 'system', content: system)
796
- end
797
-
798
- def change_system_prompt(messages, default, system: nil)
799
- selector = Regexp.new(system.to_s[1..-1].to_s)
800
- prompts = $config.system_prompts.attribute_names.compact.grep(selector)
801
- chosen = Ollama::Utils::Chooser.choose(prompts, return_immediately: true)
802
- system = if chosen
803
- $config.system_prompts.send(chosen)
804
- else
805
- default
806
- end
807
- set_system_prompt(messages, system)
808
- end
809
-
810
- def change_voice
811
- chosen = Ollama::Utils::Chooser.choose($config.voice.list)
812
- $current_voice = chosen.full? || $config.voice.default
813
- end
814
-
815
- def info
816
- puts "Current model is #{bold{$model}}."
817
- if $model_options.present?
818
- puts " Options: #{JSON.pretty_generate($model_options).gsub(/(?<!\A)^/, ' ')}"
819
- end
820
- $embedding.show
821
- if $embedding.on?
822
- puts "Embedding model is #{bold{$embedding_model}}"
823
- if $embedding_model_options.present?
824
- puts " Options: #{JSON.pretty_generate($embedding_model_options).gsub(/(?<!\A)^/, ' ')}"
825
- end
826
- puts "Text splitter is #{bold{$config.embedding.splitter.name}}."
827
- collection_stats
828
- end
829
- puts "Documents database cache is #{$documents.nil? ? 'n/a' : bold{$documents.cache.class}}"
830
- $markdown.show
831
- $stream.show
832
- $location.show
833
- puts "Document policy for references in user text: #{bold{$document_policy}}"
834
- if $voice.on?
835
- puts "Using voice #{bold{$current_voice}} to speak."
836
- end
837
- show_system_prompt
838
- end
839
-
840
- def clear_messages(messages)
841
- messages.delete_if { _1.role != 'system' }
842
- end
843
-
844
- def copy_to_clipboard(messages)
845
- if message = messages.last and message.role == 'assistant'
846
- copy = `which #{$config.copy}`.chomp
847
- if copy.present?
848
- IO.popen(copy, 'w') do |clipboard|
849
- clipboard.write(message.content)
850
- end
851
- STDOUT.puts "The last response has been copied to the system clipboard."
852
- else
853
- STDERR.puts "#{$config.copy.inspect} command not found in system's path!"
854
- end
855
- else
856
- STDERR.puts "No response available to copy to the system clipboard."
857
- end
858
- end
859
-
860
- def display_chat_help
861
- puts <<~EOT
862
- /copy to copy last response to clipboard
863
- /paste to paste content
864
- /markdown toggle markdown output
865
- /stream toggle stream output
866
- /location toggle location submission
867
- /voice( change) toggle voice output or change the voice
868
- /list [n] list the last n / all conversation exchanges
869
- /clear clear the whole conversation
870
- /clobber clear the conversation and collection
871
- /pop [n] pop the last n exchanges, defaults to 1
872
- /model change the model
873
- /system change system prompt (clears conversation)
874
- /regenerate the last answer message
875
- /collection( clear|change) change (default) collection or clear
876
- /info show information for current session
877
- /document_policy pick a scan policy for document references
878
- /import source import the source's content
879
- /summarize [n] source summarize the source's content in n words
880
- /embedding toggle embedding paused or not
881
- /embed source embed the source's content
882
- /web [n] query query web search & return n or 1 results
883
- /links( clear) display (or clear) links used in the chat
884
- /save filename store conversation messages
885
- /load filename load conversation messages
886
- /quit to quit
887
- /help to view this help
888
- EOT
889
- end
890
-
891
- def usage
892
- puts <<~EOT
893
- Usage: #{File.basename($0)} [OPTIONS]
894
-
895
- -f CONFIG config file to read
896
- -u URL the ollama base url, OLLAMA_URL
897
- -m MODEL the ollama model to chat with, OLLAMA_CHAT_MODEL
898
- -s SYSTEM the system prompt to use as a file, OLLAMA_CHAT_SYSTEM
899
- -c CHAT a saved chat conversation to load
900
- -C COLLECTION name of the collection used in this conversation
901
- -D DOCUMENT load document and add to embeddings collection (multiple)
902
- -M use (empty) MemoryCache for this chat session
903
- -E disable embeddings for this chat session
904
- -V display the current version number and quit
905
- -h this help
906
-
907
- EOT
908
- exit 0
909
- end
910
-
911
- def version
912
- puts "%s %s" % [ File.basename($0), Ollama::VERSION ]
913
- exit 0
914
- end
915
-
916
- def ollama
917
- $ollama
918
- end
919
-
920
- $opts = go 'f:u:m:s:c:C:D:MEVh'
921
-
922
- $ollama_chat_config = OllamaChatConfig.new($opts[?f])
923
- $config = $ollama_chat_config.config
924
-
925
- setup_switches
926
-
927
- $opts[?h] and usage
928
- $opts[?V] and version
929
-
930
- base_url = $opts[?u] || $config.url
931
- user_agent = [ File.basename($0), Ollama::VERSION ] * ?/
932
- $ollama = Client.new(base_url:, debug: $config.debug, user_agent:)
933
-
934
- $document_policy = $config.document_policy
935
- $model = choose_model($opts[?m], $config.model.name)
936
- $model_options = Options[$config.model.options]
937
- model_system = pull_model_unless_present($model, $model_options)
938
- messages = []
939
- $embedding_enabled.set($config.embedding.enabled && !$opts[?E])
940
-
941
- if $opts[?c]
942
- messages.concat load_conversation($opts[?c])
943
- else
944
- default = $config.system_prompts.default? || model_system
945
- if $opts[?s] =~ /\A\?/
946
- change_system_prompt(messages, default, system: $opts[?s])
947
- else
948
- system = Ollama::Utils::FileArgument.get_file_argument($opts[?s], default:)
949
- system.present? and set_system_prompt(messages, system)
950
- end
951
- end
952
-
953
- if $embedding.on?
954
- $embedding_model = $config.embedding.model.name
955
- $embedding_model_options = Options[$config.embedding.model.options]
956
- pull_model_unless_present($embedding_model, $embedding_model_options)
957
- collection = $opts[?C] || $config.embedding.collection
958
- $documents = Documentrix::Documents.new(
959
- ollama:,
960
- model: $embedding_model,
961
- model_options: $config.embedding.model.options,
962
- database_filename: $config.embedding.database_filename || $ollama_chat_config.database_path,
963
- collection: ,
964
- cache: configure_cache,
965
- redis_url: $config.redis.documents.url?,
966
- debug: $config.debug
967
- )
968
-
969
- document_list = $opts[?D].to_a
970
- if document_list.any?(&:empty?)
971
- puts "Clearing collection #{bold{collection}}."
972
- $documents.clear
973
- document_list.reject!(&:empty?)
974
- end
975
- unless document_list.empty?
976
- document_list.map! do |doc|
977
- if doc =~ %r(\Ahttps?://)
978
- doc
979
- else
980
- File.expand_path(doc)
981
- end
982
- end
983
- puts "Collection #{bold{collection}}: Adding #{document_list.size} documents…"
984
- count = 1
985
- document_list.each_slice(25) do |docs|
986
- docs.each do |doc|
987
- fetch_source(doc) do |doc_io|
988
- embed_source(doc_io, doc, count:)
989
- end
990
- count += 1
991
- end
992
- end
993
- end
994
- else
995
- $documents = Tins::NULL
996
- end
997
-
998
- if redis_expiring_url = $config.redis.expiring.url?
999
- $cache = Documentrix::Documents::RedisCache.new(
1000
- prefix: 'Expiring-',
1001
- url: redis_expiring_url,
1002
- ex: $config.redis.expiring.ex,
1003
- )
1004
- end
1005
-
1006
- $current_voice = $config.voice.default
1007
-
1008
- puts "Configuration read from #{$ollama_chat_config.filename.inspect} is:", $config
1009
- info
1010
- puts "\nType /help to display the chat help."
1011
-
1012
- $links = Set.new
1013
- images = []
1014
- loop do
1015
- parse_content = true
1016
- input_prompt = bold { color(172) { message_type(images) + " user" } } + bold { "> " }
1017
- content = Reline.readline(input_prompt, true)&.chomp
1018
-
1019
- case content
1020
- when %r(^/copy$)
1021
- copy_to_clipboard(messages)
1022
- next
1023
- when %r(^/paste$)
1024
- puts bold { "Paste your content and then press C-d!" }
1025
- content = STDIN.read
1026
- when %r(^/markdown$)
1027
- $markdown.toggle
1028
- next
1029
- when %r(^/stream$)
1030
- $stream.toggle
1031
- next
1032
- when %r(^/location$)
1033
- $location.toggle
1034
- next
1035
- when %r(^/voice(?:\s+(change))?$)
1036
- if $1 == 'change'
1037
- change_voice
1038
- else
1039
- $voice.toggle
1040
- end
1041
- next
1042
- when %r(^/list(?:\s+(\d*))?$)
1043
- last = if $1
1044
- 2 * $1.to_i
1045
- end
1046
- list_conversation(messages, last)
1047
- next
1048
- when %r(^/clear$)
1049
- clear_messages(messages)
1050
- puts "Cleared messages."
1051
- next
1052
- when %r(^/clobber$)
1053
- if ask?(prompt: 'Are you sure to clear messages and collection? (y/n) ') =~ /\Ay/i
1054
- clear_messages(messages)
1055
- $documents.clear
1056
- puts "Cleared messages and collection #{bold{$documents.collection}}."
1057
- else
1058
- puts 'Cancelled.'
1059
- end
1060
- next
1061
- when %r(^/pop(?:\s+(\d*))?$)
1062
- if messages.size > 1
1063
- n = $1.to_i.clamp(1, Float::INFINITY)
1064
- r = messages.pop(2 * n)
1065
- m = r.size / 2
1066
- puts "Popped the last #{m} exchanges."
1067
- else
1068
- puts "No more exchanges you can pop."
1069
- end
1070
- list_conversation(messages, 2)
1071
- next
1072
- when %r(^/model$)
1073
- $model = choose_model('', $model)
1074
- next
1075
- when %r(^/system$)
1076
- change_system_prompt(messages, $system)
1077
- info
1078
- next
1079
- when %r(^/regenerate$)
1080
- if content = messages[-2]&.content
1081
- content.gsub!(/\nConsider these chunks for your answer.*\z/, '')
1082
- messages.pop(2)
1083
- else
1084
- puts "Not enough messages in this conversation."
1085
- redo
1086
- end
1087
- parse_content = false
1088
- content
1089
- when %r(^/collection(?:\s+(clear|change))?$)
1090
- case $1 || 'change'
1091
- when 'clear'
1092
- loop do
1093
- tags = $documents.tags.add('[EXIT]').add('[ALL]')
1094
- tag = Utils::Chooser.choose(tags, prompt: 'Clear? %s')
1095
- case tag
1096
- when nil, '[EXIT]'
1097
- puts "Exiting chooser."
1098
- break
1099
- when '[ALL]'
1100
- if ask?(prompt: 'Are you sure? (y/n) ') =~ /\Ay/i
1101
- $documents.clear
1102
- puts "Cleared collection #{bold{$documents.collection}}."
1103
- break
1104
- else
1105
- puts 'Cancelled.'
1106
- sleep 3
1107
- end
1108
- when /./
1109
- $documents.clear(tags: [ tag ])
1110
- puts "Cleared tag #{tag} from collection #{bold{$documents.collection}}."
1111
- sleep 3
1112
- end
1113
- end
1114
- when 'change'
1115
- choose_collection($documents.collection)
1116
- end
1117
- next
1118
- when %r(^/info$)
1119
- info
1120
- next
1121
- when %r(^/document_policy$)
1122
- choose_document_policy
1123
- next
1124
- when %r(^/import\s+(.+))
1125
- parse_content = false
1126
- content = import($1) or next
1127
- when %r(^/summarize\s+(?:(\d+)\s+)?(.+))
1128
- parse_content = false
1129
- content = summarize($2, words: $1) or next
1130
- when %r(^/embedding$)
1131
- $embedding_paused.toggle(show: false)
1132
- $embedding.show
1133
- next
1134
- when %r(^/embed\s+(.+))
1135
- parse_content = false
1136
- content = embed($1) or next
1137
- when %r(^/web\s+(?:(\d+)\s+)?(.+))
1138
- parse_content = false
1139
- urls = search_web($2, $1.to_i)
1140
- urls.each do |url|
1141
- fetch_source(url) { |url_io| embed_source(url_io, url) }
1142
- end
1143
- urls_summarized = urls.map { summarize(_1) }
1144
- query = $2.inspect
1145
- results = urls.zip(urls_summarized).
1146
- map { |u, s| "%s as \n:%s" % [ u, s ] } * "\n\n"
1147
- content = $config.prompts.web % { query:, results: }
1148
- when %r(^/save\s+(.+)$)
1149
- save_conversation($1, messages)
1150
- puts "Saved conversation to #$1."
1151
- next
1152
- when %r(^/links(?:\s+(clear))?$)
1153
- case $1
1154
- when 'clear'
1155
- loop do
1156
- links = $links.dup.add('[EXIT]').add('[ALL]')
1157
- link = Utils::Chooser.choose(links, prompt: 'Clear? %s')
1158
- case link
1159
- when nil, '[EXIT]'
1160
- puts "Exiting chooser."
1161
- break
1162
- when '[ALL]'
1163
- if ask?(prompt: 'Are you sure? (y/n) ') =~ /\Ay/i
1164
- $links.clear
1165
- puts "Cleared all links in list."
1166
- break
1167
- else
1168
- puts 'Cancelled.'
1169
- sleep 3
1170
- end
1171
- when /./
1172
- $links.delete(link)
1173
- puts "Cleared link from links in list."
1174
- sleep 3
1175
- end
1176
- end
1177
- when nil
1178
- if $links.empty?
1179
- puts "List is empty."
1180
- else
1181
- Math.log10($links.size).ceil
1182
- format = "% #{}s. %s"
1183
- connect = -> link { hyperlink(link) { link } }
1184
- puts $links.each_with_index.map { |x, i| format % [ i + 1, connect.(x) ] }
1185
- end
1186
- end
1187
- next
1188
- when %r(^/load\s+(.+)$)
1189
- messages = load_conversation($1)
1190
- puts "Loaded conversation from #$1."
1191
- next
1192
- when %r(^/quit$)
1193
- puts "Goodbye."
1194
- exit 0
1195
- when %r(^/)
1196
- display_chat_help
1197
- next
1198
- when ''
1199
- puts "Type /quit to quit."
1200
- next
1201
- when nil
1202
- puts "Goodbye."
1203
- exit 0
1204
- end
1205
-
1206
- content, tags = if parse_content
1207
- parse_content(content, images)
1208
- else
1209
- [ content, Documentrix::Utils::Tags.new ]
1210
- end
1211
-
1212
- if $embedding.on? && content
1213
- records = $documents.find_where(
1214
- content.downcase,
1215
- tags:,
1216
- prompt: $config.embedding.model.prompt?,
1217
- text_size: $config.embedding.found_texts_size?,
1218
- text_count: $config.embedding.found_texts_count?,
1219
- )
1220
- unless records.empty?
1221
- content += "\nConsider these chunks for your answer:\n\n"\
1222
- "#{records.map { [ _1.text, _1.tags_set ] * ?\n }.join("\n\n---\n\n")}"
1223
- end
1224
- end
1225
-
1226
- if location = at_location.full?
1227
- content += " [#{location} – do not comment on this the time and location, "\
1228
- "just consider it for eventual queries]"
1229
- end
1230
-
1231
- messages << Message.new(role: 'user', content:, images: images.dup)
1232
- images.clear
1233
- handler = FollowChat.new(messages:, markdown: $markdown.on?, voice: ($current_voice if $voice.on?))
1234
- ollama.chat(model: $model, messages:, options: $model_options, stream: $stream.on?, &handler)
1235
-
1236
- if $embedding.on? && !records.empty?
1237
- puts "", records.map { |record|
1238
- link = if record.source =~ %r(\Ahttps?://)
1239
- record.source
1240
- else
1241
- 'file://%s' % File.expand_path(record.source)
1242
- end
1243
- [ link, record.tags.first ]
1244
- }.uniq.map { |l, t| hyperlink(l, t) }.join(' ')
1245
- $config.debug and jj messages
1246
- end
1247
- rescue Interrupt
1248
- puts "Type /quit to quit."
1249
- end