ollama-ruby 0.13.0 → 0.14.1

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