ollama_chat 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.all_images.yml +17 -0
  3. data/.gitignore +9 -0
  4. data/Gemfile +5 -0
  5. data/README.md +159 -0
  6. data/Rakefile +58 -0
  7. data/VERSION +1 -0
  8. data/bin/ollama_chat +5 -0
  9. data/lib/ollama_chat/chat.rb +398 -0
  10. data/lib/ollama_chat/clipboard.rb +23 -0
  11. data/lib/ollama_chat/dialog.rb +94 -0
  12. data/lib/ollama_chat/document_cache.rb +16 -0
  13. data/lib/ollama_chat/follow_chat.rb +60 -0
  14. data/lib/ollama_chat/information.rb +113 -0
  15. data/lib/ollama_chat/message_list.rb +216 -0
  16. data/lib/ollama_chat/message_type.rb +5 -0
  17. data/lib/ollama_chat/model_handling.rb +29 -0
  18. data/lib/ollama_chat/ollama_chat_config.rb +103 -0
  19. data/lib/ollama_chat/parsing.rb +159 -0
  20. data/lib/ollama_chat/source_fetching.rb +173 -0
  21. data/lib/ollama_chat/switches.rb +119 -0
  22. data/lib/ollama_chat/utils/cache_fetcher.rb +38 -0
  23. data/lib/ollama_chat/utils/chooser.rb +53 -0
  24. data/lib/ollama_chat/utils/fetcher.rb +175 -0
  25. data/lib/ollama_chat/utils/file_argument.rb +34 -0
  26. data/lib/ollama_chat/utils.rb +7 -0
  27. data/lib/ollama_chat/version.rb +8 -0
  28. data/lib/ollama_chat.rb +20 -0
  29. data/ollama_chat.gemspec +50 -0
  30. data/spec/assets/api_show.json +63 -0
  31. data/spec/assets/api_tags.json +21 -0
  32. data/spec/assets/conversation.json +14 -0
  33. data/spec/assets/duckduckgo.html +757 -0
  34. data/spec/assets/example.atom +26 -0
  35. data/spec/assets/example.csv +5 -0
  36. data/spec/assets/example.html +10 -0
  37. data/spec/assets/example.pdf +139 -0
  38. data/spec/assets/example.ps +4 -0
  39. data/spec/assets/example.rb +1 -0
  40. data/spec/assets/example.rss +25 -0
  41. data/spec/assets/example.xml +7 -0
  42. data/spec/assets/kitten.jpg +0 -0
  43. data/spec/assets/prompt.txt +1 -0
  44. data/spec/ollama_chat/chat_spec.rb +105 -0
  45. data/spec/ollama_chat/clipboard_spec.rb +29 -0
  46. data/spec/ollama_chat/follow_chat_spec.rb +46 -0
  47. data/spec/ollama_chat/information_spec.rb +50 -0
  48. data/spec/ollama_chat/message_list_spec.rb +132 -0
  49. data/spec/ollama_chat/model_handling_spec.rb +35 -0
  50. data/spec/ollama_chat/parsing_spec.rb +240 -0
  51. data/spec/ollama_chat/source_fetching_spec.rb +54 -0
  52. data/spec/ollama_chat/switches_spec.rb +167 -0
  53. data/spec/ollama_chat/utils/cache_fetcher_spec.rb +43 -0
  54. data/spec/ollama_chat/utils/fetcher_spec.rb +137 -0
  55. data/spec/ollama_chat/utils/file_argument_spec.rb +17 -0
  56. data/spec/spec_helper.rb +46 -0
  57. data/tmp/.keep +0 -0
  58. 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