llm.rb 12.2.0 → 12.3.1
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 +4 -4
- data/CHANGELOG.md +187 -0
- data/README.md +51 -10
- data/data/openai.json +422 -0
- data/lib/llm/active_record/acts_as_agent.rb +11 -0
- data/lib/llm/agent.rb +14 -6
- data/lib/llm/compactor.rb +2 -2
- data/lib/llm/context.rb +16 -24
- data/lib/llm/object.rb +13 -0
- data/lib/llm/provider.rb +10 -7
- data/lib/llm/providers/anthropic.rb +2 -3
- data/lib/llm/providers/bedrock.rb +4 -5
- data/lib/llm/providers/google.rb +4 -2
- data/lib/llm/providers/mistral.rb +49 -0
- data/lib/llm/providers/ollama/response_adapter/completion.rb +39 -0
- data/lib/llm/providers/ollama.rb +2 -3
- data/lib/llm/providers/openai/responses.rb +6 -3
- data/lib/llm/providers/openai.rb +2 -3
- data/lib/llm/repl/bar.rb +52 -0
- data/lib/llm/repl/input.rb +143 -30
- data/lib/llm/repl/markdown.rb +85 -0
- data/lib/llm/repl/status.rb +16 -5
- data/lib/llm/repl/stream.rb +15 -5
- data/lib/llm/repl/transcript.rb +107 -18
- data/lib/llm/repl/window.rb +49 -18
- data/lib/llm/repl.rb +99 -18
- data/lib/llm/sequel/agent.rb +11 -0
- data/lib/llm/skill.rb +1 -1
- data/lib/llm/stream/disabled.rb +23 -0
- data/lib/llm/stream/io.rb +43 -0
- data/lib/llm/stream.rb +34 -0
- data/lib/llm/tools/git.rb +2 -3
- data/lib/llm/tools/pwd.rb +0 -1
- data/lib/llm/tools/rg.rb +2 -1
- data/lib/llm/tools/swap_text.rb +6 -0
- data/lib/llm/transport/execution.rb +1 -0
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +10 -0
- data/llm.gemspec +7 -3
- data/resources/deepdive.md +170 -50
- metadata +10 -3
|
@@ -205,9 +205,8 @@ module LLM
|
|
|
205
205
|
params = {role: :user, model: default_model, max_tokens: 2048}.merge!(params)
|
|
206
206
|
tools = resolve_tools(params.delete(:tools))
|
|
207
207
|
params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
|
|
208
|
-
role, stream = params.delete(:role), params.delete(:stream)
|
|
209
|
-
params
|
|
210
|
-
[params, stream, tools, role]
|
|
208
|
+
role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
|
|
209
|
+
[params.merge!(stream: stream.enabled?), stream, tools, role]
|
|
211
210
|
end
|
|
212
211
|
|
|
213
212
|
def build_complete_request(prompt, params, role, stream: nil)
|
|
@@ -215,8 +214,8 @@ module LLM
|
|
|
215
214
|
model_id = params.delete(:model) || default_model
|
|
216
215
|
payload = build_converse_payload(messages, params)
|
|
217
216
|
body = LLM.json.dump(payload)
|
|
218
|
-
path = stream ? "/model/#{model_id}/converse-stream" \
|
|
219
|
-
|
|
217
|
+
path = stream&.enabled? ? "/model/#{model_id}/converse-stream" \
|
|
218
|
+
: "/model/#{model_id}/converse"
|
|
220
219
|
req = LLM::Transport::Request.post(path, headers)
|
|
221
220
|
transport.set_body_stream(req, StringIO.new(body))
|
|
222
221
|
[req, messages, body]
|
data/lib/llm/providers/google.rb
CHANGED
|
@@ -199,8 +199,10 @@ module LLM
|
|
|
199
199
|
tools = resolve_tools(params.delete(:tools))
|
|
200
200
|
config = adapt_generation_config(params.except(*except))
|
|
201
201
|
params = [params.except(:schema), config, adapt_tools(tools)].inject({}, &:merge!).compact
|
|
202
|
-
role, model, stream =
|
|
203
|
-
|
|
202
|
+
role, model, stream = params.delete(:role),
|
|
203
|
+
params.delete(:model),
|
|
204
|
+
LLM::Stream.try(params.delete(:stream))
|
|
205
|
+
[params.merge!(stream: stream.enabled?), stream, tools, role, model]
|
|
204
206
|
end
|
|
205
207
|
|
|
206
208
|
def build_complete_request(prompt, params, role, model, stream)
|
|
@@ -56,6 +56,32 @@ module LLM
|
|
|
56
56
|
super
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
##
|
|
60
|
+
# Runs OCR on a remote image or document URL.
|
|
61
|
+
# @see https://docs.mistral.ai/api/endpoint/ocr#operation-ocr_v1_ocr_post Mistral OCR docs
|
|
62
|
+
# @param [String, nil] image_url
|
|
63
|
+
# A remote HTTP(S) URL to the image
|
|
64
|
+
# @param [String, nil] document_url
|
|
65
|
+
# A remote HTTP(S) URL to the document
|
|
66
|
+
# @param [String] model
|
|
67
|
+
# The OCR model to use
|
|
68
|
+
# @param [Hash] params
|
|
69
|
+
# Additional OCR parameters
|
|
70
|
+
# @raise (see LLM::Provider#request)
|
|
71
|
+
# @return [LLM::Response]
|
|
72
|
+
def ocr(image_url: nil, document_url: nil, model: "mistral-ocr-latest", **params)
|
|
73
|
+
if [image_url, document_url].all?(&:nil?)
|
|
74
|
+
raise ArgumentError, "must provide one of: image_url, document_url"
|
|
75
|
+
elsif [image_url, document_url].compact.size > 1
|
|
76
|
+
raise ArgumentError, "must provide one of: image_url, document_url"
|
|
77
|
+
end
|
|
78
|
+
document = parse_document(image_url, document_url)
|
|
79
|
+
req = LLM::Transport::Request.post("/v1/ocr", headers)
|
|
80
|
+
req.body = LLM.json.dump({model:, document:}.merge!(params))
|
|
81
|
+
res, = execute(request: req, operation: "ocr", model:)
|
|
82
|
+
LLM::Response.new(res)
|
|
83
|
+
end
|
|
84
|
+
|
|
59
85
|
##
|
|
60
86
|
# @raise [NotImplementedError]
|
|
61
87
|
def responses
|
|
@@ -92,5 +118,28 @@ module LLM
|
|
|
92
118
|
def default_model
|
|
93
119
|
"mistral-large-latest"
|
|
94
120
|
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
##
|
|
125
|
+
# @api private
|
|
126
|
+
def headers
|
|
127
|
+
lock do
|
|
128
|
+
(@headers || {}).merge(
|
|
129
|
+
"Authorization" => "Bearer #{@key}",
|
|
130
|
+
"Content-Type" => "application/json"
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
##
|
|
136
|
+
# @api private
|
|
137
|
+
def parse_document(image_url, document_url)
|
|
138
|
+
if image_url
|
|
139
|
+
{type: "image_url", image_url:}
|
|
140
|
+
elsif document_url
|
|
141
|
+
{type: "document_url", document_url:}
|
|
142
|
+
end
|
|
143
|
+
end
|
|
95
144
|
end
|
|
96
145
|
end
|
|
@@ -9,6 +9,16 @@ module LLM::Ollama::ResponseAdapter
|
|
|
9
9
|
end
|
|
10
10
|
alias_method :choices, :messages
|
|
11
11
|
|
|
12
|
+
##
|
|
13
|
+
# Returns the response body, parsing NDJSON when the
|
|
14
|
+
# transport returned a raw string (non-streaming path).
|
|
15
|
+
# @return [LLM::Object]
|
|
16
|
+
def body
|
|
17
|
+
raw = super
|
|
18
|
+
return raw unless String === raw
|
|
19
|
+
parse_ndjson(raw)
|
|
20
|
+
end
|
|
21
|
+
|
|
12
22
|
##
|
|
13
23
|
# (see LLM::Contract::Completion#input_tokens)
|
|
14
24
|
def input_tokens
|
|
@@ -110,6 +120,35 @@ module LLM::Ollama::ResponseAdapter
|
|
|
110
120
|
end
|
|
111
121
|
end
|
|
112
122
|
|
|
123
|
+
##
|
|
124
|
+
# Parses a raw NDJSON string into an LLM::Object.
|
|
125
|
+
# Ollama's streaming API returns application/x-ndjson,
|
|
126
|
+
# but the transport only parses application/json. When
|
|
127
|
+
# the response body arrives as a raw string, we merge
|
|
128
|
+
# the NDJSON lines here.
|
|
129
|
+
# @param [String] raw
|
|
130
|
+
# @return [LLM::Object]
|
|
131
|
+
def parse_ndjson(raw)
|
|
132
|
+
lines = raw.split("\n").reject(&:empty?)
|
|
133
|
+
merged = lines.each_with_object({}) do |line, hash|
|
|
134
|
+
parsed = LLM.json.load(line)
|
|
135
|
+
next unless parsed.is_a?(Hash)
|
|
136
|
+
parsed.each do |key, value|
|
|
137
|
+
if key == "message" && value.is_a?(Hash)
|
|
138
|
+
if hash.key?("message")
|
|
139
|
+
hash["message"]["content"] << value["content"].to_s
|
|
140
|
+
else
|
|
141
|
+
hash["message"] = {"role" => value["role"], "content" => value["content"].to_s}
|
|
142
|
+
hash["message"]["tool_calls"] = value["tool_calls"] if value.key?("tool_calls")
|
|
143
|
+
end
|
|
144
|
+
else
|
|
145
|
+
hash[key] = value
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
LLM::Object.from(merged)
|
|
150
|
+
end
|
|
151
|
+
|
|
113
152
|
include LLM::Contract::Completion
|
|
114
153
|
end
|
|
115
154
|
end
|
data/lib/llm/providers/ollama.rb
CHANGED
|
@@ -121,9 +121,8 @@ module LLM
|
|
|
121
121
|
params = {role: :user, model: default_model, stream: true}.merge!(params)
|
|
122
122
|
tools = resolve_tools(params.delete(:tools))
|
|
123
123
|
params = [params, {format: params[:schema]}, adapt_tools(tools)].inject({}, &:merge!).compact
|
|
124
|
-
role, stream = params.delete(:role), params.delete(:stream)
|
|
125
|
-
params
|
|
126
|
-
[params, stream, tools, role]
|
|
124
|
+
role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
|
|
125
|
+
[params.merge!(stream: stream.enabled?), stream, tools, role]
|
|
127
126
|
end
|
|
128
127
|
|
|
129
128
|
def build_complete_request(prompt, params, role)
|
|
@@ -36,10 +36,13 @@ class LLM::OpenAI
|
|
|
36
36
|
# @return [LLM::Response]
|
|
37
37
|
def create(prompt, params = {})
|
|
38
38
|
params = {role: :user, model: @provider.default_model}.merge!(params)
|
|
39
|
+
role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
|
|
39
40
|
tools = resolve_tools(params.delete(:tools))
|
|
40
|
-
params = [
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
params = [
|
|
42
|
+
params.merge!(stream: stream.enabled?),
|
|
43
|
+
adapt_schema(params),
|
|
44
|
+
adapt_tools(tools)
|
|
45
|
+
].inject({}, &:merge!).compact
|
|
43
46
|
req = LLM::Transport::Request.post(path("/responses"), headers)
|
|
44
47
|
messages = build_complete_messages(prompt, params, role)
|
|
45
48
|
@provider.tracer.set_request_metadata(user_input: extract_user_input(messages, fallback: prompt))
|
data/lib/llm/providers/openai.rb
CHANGED
|
@@ -211,12 +211,11 @@ module LLM
|
|
|
211
211
|
params = {role: :user, model: default_model}.merge!(params)
|
|
212
212
|
tools = resolve_tools(params.delete(:tools))
|
|
213
213
|
params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
|
|
214
|
-
role, stream = params.delete(:role), params.delete(:stream)
|
|
215
|
-
params[:stream] = true if streamable?(stream) || stream == true
|
|
214
|
+
role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
|
|
216
215
|
if params[:stream]
|
|
217
216
|
params[:stream_options] = {include_usage: true}.merge!(params[:stream_options] || {})
|
|
218
217
|
end
|
|
219
|
-
[params, stream, tools, role]
|
|
218
|
+
[params.merge!(stream: stream.enabled?), stream, tools, role]
|
|
220
219
|
end
|
|
221
220
|
|
|
222
221
|
def build_complete_request(prompt, params, role)
|
data/lib/llm/repl/bar.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Repl
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Repl::Bar LLM::Repl::Bar} class renders a
|
|
6
|
+
# small progress bar for the REPL. It is used to show
|
|
7
|
+
# the remaining size of the model's context window in
|
|
8
|
+
# a compact form near the input line.
|
|
9
|
+
# @api private
|
|
10
|
+
class Bar
|
|
11
|
+
##
|
|
12
|
+
# @return [String]
|
|
13
|
+
OCCUPIED = "█"
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# @return [String]
|
|
17
|
+
FREE = " "
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# @param [Integer] used
|
|
21
|
+
# @param [Integer] total
|
|
22
|
+
# @param [Integer] width
|
|
23
|
+
# @return [LLM::Repl::Bar]
|
|
24
|
+
def initialize(used:, total:, width: 10)
|
|
25
|
+
@width = width
|
|
26
|
+
@label, @filled = remainder(used, total)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @return [String]
|
|
31
|
+
def to_s
|
|
32
|
+
bar = "#{OCCUPIED * filled}#{FREE * (width - filled)}"
|
|
33
|
+
"│#{bar}│ #{label}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @param [Integer] used
|
|
40
|
+
# @param [Integer] total
|
|
41
|
+
# @return [[String, Integer]]
|
|
42
|
+
def remainder(used, total)
|
|
43
|
+
return ["???", width] if total <= 0
|
|
44
|
+
diff = total - used
|
|
45
|
+
return ["0%", 0] if diff <= 0
|
|
46
|
+
remaining = (diff.fdiv(total) * 100).round(2)
|
|
47
|
+
["#{remaining}%", ((remaining / 100) * width).round]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :label, :filled, :width
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/llm/repl/input.rb
CHANGED
|
@@ -6,59 +6,172 @@ class LLM::Repl
|
|
|
6
6
|
# the editable input line shown at the bottom of the REPL.
|
|
7
7
|
# @api private
|
|
8
8
|
class Input
|
|
9
|
+
CTRL_A = 1
|
|
10
|
+
CTRL_E = 5
|
|
9
11
|
UP = Curses::Key::UP
|
|
10
12
|
DOWN = Curses::Key::DOWN
|
|
13
|
+
LEFT = Curses::Key::LEFT
|
|
14
|
+
RIGHT = Curses::Key::RIGHT
|
|
11
15
|
ENTER = [Curses::Key::ENTER, 10, 13]
|
|
12
16
|
BACKSPACE = [Curses::Key::BACKSPACE, 127]
|
|
13
|
-
EOF = [
|
|
17
|
+
EOF = [4]
|
|
14
18
|
|
|
15
19
|
##
|
|
16
|
-
# @param [
|
|
20
|
+
# @param [LLM::Agent] agent
|
|
17
21
|
# @return [LLM::Repl::Input]
|
|
18
|
-
def initialize(
|
|
19
|
-
@
|
|
22
|
+
def initialize(agent, options = {})
|
|
23
|
+
@agent = agent
|
|
24
|
+
@provider = agent.llm.name
|
|
20
25
|
@buffer = +""
|
|
26
|
+
@cursor = 0
|
|
27
|
+
@scroll = 0
|
|
28
|
+
@height = options.fetch(:height, 3)
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
##
|
|
24
32
|
# @param [LLM::Repl::Window] window
|
|
25
|
-
# @
|
|
26
|
-
|
|
27
|
-
catch(:done) do
|
|
28
|
-
@buffer.clear
|
|
29
|
-
loop do
|
|
30
|
-
on_char(window, window.getch)
|
|
31
|
-
window.redraw
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
##
|
|
37
|
-
# @return [String]
|
|
38
|
-
def to_s
|
|
39
|
-
"> #{@buffer}"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
33
|
+
# @param [Object] char
|
|
34
|
+
# @return [Symbol, nil]
|
|
44
35
|
def on_char(window, char)
|
|
45
36
|
if EOF.include?(char)
|
|
46
|
-
|
|
37
|
+
:exit
|
|
47
38
|
elsif BACKSPACE.include?(char)
|
|
48
|
-
|
|
39
|
+
backspace
|
|
40
|
+
:backspace
|
|
49
41
|
elsif ENTER.include?(char)
|
|
50
|
-
|
|
51
|
-
@buffer.clear
|
|
52
|
-
throw(:done, buf)
|
|
42
|
+
:submit
|
|
53
43
|
elsif char == UP
|
|
54
44
|
window.scroll_up
|
|
45
|
+
:up
|
|
55
46
|
elsif char == DOWN
|
|
56
47
|
window.scroll_down
|
|
48
|
+
:down
|
|
49
|
+
elsif char == CTRL_A
|
|
50
|
+
move_start
|
|
51
|
+
:ctrl_a
|
|
52
|
+
elsif char == CTRL_E
|
|
53
|
+
move_end
|
|
54
|
+
:ctrl_e
|
|
55
|
+
elsif char == LEFT
|
|
56
|
+
move_left
|
|
57
|
+
:left
|
|
58
|
+
elsif char == RIGHT
|
|
59
|
+
move_right
|
|
60
|
+
:right
|
|
57
61
|
elsif String === char
|
|
58
|
-
|
|
62
|
+
insert(char)
|
|
63
|
+
:char
|
|
59
64
|
else
|
|
60
|
-
|
|
65
|
+
nil
|
|
61
66
|
end
|
|
62
67
|
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# @return [String]
|
|
71
|
+
def to_s
|
|
72
|
+
"#{@provider}> #{@buffer}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
##
|
|
76
|
+
# @return [Integer]
|
|
77
|
+
def cursor
|
|
78
|
+
prompt.length + @cursor
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# @return [Integer]
|
|
83
|
+
def height
|
|
84
|
+
@height
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
##
|
|
88
|
+
# Returns the visible lines of the input buffer,
|
|
89
|
+
# wrapped at the given column width. The viewport
|
|
90
|
+
# follows the cursor so the cursor line is always
|
|
91
|
+
# visible.
|
|
92
|
+
# @param [Integer] cols
|
|
93
|
+
# @return [Array<String>]
|
|
94
|
+
def lines(cols)
|
|
95
|
+
sync_scroll(cols)
|
|
96
|
+
text = to_s
|
|
97
|
+
chunks = text.chars.each_slice(cols).map(&:join)
|
|
98
|
+
chunks = [""] if chunks.empty?
|
|
99
|
+
chunks[@scroll, height] || []
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
##
|
|
103
|
+
# Returns the cursor position as [line, column] within
|
|
104
|
+
# the visible viewport.
|
|
105
|
+
# @param [Integer] cols
|
|
106
|
+
# @return [Array(Integer, Integer)]
|
|
107
|
+
def cursor_pos(cols)
|
|
108
|
+
sync_scroll(cols)
|
|
109
|
+
[(cursor / cols) - @scroll, cursor % cols]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
##
|
|
113
|
+
# @return [void]
|
|
114
|
+
def move_start
|
|
115
|
+
@cursor = 0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
##
|
|
119
|
+
# @return [void]
|
|
120
|
+
def move_end
|
|
121
|
+
@cursor = [0, @buffer.size].max
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
##
|
|
125
|
+
# @return [void]
|
|
126
|
+
def move_left
|
|
127
|
+
@cursor = [@cursor - 1, 0].max
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
##
|
|
131
|
+
# @return [void]
|
|
132
|
+
def move_right
|
|
133
|
+
@cursor = [@cursor + 1, @buffer.size].min
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
##
|
|
137
|
+
# @return [String]
|
|
138
|
+
def take
|
|
139
|
+
@buffer.dup.tap do
|
|
140
|
+
@buffer.clear
|
|
141
|
+
@cursor = 0
|
|
142
|
+
@scroll = 0
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
##
|
|
149
|
+
# Adjusts @scroll so the cursor line is visible within
|
|
150
|
+
# the viewport.
|
|
151
|
+
def sync_scroll(cols)
|
|
152
|
+
total_lines = [1, (to_s.length.to_f / cols).ceil].max
|
|
153
|
+
cursor_line = cursor / cols
|
|
154
|
+
if cursor_line < @scroll
|
|
155
|
+
@scroll = cursor_line
|
|
156
|
+
elsif cursor_line >= (@scroll + height)
|
|
157
|
+
@scroll = (cursor_line - height) + 1
|
|
158
|
+
end
|
|
159
|
+
@scroll = [[@scroll, (total_lines - height)].min, 0].max
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def prompt
|
|
163
|
+
"#{@provider}> "
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def insert(char)
|
|
167
|
+
@buffer.insert(@cursor, char)
|
|
168
|
+
@cursor += char.length
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def backspace
|
|
172
|
+
return if @cursor <= 0
|
|
173
|
+
@buffer.slice!(@cursor - 1)
|
|
174
|
+
@cursor -= 1
|
|
175
|
+
end
|
|
63
176
|
end
|
|
64
177
|
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Repl
|
|
4
|
+
##
|
|
5
|
+
# This class is designed to represent a markdown
|
|
6
|
+
# string (typically from a model's response) as a
|
|
7
|
+
# tree of objects where each object contains a piece
|
|
8
|
+
# of text, and also optional style information for
|
|
9
|
+
# that text (eg bold, underscore, ...)
|
|
10
|
+
class Markdown
|
|
11
|
+
##
|
|
12
|
+
# @param [String] text
|
|
13
|
+
# @return [LLM::Repl::Markdown]
|
|
14
|
+
def initialize(text)
|
|
15
|
+
@doc = Kramdown::Document.new(text)
|
|
16
|
+
@ast = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# @return [Array<Hash>]
|
|
21
|
+
def ast
|
|
22
|
+
@ast.tap do
|
|
23
|
+
##
|
|
24
|
+
# Recurisvely travels the markdown document and
|
|
25
|
+
# populates the `@ast` variable along the way.
|
|
26
|
+
# The AST is composed of structured data that
|
|
27
|
+
# carries both text and styling information that
|
|
28
|
+
# is applied by the UI thread.
|
|
29
|
+
walk(@doc.root)
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# This is required because the AST collects
|
|
33
|
+
# empty nodes towards the end of the tree.
|
|
34
|
+
# If we don't pop them we end up with excessive
|
|
35
|
+
# amount of newlines between turns.
|
|
36
|
+
last = @ast.last
|
|
37
|
+
while last and last[:text].to_s.strip.empty?
|
|
38
|
+
@ast.pop
|
|
39
|
+
last = @ast.last
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
##
|
|
47
|
+
# Recursively walk from the head node to the
|
|
48
|
+
# tail node. This method mutates the `@ast`
|
|
49
|
+
# variable. A future refactor might be worthwhile
|
|
50
|
+
# since this method is implemented with side effects,
|
|
51
|
+
# but it probably could return the ast instead.
|
|
52
|
+
def walk(node, attrs = nil)
|
|
53
|
+
case node.type
|
|
54
|
+
when :root
|
|
55
|
+
node.children.each { walk(_1, attrs) }
|
|
56
|
+
when :text
|
|
57
|
+
emit(node.value.to_s, attrs)
|
|
58
|
+
when :p
|
|
59
|
+
node.children.each { walk(_1, attrs) }
|
|
60
|
+
emit("\n\n", attrs)
|
|
61
|
+
when :header
|
|
62
|
+
emit("\n", attrs)
|
|
63
|
+
node.children.each { walk(_1, Curses::A_BOLD) }
|
|
64
|
+
emit("\n", attrs)
|
|
65
|
+
when :strong
|
|
66
|
+
node.children.each { walk(_1, Curses::A_BOLD) }
|
|
67
|
+
when :em
|
|
68
|
+
node.children.each { walk(_1, Curses::A_UNDERLINE) }
|
|
69
|
+
when :codespan
|
|
70
|
+
emit(node.value, Curses::A_REVERSE)
|
|
71
|
+
when :codeblock
|
|
72
|
+
emit(node.value, Curses::A_REVERSE)
|
|
73
|
+
emit("\n\n", attrs)
|
|
74
|
+
when :br
|
|
75
|
+
emit("\n", attrs)
|
|
76
|
+
else
|
|
77
|
+
node.children.each { walk(_1, attrs) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def emit(text, attrs)
|
|
82
|
+
@ast.push({text: text.to_s, attrs:}.compact)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/llm/repl/status.rb
CHANGED
|
@@ -9,18 +9,29 @@ class LLM::Repl
|
|
|
9
9
|
##
|
|
10
10
|
# @param [String, Symbol] provider
|
|
11
11
|
# @return [LLM::Repl::Status]
|
|
12
|
-
def initialize(
|
|
13
|
-
@
|
|
12
|
+
def initialize(agent)
|
|
13
|
+
@agent = agent
|
|
14
|
+
@provider = agent.llm.name
|
|
14
15
|
@text = "idle"
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
##
|
|
18
19
|
# @return [String]
|
|
19
|
-
|
|
20
|
+
def context_bar
|
|
21
|
+
LLM::Repl::Bar.new(
|
|
22
|
+
used: @agent.usage.total_tokens,
|
|
23
|
+
total: @agent.context_window
|
|
24
|
+
).to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# @return [String]
|
|
29
|
+
def cost
|
|
30
|
+
"$#{@agent.cost}"
|
|
31
|
+
end
|
|
20
32
|
|
|
21
33
|
##
|
|
22
|
-
# @
|
|
23
|
-
# @return [void]
|
|
34
|
+
# @return [String]
|
|
24
35
|
attr_accessor :text
|
|
25
36
|
|
|
26
37
|
##
|
data/lib/llm/repl/stream.rb
CHANGED
|
@@ -11,8 +11,10 @@ class LLM::Repl
|
|
|
11
11
|
##
|
|
12
12
|
# @param [LLM::Repl] repl
|
|
13
13
|
# @return [LLM::Repl::Stream]
|
|
14
|
-
def initialize(repl)
|
|
14
|
+
def initialize(repl, queue)
|
|
15
15
|
@repl = repl
|
|
16
|
+
@_queue = queue
|
|
17
|
+
@buffer = +""
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
##
|
|
@@ -20,7 +22,8 @@ class LLM::Repl
|
|
|
20
22
|
# One or more chars
|
|
21
23
|
# @return [void]
|
|
22
24
|
def on_content(chars)
|
|
23
|
-
@
|
|
25
|
+
@buffer << chars
|
|
26
|
+
@_queue.push [:stream, @buffer]
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
##
|
|
@@ -29,9 +32,9 @@ class LLM::Repl
|
|
|
29
32
|
# @return [void]
|
|
30
33
|
def on_tool_call(tool, error)
|
|
31
34
|
if error
|
|
32
|
-
@
|
|
35
|
+
@_queue.push [:status, "tool not found: #{tool.name}"]
|
|
33
36
|
else
|
|
34
|
-
@
|
|
37
|
+
@_queue.push [:status, "tool: #{tool.name}"]
|
|
35
38
|
end
|
|
36
39
|
end
|
|
37
40
|
|
|
@@ -40,7 +43,14 @@ class LLM::Repl
|
|
|
40
43
|
# @param [LLM::Function::Return] result
|
|
41
44
|
# @return [void]
|
|
42
45
|
def on_tool_return(_tool, result)
|
|
43
|
-
@
|
|
46
|
+
@_queue.push [:status, "tool done: #{result.name}"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# Empty the accumulated buffer
|
|
51
|
+
# @return [void]
|
|
52
|
+
def empty!
|
|
53
|
+
@buffer.clear
|
|
44
54
|
end
|
|
45
55
|
end
|
|
46
56
|
end
|