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.
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