ollama_chat 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.all_images.yml +17 -0
- data/.gitignore +9 -0
- data/Gemfile +5 -0
- data/README.md +159 -0
- data/Rakefile +58 -0
- data/VERSION +1 -0
- data/bin/ollama_chat +5 -0
- data/lib/ollama_chat/chat.rb +398 -0
- data/lib/ollama_chat/clipboard.rb +23 -0
- data/lib/ollama_chat/dialog.rb +94 -0
- data/lib/ollama_chat/document_cache.rb +16 -0
- data/lib/ollama_chat/follow_chat.rb +60 -0
- data/lib/ollama_chat/information.rb +113 -0
- data/lib/ollama_chat/message_list.rb +216 -0
- data/lib/ollama_chat/message_type.rb +5 -0
- data/lib/ollama_chat/model_handling.rb +29 -0
- data/lib/ollama_chat/ollama_chat_config.rb +103 -0
- data/lib/ollama_chat/parsing.rb +159 -0
- data/lib/ollama_chat/source_fetching.rb +173 -0
- data/lib/ollama_chat/switches.rb +119 -0
- data/lib/ollama_chat/utils/cache_fetcher.rb +38 -0
- data/lib/ollama_chat/utils/chooser.rb +53 -0
- data/lib/ollama_chat/utils/fetcher.rb +175 -0
- data/lib/ollama_chat/utils/file_argument.rb +34 -0
- data/lib/ollama_chat/utils.rb +7 -0
- data/lib/ollama_chat/version.rb +8 -0
- data/lib/ollama_chat.rb +20 -0
- data/ollama_chat.gemspec +50 -0
- data/spec/assets/api_show.json +63 -0
- data/spec/assets/api_tags.json +21 -0
- data/spec/assets/conversation.json +14 -0
- data/spec/assets/duckduckgo.html +757 -0
- data/spec/assets/example.atom +26 -0
- data/spec/assets/example.csv +5 -0
- data/spec/assets/example.html +10 -0
- data/spec/assets/example.pdf +139 -0
- data/spec/assets/example.ps +4 -0
- data/spec/assets/example.rb +1 -0
- data/spec/assets/example.rss +25 -0
- data/spec/assets/example.xml +7 -0
- data/spec/assets/kitten.jpg +0 -0
- data/spec/assets/prompt.txt +1 -0
- data/spec/ollama_chat/chat_spec.rb +105 -0
- data/spec/ollama_chat/clipboard_spec.rb +29 -0
- data/spec/ollama_chat/follow_chat_spec.rb +46 -0
- data/spec/ollama_chat/information_spec.rb +50 -0
- data/spec/ollama_chat/message_list_spec.rb +132 -0
- data/spec/ollama_chat/model_handling_spec.rb +35 -0
- data/spec/ollama_chat/parsing_spec.rb +240 -0
- data/spec/ollama_chat/source_fetching_spec.rb +54 -0
- data/spec/ollama_chat/switches_spec.rb +167 -0
- data/spec/ollama_chat/utils/cache_fetcher_spec.rb +43 -0
- data/spec/ollama_chat/utils/fetcher_spec.rb +137 -0
- data/spec/ollama_chat/utils/file_argument_spec.rb +17 -0
- data/spec/spec_helper.rb +46 -0
- data/tmp/.keep +0 -0
- metadata +476 -0
@@ -0,0 +1,159 @@
|
|
1
|
+
module OllamaChat::Parsing
|
2
|
+
def parse_source(source_io)
|
3
|
+
case source_io&.content_type
|
4
|
+
when 'text/html'
|
5
|
+
reverse_markdown(source_io.read)
|
6
|
+
when 'text/xml'
|
7
|
+
if source_io.read(8192) =~ %r(^\s*<rss\s)
|
8
|
+
source_io.rewind
|
9
|
+
return parse_rss(source_io)
|
10
|
+
end
|
11
|
+
source_io.rewind
|
12
|
+
source_io.read
|
13
|
+
when 'text/csv'
|
14
|
+
parse_csv(source_io)
|
15
|
+
when 'application/rss+xml'
|
16
|
+
parse_rss(source_io)
|
17
|
+
when 'application/atom+xml'
|
18
|
+
parse_atom(source_io)
|
19
|
+
when 'application/postscript'
|
20
|
+
ps_read(source_io)
|
21
|
+
when 'application/pdf'
|
22
|
+
pdf_read(source_io)
|
23
|
+
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
|
24
|
+
source_io.read
|
25
|
+
else
|
26
|
+
STDERR.puts "Cannot embed #{source_io&.content_type} document."
|
27
|
+
return
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def parse_csv(source_io)
|
32
|
+
result = +''
|
33
|
+
CSV.table(File.new(source_io), col_sep: ?,).each do |row|
|
34
|
+
next if row.fields.select(&:present?).none?
|
35
|
+
result << row.map { |pair|
|
36
|
+
pair.compact.map { _1.to_s.strip } * ': ' if pair.last.present?
|
37
|
+
}.select(&:present?).map { _1.prepend(' ') } * ?\n
|
38
|
+
result << "\n\n"
|
39
|
+
end
|
40
|
+
result
|
41
|
+
end
|
42
|
+
|
43
|
+
def parse_rss(source_io)
|
44
|
+
feed = RSS::Parser.parse(source_io, false, false)
|
45
|
+
title = <<~EOT
|
46
|
+
# #{feed&.channel&.title}
|
47
|
+
|
48
|
+
EOT
|
49
|
+
feed.items.inject(title) do |text, item|
|
50
|
+
text << <<~EOT
|
51
|
+
## [#{item&.title}](#{item&.link})
|
52
|
+
|
53
|
+
updated on #{item&.pubDate}
|
54
|
+
|
55
|
+
#{reverse_markdown(item&.description)}
|
56
|
+
|
57
|
+
EOT
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def parse_atom(source_io)
|
62
|
+
feed = RSS::Parser.parse(source_io, false, false)
|
63
|
+
title = <<~EOT
|
64
|
+
# #{feed.title.content}
|
65
|
+
|
66
|
+
EOT
|
67
|
+
feed.items.inject(title) do |text, item|
|
68
|
+
text << <<~EOT
|
69
|
+
## [#{item&.title&.content}](#{item&.link&.href})
|
70
|
+
|
71
|
+
updated on #{item&.updated&.content}
|
72
|
+
|
73
|
+
#{reverse_markdown(item&.content&.content)}
|
74
|
+
|
75
|
+
EOT
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def pdf_read(io)
|
80
|
+
reader = PDF::Reader.new(io)
|
81
|
+
reader.pages.inject(+'') { |result, page| result << page.text }
|
82
|
+
end
|
83
|
+
|
84
|
+
def ps_read(io)
|
85
|
+
gs = `which gs`.chomp
|
86
|
+
if gs.present?
|
87
|
+
Tempfile.create do |tmp|
|
88
|
+
IO.popen("#{gs} -q -sDEVICE=pdfwrite -sOutputFile=#{tmp.path} -", 'wb') do |gs_io|
|
89
|
+
until io.eof?
|
90
|
+
buffer = io.read(1 << 17)
|
91
|
+
IO.select(nil, [ gs_io ], nil)
|
92
|
+
gs_io.write buffer
|
93
|
+
end
|
94
|
+
gs_io.close
|
95
|
+
File.open(tmp.path, 'rb') do |pdf|
|
96
|
+
pdf_read(pdf)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
else
|
101
|
+
STDERR.puts "Cannot convert #{io&.content_type} whith ghostscript, gs not in path."
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def reverse_markdown(html)
|
106
|
+
ReverseMarkdown.convert(
|
107
|
+
html,
|
108
|
+
unknown_tags: :bypass,
|
109
|
+
github_flavored: true,
|
110
|
+
tag_border: ''
|
111
|
+
)
|
112
|
+
end
|
113
|
+
|
114
|
+
def parse_content(content, images)
|
115
|
+
images.clear
|
116
|
+
tags = Documentrix::Utils::Tags.new
|
117
|
+
|
118
|
+
contents = [ content ]
|
119
|
+
content.scan(%r((https?://\S+)|(#\S+)|(?:file://)?(\S*\/\S+))).each do |url, tag, file|
|
120
|
+
case
|
121
|
+
when tag
|
122
|
+
tags.add(tag)
|
123
|
+
next
|
124
|
+
when file
|
125
|
+
file = file.sub(/#.*/, '')
|
126
|
+
file =~ %r(\A[~./]) or file.prepend('./')
|
127
|
+
File.exist?(file) or next
|
128
|
+
source = file
|
129
|
+
when url
|
130
|
+
links.add(url.to_s)
|
131
|
+
source = url
|
132
|
+
end
|
133
|
+
fetch_source(source) do |source_io|
|
134
|
+
case source_io&.content_type&.media_type
|
135
|
+
when 'image'
|
136
|
+
add_image(images, source_io, source)
|
137
|
+
when 'text', 'application', nil
|
138
|
+
case @document_policy
|
139
|
+
when 'ignoring'
|
140
|
+
nil
|
141
|
+
when 'importing'
|
142
|
+
contents << import_source(source_io, source)
|
143
|
+
when 'embedding'
|
144
|
+
embed_source(source_io, source)
|
145
|
+
when 'summarizing'
|
146
|
+
contents << summarize_source(source_io, source)
|
147
|
+
end
|
148
|
+
else
|
149
|
+
STDERR.puts(
|
150
|
+
"Cannot fetch #{source.to_s.inspect} with content type "\
|
151
|
+
"#{source_io&.content_type.inspect}"
|
152
|
+
)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
new_content = contents.select { _1.present? rescue nil }.compact * "\n\n"
|
157
|
+
return new_content, (tags unless tags.empty?)
|
158
|
+
end
|
159
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
module OllamaChat::SourceFetching
|
2
|
+
def http_options(url)
|
3
|
+
options = {}
|
4
|
+
if ssl_no_verify = config.ssl_no_verify?
|
5
|
+
hostname = URI.parse(url).hostname
|
6
|
+
options |= { ssl_verify_peer: !ssl_no_verify.include?(hostname) }
|
7
|
+
end
|
8
|
+
if proxy = config.proxy?
|
9
|
+
options |= { proxy: }
|
10
|
+
end
|
11
|
+
options
|
12
|
+
end
|
13
|
+
|
14
|
+
def fetch_source(source, &block)
|
15
|
+
case source
|
16
|
+
when %r(\A!(.*))
|
17
|
+
command = $1
|
18
|
+
OllamaChat::Utils::Fetcher.execute(command) do |tmp|
|
19
|
+
block.(tmp)
|
20
|
+
end
|
21
|
+
when %r(\Ahttps?://\S+)
|
22
|
+
links.add(source.to_s)
|
23
|
+
OllamaChat::Utils::Fetcher.get(
|
24
|
+
source,
|
25
|
+
cache: @cache,
|
26
|
+
debug: config.debug,
|
27
|
+
http_options: http_options(OllamaChat::Utils::Fetcher.normalize_url(source))
|
28
|
+
) do |tmp|
|
29
|
+
block.(tmp)
|
30
|
+
end
|
31
|
+
when %r(\Afile://(/\S*?)#|\A((?:\.\.|[~.]?)/\S*))
|
32
|
+
filename = $~.captures.compact.first
|
33
|
+
filename = File.expand_path(filename)
|
34
|
+
OllamaChat::Utils::Fetcher.read(filename) do |tmp|
|
35
|
+
block.(tmp)
|
36
|
+
end
|
37
|
+
else
|
38
|
+
raise "invalid source"
|
39
|
+
end
|
40
|
+
rescue => e
|
41
|
+
STDERR.puts "Cannot fetch source #{source.to_s.inspect}: #{e.class} #{e}\n#{e.backtrace * ?\n}"
|
42
|
+
end
|
43
|
+
|
44
|
+
def add_image(images, source_io, source)
|
45
|
+
STDERR.puts "Adding #{source_io&.content_type} image #{source.to_s.inspect}."
|
46
|
+
image = Ollama::Image.for_io(source_io, path: source.to_s)
|
47
|
+
(images << image).uniq!
|
48
|
+
end
|
49
|
+
|
50
|
+
def import_source(source_io, source)
|
51
|
+
source = source.to_s
|
52
|
+
STDOUT.puts "Importing #{italic { source_io&.content_type }} document #{source.to_s.inspect} now."
|
53
|
+
source_content = parse_source(source_io)
|
54
|
+
"Imported #{source.inspect}:\n\n#{source_content}\n\n"
|
55
|
+
end
|
56
|
+
|
57
|
+
def import(source)
|
58
|
+
fetch_source(source) do |source_io|
|
59
|
+
content = import_source(source_io, source) or return
|
60
|
+
source_io.rewind
|
61
|
+
content
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def summarize_source(source_io, source, words: nil)
|
66
|
+
STDOUT.puts "Summarizing #{italic { source_io&.content_type }} document #{source.to_s.inspect} now."
|
67
|
+
words = words.to_i
|
68
|
+
words < 1 and words = 100
|
69
|
+
source_content = parse_source(source_io)
|
70
|
+
source_content.present? or return
|
71
|
+
config.prompts.summarize % { source_content:, words: }
|
72
|
+
end
|
73
|
+
|
74
|
+
def summarize(source, words: nil)
|
75
|
+
fetch_source(source) do |source_io|
|
76
|
+
content = summarize_source(source_io, source, words:) or return
|
77
|
+
source_io.rewind
|
78
|
+
content
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def embed_source(source_io, source, count: nil)
|
83
|
+
@embedding.on? or return parse_source(source_io)
|
84
|
+
m = "Embedding #{italic { source_io&.content_type }} document #{source.to_s.inspect}."
|
85
|
+
if count
|
86
|
+
STDOUT.puts '%u. %s' % [ count, m ]
|
87
|
+
else
|
88
|
+
STDOUT.puts m
|
89
|
+
end
|
90
|
+
text = parse_source(source_io) or return
|
91
|
+
text.downcase!
|
92
|
+
splitter_config = config.embedding.splitter
|
93
|
+
inputs = nil
|
94
|
+
case splitter_config.name
|
95
|
+
when 'Character'
|
96
|
+
splitter = Documentrix::Documents::Splitters::Character.new(
|
97
|
+
chunk_size: splitter_config.chunk_size,
|
98
|
+
)
|
99
|
+
inputs = splitter.split(text)
|
100
|
+
when 'RecursiveCharacter'
|
101
|
+
splitter = Documentrix::Documents::Splitters::RecursiveCharacter.new(
|
102
|
+
chunk_size: splitter_config.chunk_size,
|
103
|
+
)
|
104
|
+
inputs = splitter.split(text)
|
105
|
+
when 'Semantic'
|
106
|
+
splitter = Documentrix::Documents::Splitters::Semantic.new(
|
107
|
+
ollama:, model: config.embedding.model.name,
|
108
|
+
chunk_size: splitter_config.chunk_size,
|
109
|
+
)
|
110
|
+
inputs = splitter.split(
|
111
|
+
text,
|
112
|
+
breakpoint: splitter_config.breakpoint.to_sym,
|
113
|
+
percentage: splitter_config.percentage?,
|
114
|
+
percentile: splitter_config.percentile?,
|
115
|
+
)
|
116
|
+
end
|
117
|
+
inputs or return
|
118
|
+
source = source.to_s
|
119
|
+
if source.start_with?(?!)
|
120
|
+
source = Kramdown::ANSI::Width.truncate(
|
121
|
+
source[1..-1].gsub(/\W+/, ?_),
|
122
|
+
length: 10
|
123
|
+
)
|
124
|
+
end
|
125
|
+
@documents.add(inputs, source:, batch_size: config.embedding.batch_size?)
|
126
|
+
end
|
127
|
+
|
128
|
+
def embed(source)
|
129
|
+
if @embedding.on?
|
130
|
+
STDOUT.puts "Now embedding #{source.to_s.inspect}."
|
131
|
+
fetch_source(source) do |source_io|
|
132
|
+
content = parse_source(source_io)
|
133
|
+
content.present? or return
|
134
|
+
source_io.rewind
|
135
|
+
embed_source(source_io, source)
|
136
|
+
end
|
137
|
+
config.prompts.embed % { source: }
|
138
|
+
else
|
139
|
+
STDOUT.puts "Embedding is off, so I will just give a small summary of this source."
|
140
|
+
summarize(source)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def search_web(query, n = nil)
|
145
|
+
if l = @messages.at_location.full?
|
146
|
+
query += " #{l}"
|
147
|
+
end
|
148
|
+
n = n.to_i
|
149
|
+
n < 1 and n = 1
|
150
|
+
query = URI.encode_uri_component(query)
|
151
|
+
url = "https://www.duckduckgo.com/html/?q=#{query}"
|
152
|
+
OllamaChat::Utils::Fetcher.get(url, debug: config.debug) do |tmp|
|
153
|
+
result = []
|
154
|
+
doc = Nokogiri::HTML(tmp)
|
155
|
+
doc.css('.results_links').each do |link|
|
156
|
+
if n > 0
|
157
|
+
url = link.css('.result__a').first&.[]('href')
|
158
|
+
url.sub!(%r(\A(//duckduckgo\.com)?/l/\?uddg=), '')
|
159
|
+
url.sub!(%r(&rut=.*), '')
|
160
|
+
url = URI.decode_uri_component(url)
|
161
|
+
url = URI.parse(url)
|
162
|
+
url.host =~ /duckduckgo\.com/ and next
|
163
|
+
links.add(url.to_s)
|
164
|
+
result << url
|
165
|
+
n -= 1
|
166
|
+
else
|
167
|
+
break
|
168
|
+
end
|
169
|
+
end
|
170
|
+
result
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
module OllamaChat::Switches
|
2
|
+
module CheckSwitch
|
3
|
+
extend Tins::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
alias_method :on?, :value
|
7
|
+
end
|
8
|
+
|
9
|
+
def off?
|
10
|
+
!on?
|
11
|
+
end
|
12
|
+
|
13
|
+
def show
|
14
|
+
STDOUT.puts @msg[value]
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class Switch
|
19
|
+
def initialize(name, msg:, config:)
|
20
|
+
@value = [ false, true ].include?(config) ? config : !!config.send("#{name}?")
|
21
|
+
@msg = msg
|
22
|
+
end
|
23
|
+
|
24
|
+
attr_reader :value
|
25
|
+
|
26
|
+
def set(value, show: false)
|
27
|
+
@value = !!value
|
28
|
+
show && self.show
|
29
|
+
end
|
30
|
+
|
31
|
+
def toggle(show: true)
|
32
|
+
@value = !@value
|
33
|
+
show && self.show
|
34
|
+
end
|
35
|
+
|
36
|
+
include CheckSwitch
|
37
|
+
end
|
38
|
+
|
39
|
+
class CombinedSwitch
|
40
|
+
def initialize(value:, msg:)
|
41
|
+
@value = value
|
42
|
+
@msg = msg
|
43
|
+
end
|
44
|
+
|
45
|
+
def value
|
46
|
+
@value.()
|
47
|
+
end
|
48
|
+
|
49
|
+
include CheckSwitch
|
50
|
+
end
|
51
|
+
|
52
|
+
attr_reader :markdown
|
53
|
+
|
54
|
+
attr_reader :location
|
55
|
+
|
56
|
+
def setup_switches(config)
|
57
|
+
@markdown = Switch.new(
|
58
|
+
:markdown,
|
59
|
+
config:,
|
60
|
+
msg: {
|
61
|
+
true => "Using #{italic{'ANSI'}} markdown to output content.",
|
62
|
+
false => "Using plaintext for outputting content.",
|
63
|
+
}
|
64
|
+
)
|
65
|
+
|
66
|
+
@stream = Switch.new(
|
67
|
+
:stream,
|
68
|
+
config:,
|
69
|
+
msg: {
|
70
|
+
true => "Streaming enabled.",
|
71
|
+
false => "Streaming disabled.",
|
72
|
+
}
|
73
|
+
)
|
74
|
+
|
75
|
+
@voice = Switch.new(
|
76
|
+
:stream,
|
77
|
+
config: config.voice,
|
78
|
+
msg: {
|
79
|
+
true => "Voice output enabled.",
|
80
|
+
false => "Voice output disabled.",
|
81
|
+
}
|
82
|
+
)
|
83
|
+
|
84
|
+
@embedding_enabled = Switch.new(
|
85
|
+
:embedding_enabled,
|
86
|
+
config:,
|
87
|
+
msg: {
|
88
|
+
true => "Embedding enabled.",
|
89
|
+
false => "Embedding disabled.",
|
90
|
+
}
|
91
|
+
)
|
92
|
+
|
93
|
+
@embedding_paused = Switch.new(
|
94
|
+
:embedding_paused,
|
95
|
+
config:,
|
96
|
+
msg: {
|
97
|
+
true => "Embedding paused.",
|
98
|
+
false => "Embedding resumed.",
|
99
|
+
}
|
100
|
+
)
|
101
|
+
|
102
|
+
@embedding = CombinedSwitch.new(
|
103
|
+
value: -> { @embedding_enabled.on? && @embedding_paused.off? },
|
104
|
+
msg: {
|
105
|
+
true => "Embedding is currently performed.",
|
106
|
+
false => "Embedding is currently not performed.",
|
107
|
+
}
|
108
|
+
)
|
109
|
+
|
110
|
+
@location = Switch.new(
|
111
|
+
:location,
|
112
|
+
config: config.location.enabled,
|
113
|
+
msg: {
|
114
|
+
true => "Location and localtime enabled.",
|
115
|
+
false => "Location and localtime disabled.",
|
116
|
+
}
|
117
|
+
)
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'digest/md5'
|
2
|
+
|
3
|
+
class OllamaChat::Utils::CacheFetcher
|
4
|
+
def initialize(cache)
|
5
|
+
@cache = cache
|
6
|
+
end
|
7
|
+
|
8
|
+
def get(url, &block)
|
9
|
+
block or raise ArgumentError, 'require block argument'
|
10
|
+
body = @cache[key(:body, url)]
|
11
|
+
content_type = @cache[key(:content_type, url)]
|
12
|
+
content_type = MIME::Types[content_type].first
|
13
|
+
if body && content_type
|
14
|
+
io = StringIO.new(body)
|
15
|
+
io.rewind
|
16
|
+
io.extend(OllamaChat::Utils::Fetcher::HeaderExtension)
|
17
|
+
io.content_type = content_type
|
18
|
+
block.(io)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def put(url, io)
|
23
|
+
io.rewind
|
24
|
+
body = io.read
|
25
|
+
body.empty? and return
|
26
|
+
content_type = io.content_type
|
27
|
+
content_type.nil? and return
|
28
|
+
@cache.set(key(:body, url), body, ex: io.ex)
|
29
|
+
@cache.set(key(:content_type, url), content_type.to_s, ex: io.ex)
|
30
|
+
self
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def key(type, url)
|
36
|
+
[ type, Digest::MD5.hexdigest(url) ] * ?-
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'amatch'
|
2
|
+
require 'search_ui'
|
3
|
+
require 'term/ansicolor'
|
4
|
+
|
5
|
+
module OllamaChat::Utils::Chooser
|
6
|
+
class << self
|
7
|
+
include SearchUI
|
8
|
+
include Term::ANSIColor
|
9
|
+
|
10
|
+
# The choose method presents a list of entries and prompts the user
|
11
|
+
# for input, allowing them to select one entry based on their input.
|
12
|
+
#
|
13
|
+
# @param entries [Array] the list of entries to present to the user
|
14
|
+
# @param prompt [String] the prompt message to display when asking for input (default: 'Search? %s')
|
15
|
+
# @param return_immediately [Boolean] whether to immediately return the
|
16
|
+
# first entry if there is only one or nil when there is none (default: false)
|
17
|
+
#
|
18
|
+
# @return [Object] the selected entry, or nil if no entry was chosen
|
19
|
+
#
|
20
|
+
# @example
|
21
|
+
# choose(['entry1', 'entry2'], prompt: 'Choose an option:')
|
22
|
+
def choose(entries, prompt: 'Search? %s', return_immediately: false)
|
23
|
+
if return_immediately && entries.size <= 1
|
24
|
+
return entries.first
|
25
|
+
end
|
26
|
+
entry = Search.new(
|
27
|
+
prompt:,
|
28
|
+
match: -> answer {
|
29
|
+
matcher = Amatch::PairDistance.new(answer.downcase)
|
30
|
+
matches = entries.map { |n| [ n, -matcher.similar(n.to_s.downcase) ] }.
|
31
|
+
select { |_, s| s < 0 }.sort_by(&:last).map(&:first)
|
32
|
+
matches.empty? and matches = entries
|
33
|
+
matches.first(Tins::Terminal.lines - 1)
|
34
|
+
},
|
35
|
+
query: -> _answer, matches, selector {
|
36
|
+
matches.each_with_index.map { |m, i|
|
37
|
+
i == selector ? "#{blue{?⮕}} #{on_blue{m}}" : " #{m.to_s}"
|
38
|
+
} * ?\n
|
39
|
+
},
|
40
|
+
found: -> _answer, matches, selector {
|
41
|
+
matches[selector]
|
42
|
+
},
|
43
|
+
output: STDOUT
|
44
|
+
).start
|
45
|
+
if entry
|
46
|
+
entry
|
47
|
+
else
|
48
|
+
print clear_screen, move_home
|
49
|
+
nil
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,175 @@
|
|
1
|
+
require 'tempfile'
|
2
|
+
require 'tins/unit'
|
3
|
+
require 'infobar'
|
4
|
+
require 'mime-types'
|
5
|
+
require 'stringio'
|
6
|
+
require 'ollama_chat/utils/cache_fetcher'
|
7
|
+
|
8
|
+
class OllamaChat::Utils::Fetcher
|
9
|
+
module HeaderExtension
|
10
|
+
attr_accessor :content_type
|
11
|
+
|
12
|
+
attr_accessor :ex
|
13
|
+
|
14
|
+
def self.failed
|
15
|
+
object = StringIO.new.extend(self)
|
16
|
+
object.content_type = MIME::Types['text/plain'].first
|
17
|
+
object
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class RetryWithoutStreaming < StandardError; end
|
22
|
+
|
23
|
+
def self.get(url, **options, &block)
|
24
|
+
cache = options.delete(:cache) and
|
25
|
+
cache = OllamaChat::Utils::CacheFetcher.new(cache)
|
26
|
+
if result = cache&.get(url, &block)
|
27
|
+
infobar.puts "Getting #{url.to_s.inspect} from cache."
|
28
|
+
return result
|
29
|
+
else
|
30
|
+
new(**options).send(:get, url) do |tmp|
|
31
|
+
result = block.(tmp)
|
32
|
+
if cache && !tmp.is_a?(StringIO)
|
33
|
+
tmp.rewind
|
34
|
+
cache.put(url, tmp)
|
35
|
+
end
|
36
|
+
result
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.normalize_url(url)
|
42
|
+
url = url.to_s
|
43
|
+
url = URI.decode_uri_component(url)
|
44
|
+
url = url.sub(/#.*/, '')
|
45
|
+
URI::Parser.new.escape(url).to_s
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.read(filename, &block)
|
49
|
+
if File.exist?(filename)
|
50
|
+
File.open(filename) do |file|
|
51
|
+
file.extend(OllamaChat::Utils::Fetcher::HeaderExtension)
|
52
|
+
file.content_type = MIME::Types.type_for(filename).first
|
53
|
+
block.(file)
|
54
|
+
end
|
55
|
+
else
|
56
|
+
STDERR.puts "File #{filename.to_s.inspect} doesn't exist."
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.execute(command, &block)
|
61
|
+
Tempfile.open do |tmp|
|
62
|
+
IO.popen(command) do |command|
|
63
|
+
until command.eof?
|
64
|
+
tmp.write command.read(1 << 14)
|
65
|
+
end
|
66
|
+
tmp.rewind
|
67
|
+
tmp.extend(OllamaChat::Utils::Fetcher::HeaderExtension)
|
68
|
+
tmp.content_type = MIME::Types['text/plain'].first
|
69
|
+
block.(tmp)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
rescue => e
|
73
|
+
STDERR.puts "Cannot execute #{command.inspect} (#{e})"
|
74
|
+
if @debug && !e.is_a?(RuntimeError)
|
75
|
+
STDERR.puts "#{e.backtrace * ?\n}"
|
76
|
+
end
|
77
|
+
yield HeaderExtension.failed
|
78
|
+
end
|
79
|
+
|
80
|
+
def initialize(debug: false, http_options: {})
|
81
|
+
@debug = debug
|
82
|
+
@started = false
|
83
|
+
@streaming = true
|
84
|
+
@http_options = http_options
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
def excon(url, **options)
|
90
|
+
url = self.class.normalize_url(url)
|
91
|
+
Excon.new(url, options.merge(@http_options))
|
92
|
+
end
|
93
|
+
|
94
|
+
def get(url, &block)
|
95
|
+
response = nil
|
96
|
+
Tempfile.open do |tmp|
|
97
|
+
infobar.label = 'Getting'
|
98
|
+
if @streaming
|
99
|
+
response = excon(url, headers:, response_block: callback(tmp)).request(method: :get)
|
100
|
+
response.status != 200 || !@started and raise RetryWithoutStreaming
|
101
|
+
decorate_io(tmp, response)
|
102
|
+
infobar.finish
|
103
|
+
block.(tmp)
|
104
|
+
else
|
105
|
+
response = excon(url, headers:, middlewares:).request(method: :get)
|
106
|
+
if response.status != 200
|
107
|
+
raise "invalid response status code"
|
108
|
+
end
|
109
|
+
body = response.body
|
110
|
+
tmp.print body
|
111
|
+
infobar.update(message: message(body.size, body.size), force: true)
|
112
|
+
decorate_io(tmp, response)
|
113
|
+
infobar.finish
|
114
|
+
block.(tmp)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
rescue RetryWithoutStreaming
|
118
|
+
@streaming = false
|
119
|
+
retry
|
120
|
+
rescue => e
|
121
|
+
STDERR.puts "Cannot get #{url.to_s.inspect} (#{e}): #{response&.status_line || 'n/a'}"
|
122
|
+
if @debug && !e.is_a?(RuntimeError)
|
123
|
+
STDERR.puts "#{e.backtrace * ?\n}"
|
124
|
+
end
|
125
|
+
yield HeaderExtension.failed
|
126
|
+
end
|
127
|
+
|
128
|
+
def headers
|
129
|
+
{
|
130
|
+
'User-Agent' => OllamaChat::Chat.user_agent,
|
131
|
+
}
|
132
|
+
end
|
133
|
+
|
134
|
+
def middlewares
|
135
|
+
(Excon.defaults[:middlewares] + [ Excon::Middleware::RedirectFollower ]).uniq
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def decorate_io(tmp, response)
|
141
|
+
tmp.rewind
|
142
|
+
tmp.extend(HeaderExtension)
|
143
|
+
if content_type = MIME::Types[response.headers['content-type']].first
|
144
|
+
tmp.content_type = content_type
|
145
|
+
end
|
146
|
+
if cache_control = response.headers['cache-control'] and
|
147
|
+
cache_control !~ /no-store|no-cache/ and
|
148
|
+
ex = cache_control[/s-maxage\s*=\s*(\d+)/, 1] || cache_control[/max-age\s*=\s*(\d+)/, 1]
|
149
|
+
then
|
150
|
+
tmp.ex = ex.to_i
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def callback(tmp)
|
155
|
+
-> chunk, remaining_bytes, total_bytes do
|
156
|
+
total = total_bytes or next
|
157
|
+
current = total_bytes - remaining_bytes
|
158
|
+
if @started
|
159
|
+
infobar.counter.progress(by: total - current)
|
160
|
+
else
|
161
|
+
@started = true
|
162
|
+
infobar.counter.reset(total:, current:)
|
163
|
+
end
|
164
|
+
infobar.update(message: message(current, total), force: true)
|
165
|
+
tmp.print(chunk)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def message(current, total)
|
170
|
+
progress = '%s/%s' % [ current, total ].map {
|
171
|
+
Tins::Unit.format(_1, format: '%.2f %U')
|
172
|
+
}
|
173
|
+
'%l ' + progress + ' in %te, ETA %e @%E'
|
174
|
+
end
|
175
|
+
end
|