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.
- checksums.yaml +4 -4
- data/.envrc +1 -0
- data/CHANGES.md +78 -0
- data/README.md +62 -23
- data/Rakefile +16 -4
- data/bin/ollama_chat +470 -90
- data/bin/ollama_console +3 -3
- data/bin/ollama_update +17 -0
- data/config/redis.conf +5 -0
- data/docker-compose.yml +11 -0
- data/lib/ollama/client.rb +7 -2
- data/lib/ollama/documents/memory_cache.rb +44 -0
- data/lib/ollama/documents/redis_cache.rb +57 -0
- data/lib/ollama/documents/splitters/character.rb +70 -0
- data/lib/ollama/documents/splitters/semantic.rb +90 -0
- data/lib/ollama/documents.rb +172 -0
- data/lib/ollama/dto.rb +4 -7
- data/lib/ollama/handlers/progress.rb +18 -5
- data/lib/ollama/image.rb +16 -7
- data/lib/ollama/options.rb +4 -0
- data/lib/ollama/utils/chooser.rb +30 -0
- data/lib/ollama/utils/colorize_texts.rb +42 -0
- data/lib/ollama/utils/fetcher.rb +105 -0
- data/lib/ollama/utils/math.rb +48 -0
- data/lib/ollama/utils/tags.rb +7 -0
- data/lib/ollama/utils/width.rb +1 -1
- data/lib/ollama/version.rb +1 -1
- data/lib/ollama.rb +12 -5
- data/ollama-ruby.gemspec +19 -9
- data/spec/assets/embeddings.json +1 -0
- data/spec/ollama/client_spec.rb +2 -2
- data/spec/ollama/commands/chat_spec.rb +2 -2
- data/spec/ollama/commands/copy_spec.rb +2 -2
- data/spec/ollama/commands/create_spec.rb +2 -2
- data/spec/ollama/commands/delete_spec.rb +2 -2
- data/spec/ollama/commands/embed_spec.rb +3 -3
- data/spec/ollama/commands/embeddings_spec.rb +2 -2
- data/spec/ollama/commands/generate_spec.rb +2 -2
- data/spec/ollama/commands/pull_spec.rb +2 -2
- data/spec/ollama/commands/push_spec.rb +2 -2
- data/spec/ollama/commands/show_spec.rb +2 -2
- data/spec/ollama/documents/memory_cache_spec.rb +63 -0
- data/spec/ollama/documents/redis_cache_spec.rb +78 -0
- data/spec/ollama/documents/splitters/character_spec.rb +96 -0
- data/spec/ollama/documents/splitters/semantic_spec.rb +56 -0
- data/spec/ollama/documents_spec.rb +119 -0
- data/spec/ollama/handlers/progress_spec.rb +2 -2
- data/spec/ollama/image_spec.rb +4 -0
- data/spec/ollama/message_spec.rb +3 -4
- data/spec/ollama/options_spec.rb +18 -0
- data/spec/ollama/tool_spec.rb +1 -6
- data/spec/ollama/utils/fetcher_spec.rb +74 -0
- data/spec/ollama/utils/tags_spec.rb +24 -0
- data/spec/spec_helper.rb +8 -0
- data/tmp/.keep +0 -0
- 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
|
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 ?
|
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
|
-
|
96
|
+
$config.debug and jj response
|
26
97
|
if response&.message&.role == 'assistant'
|
27
98
|
if @messages.last.role != 'assistant'
|
28
|
-
@messages <<
|
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 =
|
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
|
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
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
if
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
147
|
+
break
|
64
148
|
end
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
73
|
-
retry
|
161
|
+
return
|
74
162
|
end
|
75
|
-
|
76
|
-
|
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
|
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
|
-
|
215
|
+
Utils::ANSIMarkdown.parse(m.content)
|
119
216
|
else
|
120
217
|
m.content
|
121
218
|
end
|
122
|
-
|
123
|
-
|
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
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
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
|
-
-
|
147
|
-
-
|
148
|
-
-
|
149
|
-
-s SYSTEM
|
150
|
-
-c CHAT
|
151
|
-
-
|
152
|
-
-
|
153
|
-
-
|
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
|
-
|
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
|
-
|
164
|
-
|
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
|
-
|
442
|
+
base_url = opts[?u] || $config.url
|
443
|
+
$ollama = Client.new(base_url:, debug: $config.debug)
|
170
444
|
|
171
|
-
|
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
|
-
|
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
|
-
|
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 ||=
|
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 "
|
514
|
+
puts "\nType /help to display the chat help."
|
195
515
|
|
196
|
-
images
|
516
|
+
images = []
|
197
517
|
loop do
|
198
|
-
|
199
|
-
|
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,
|
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(^/
|
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
|
-
|
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
|
-
|
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(^/
|
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
|
253
|
-
|
254
|
-
|
255
|
-
|
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
|