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