fantasy-cli 1.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +456 -0
- data/bin/gsd +8 -0
- data/bin/gsd-core-darwin-amd64 +0 -0
- data/bin/gsd-core-darwin-arm64 +0 -0
- data/bin/gsd-core-linux-amd64 +0 -0
- data/bin/gsd-core-linux-arm64 +0 -0
- data/bin/gsd-core-windows-amd64.exe +0 -0
- data/bin/gsd-core-windows-arm64.exe +0 -0
- data/bin/gsd-core.exe +0 -0
- data/lib/gsd/agents/coordinator.rb +195 -0
- data/lib/gsd/agents/task_manager.rb +158 -0
- data/lib/gsd/agents/worker.rb +162 -0
- data/lib/gsd/agents.rb +30 -0
- data/lib/gsd/ai/chat.rb +486 -0
- data/lib/gsd/ai/cli.rb +248 -0
- data/lib/gsd/ai/command_parser.rb +97 -0
- data/lib/gsd/ai/commands/base.rb +42 -0
- data/lib/gsd/ai/commands/clear.rb +20 -0
- data/lib/gsd/ai/commands/context.rb +30 -0
- data/lib/gsd/ai/commands/cost.rb +30 -0
- data/lib/gsd/ai/commands/export.rb +42 -0
- data/lib/gsd/ai/commands/help.rb +61 -0
- data/lib/gsd/ai/commands/model.rb +67 -0
- data/lib/gsd/ai/commands/reset.rb +22 -0
- data/lib/gsd/ai/config.rb +256 -0
- data/lib/gsd/ai/context.rb +324 -0
- data/lib/gsd/ai/cost_tracker.rb +361 -0
- data/lib/gsd/ai/git_context.rb +169 -0
- data/lib/gsd/ai/history.rb +384 -0
- data/lib/gsd/ai/providers/anthropic.rb +429 -0
- data/lib/gsd/ai/providers/base.rb +282 -0
- data/lib/gsd/ai/providers/lmstudio.rb +279 -0
- data/lib/gsd/ai/providers/ollama.rb +336 -0
- data/lib/gsd/ai/providers/openai.rb +396 -0
- data/lib/gsd/ai/providers/openrouter.rb +429 -0
- data/lib/gsd/ai/reference_resolver.rb +225 -0
- data/lib/gsd/ai/repl.rb +349 -0
- data/lib/gsd/ai/streaming.rb +438 -0
- data/lib/gsd/ai/ui.rb +429 -0
- data/lib/gsd/buddy/cli.rb +284 -0
- data/lib/gsd/buddy/gacha.rb +148 -0
- data/lib/gsd/buddy/renderer.rb +108 -0
- data/lib/gsd/buddy/species.rb +190 -0
- data/lib/gsd/buddy/stats.rb +156 -0
- data/lib/gsd/buddy.rb +28 -0
- data/lib/gsd/cli.rb +455 -0
- data/lib/gsd/commands.rb +198 -0
- data/lib/gsd/config.rb +183 -0
- data/lib/gsd/error.rb +188 -0
- data/lib/gsd/frontmatter.rb +123 -0
- data/lib/gsd/go/bridge.rb +173 -0
- data/lib/gsd/history.rb +76 -0
- data/lib/gsd/milestone.rb +75 -0
- data/lib/gsd/output.rb +184 -0
- data/lib/gsd/phase.rb +102 -0
- data/lib/gsd/plugins/base.rb +92 -0
- data/lib/gsd/plugins/cli.rb +330 -0
- data/lib/gsd/plugins/config.rb +164 -0
- data/lib/gsd/plugins/hooks.rb +132 -0
- data/lib/gsd/plugins/installer.rb +158 -0
- data/lib/gsd/plugins/loader.rb +122 -0
- data/lib/gsd/plugins/manager.rb +187 -0
- data/lib/gsd/plugins/marketplace.rb +142 -0
- data/lib/gsd/plugins/sandbox.rb +114 -0
- data/lib/gsd/plugins/search.rb +131 -0
- data/lib/gsd/plugins/validator.rb +157 -0
- data/lib/gsd/plugins.rb +48 -0
- data/lib/gsd/profile.rb +127 -0
- data/lib/gsd/research.rb +85 -0
- data/lib/gsd/roadmap.rb +90 -0
- data/lib/gsd/skills/bundled/commit.md +58 -0
- data/lib/gsd/skills/bundled/debug.md +28 -0
- data/lib/gsd/skills/bundled/explain.md +41 -0
- data/lib/gsd/skills/bundled/plan.md +42 -0
- data/lib/gsd/skills/bundled/verify.md +26 -0
- data/lib/gsd/skills/loader.rb +189 -0
- data/lib/gsd/state.rb +102 -0
- data/lib/gsd/template.rb +106 -0
- data/lib/gsd/tools/ask_user_question.rb +179 -0
- data/lib/gsd/tools/base.rb +204 -0
- data/lib/gsd/tools/bash.rb +246 -0
- data/lib/gsd/tools/file_edit.rb +297 -0
- data/lib/gsd/tools/file_read.rb +199 -0
- data/lib/gsd/tools/file_write.rb +153 -0
- data/lib/gsd/tools/glob.rb +202 -0
- data/lib/gsd/tools/grep.rb +227 -0
- data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
- data/lib/gsd/tools/gsd_phase.rb +140 -0
- data/lib/gsd/tools/gsd_roadmap.rb +108 -0
- data/lib/gsd/tools/gsd_state.rb +143 -0
- data/lib/gsd/tools/gsd_template.rb +157 -0
- data/lib/gsd/tools/gsd_verify.rb +159 -0
- data/lib/gsd/tools/registry.rb +103 -0
- data/lib/gsd/tools/task.rb +235 -0
- data/lib/gsd/tools/todo_write.rb +290 -0
- data/lib/gsd/tools/web.rb +260 -0
- data/lib/gsd/tui/app.rb +366 -0
- data/lib/gsd/tui/auto_complete.rb +79 -0
- data/lib/gsd/tui/colors.rb +111 -0
- data/lib/gsd/tui/command_palette.rb +126 -0
- data/lib/gsd/tui/header.rb +38 -0
- data/lib/gsd/tui/input_box.rb +199 -0
- data/lib/gsd/tui/spinner.rb +40 -0
- data/lib/gsd/tui/status_bar.rb +51 -0
- data/lib/gsd/tui.rb +17 -0
- data/lib/gsd/validator.rb +216 -0
- data/lib/gsd/verify.rb +175 -0
- data/lib/gsd/version.rb +5 -0
- data/lib/gsd/workstream.rb +91 -0
- metadata +231 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Gsd
|
|
7
|
+
module AI
|
|
8
|
+
module Providers
|
|
9
|
+
# LM Studio Provider - Integração com LM Studio (LLMs locais)
|
|
10
|
+
#
|
|
11
|
+
# Suporta:
|
|
12
|
+
# - Qualquer modelo rodando no LM Studio
|
|
13
|
+
# - API compatível com OpenAI
|
|
14
|
+
# - Streaming de respostas
|
|
15
|
+
# - Modelos locais gratuitos
|
|
16
|
+
class LMStudio < Base
|
|
17
|
+
PROVIDER_NAME = 'LM Studio'
|
|
18
|
+
DEFAULT_MODEL = 'local-model'
|
|
19
|
+
DEFAULT_HOST = 'localhost'
|
|
20
|
+
DEFAULT_PORT = '1234'
|
|
21
|
+
API_URL = '/v1/chat/completions'
|
|
22
|
+
MODELS_URL = '/v1/models'
|
|
23
|
+
|
|
24
|
+
# Inicializa o provider LM Studio
|
|
25
|
+
#
|
|
26
|
+
# @param model [String] Modelo do LM Studio
|
|
27
|
+
# @param host [String] Host do LM Studio
|
|
28
|
+
# @param port [String] Porta do LM Studio
|
|
29
|
+
# @param debug [Boolean] Habilitar debug
|
|
30
|
+
def initialize(model: nil, host: nil, port: nil, debug: false)
|
|
31
|
+
super(model: model, debug: debug)
|
|
32
|
+
@host = host || DEFAULT_HOST
|
|
33
|
+
@port = port || DEFAULT_PORT
|
|
34
|
+
@base_url = "http://#{@host}:#{@port}"
|
|
35
|
+
@api_key = 'lm-studio' # LM Studio não requer API key real
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Chama a API do LM Studio
|
|
39
|
+
#
|
|
40
|
+
# @param system_prompt [String] System prompt
|
|
41
|
+
# @param messages [Array] Histórico de mensagens
|
|
42
|
+
# @param tools [Array] Tools disponíveis
|
|
43
|
+
# @param stream [Boolean] Habilitar streaming
|
|
44
|
+
# @yield [Hash] Chunk de streaming
|
|
45
|
+
# @return [OpenStruct] Resposta
|
|
46
|
+
def call(system_prompt:, messages:, tools:, stream: false, &block)
|
|
47
|
+
validate_lmstudio_available!
|
|
48
|
+
|
|
49
|
+
payload = build_payload(system_prompt, messages, stream)
|
|
50
|
+
|
|
51
|
+
log_debug("Calling LM Studio API with model: #{@model}")
|
|
52
|
+
log_debug("Messages count: #{messages.count}")
|
|
53
|
+
|
|
54
|
+
if stream
|
|
55
|
+
stream_request(payload, &block)
|
|
56
|
+
else
|
|
57
|
+
sync_request(payload)
|
|
58
|
+
end
|
|
59
|
+
rescue => e
|
|
60
|
+
log_debug("Error: #{e.message}")
|
|
61
|
+
raise LMStudioError, "LM Studio API error: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Verifica se está configurado (LM Studio sempre "configurado" pois é local)
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean] true
|
|
67
|
+
def configured?
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Lista modelos disponíveis
|
|
72
|
+
#
|
|
73
|
+
# @return [Array<String>] Lista de modelos
|
|
74
|
+
def list_models
|
|
75
|
+
uri = URI("#{@base_url}#{MODELS_URL}")
|
|
76
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
77
|
+
|
|
78
|
+
request = Net::HTTP::Get.new(uri.path)
|
|
79
|
+
response = http.request(request)
|
|
80
|
+
|
|
81
|
+
if response.code == '200'
|
|
82
|
+
data = JSON.parse(response.body)
|
|
83
|
+
data['data']&.map { |m| m['id'] } || []
|
|
84
|
+
else
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
rescue => e
|
|
88
|
+
log_debug("Error listing models: #{e.message}")
|
|
89
|
+
[]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Verifica se um modelo está disponível
|
|
93
|
+
#
|
|
94
|
+
# @param model [String] Nome do modelo
|
|
95
|
+
# @return [Boolean] true se disponível
|
|
96
|
+
def model_available?(model)
|
|
97
|
+
list_models.include?(model)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Verifica se LM Studio está rodando
|
|
101
|
+
#
|
|
102
|
+
# @return [Boolean] true se disponível
|
|
103
|
+
def available?
|
|
104
|
+
uri = URI("#{@base_url}#{MODELS_URL}")
|
|
105
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
106
|
+
http.read_timeout = 5
|
|
107
|
+
|
|
108
|
+
request = Net::HTTP::Get.new(uri.path)
|
|
109
|
+
response = http.request(request)
|
|
110
|
+
|
|
111
|
+
response.code == '200'
|
|
112
|
+
rescue
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Valida se LM Studio está disponível
|
|
119
|
+
#
|
|
120
|
+
# @raise [RuntimeError] Se LM Studio não estiver disponível
|
|
121
|
+
def validate_lmstudio_available!
|
|
122
|
+
raise "LM Studio not available at #{@base_url}" unless available?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Constrói o payload da requisição
|
|
126
|
+
#
|
|
127
|
+
# @param system_prompt [String] System prompt
|
|
128
|
+
# @param messages [Array] Mensagens
|
|
129
|
+
# @param stream [Boolean] Streaming
|
|
130
|
+
# @return [Hash] Payload
|
|
131
|
+
def build_payload(system_prompt, messages, stream)
|
|
132
|
+
formatted_messages = [{ role: 'system', content: system_prompt }] +
|
|
133
|
+
messages.map { |m| { role: m[:role], content: m[:content] } }
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
model: @model,
|
|
137
|
+
messages: formatted_messages,
|
|
138
|
+
stream: stream,
|
|
139
|
+
temperature: 0.7,
|
|
140
|
+
max_tokens: -1 # Ilimitado
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Faz requisição síncrona
|
|
145
|
+
#
|
|
146
|
+
# @param payload [Hash] Payload da requisição
|
|
147
|
+
# @return [OpenStruct] Resposta
|
|
148
|
+
def sync_request(payload)
|
|
149
|
+
uri = URI("#{@base_url}#{API_URL}")
|
|
150
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
151
|
+
http.use_ssl = false
|
|
152
|
+
|
|
153
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
154
|
+
request['Content-Type'] = 'application/json'
|
|
155
|
+
request.body = JSON.generate(payload)
|
|
156
|
+
|
|
157
|
+
log_debug("Sending request to #{uri}")
|
|
158
|
+
|
|
159
|
+
response = http.request(request)
|
|
160
|
+
log_debug("Response status: #{response.code}")
|
|
161
|
+
|
|
162
|
+
if response.code == '200'
|
|
163
|
+
parse_response(JSON.parse(response.body))
|
|
164
|
+
else
|
|
165
|
+
raise LMStudioError, "API error: #{response.code} - #{response.body}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Faz requisição com streaming
|
|
170
|
+
#
|
|
171
|
+
# @param payload [Hash] Payload da requisição
|
|
172
|
+
# @yield [Hash] Chunk de streaming
|
|
173
|
+
# @return [OpenStruct] Resposta
|
|
174
|
+
def stream_request(payload, &block)
|
|
175
|
+
uri = URI("#{@base_url}#{API_URL}")
|
|
176
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
177
|
+
http.use_ssl = false
|
|
178
|
+
|
|
179
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
180
|
+
request['Content-Type'] = 'application/json'
|
|
181
|
+
request.body = JSON.generate(payload)
|
|
182
|
+
|
|
183
|
+
content = +''
|
|
184
|
+
|
|
185
|
+
http.request(request) do |response|
|
|
186
|
+
if response.code == '200'
|
|
187
|
+
response.read_body do |chunk|
|
|
188
|
+
chunk.each_line do |line|
|
|
189
|
+
line = line.strip
|
|
190
|
+
next if line.empty?
|
|
191
|
+
next unless line.start_with?('data: ')
|
|
192
|
+
|
|
193
|
+
data = line[6..-1]
|
|
194
|
+
next if data == '[DONE]'
|
|
195
|
+
|
|
196
|
+
begin
|
|
197
|
+
event = JSON.parse(data)
|
|
198
|
+
yield(event) if block_given?
|
|
199
|
+
|
|
200
|
+
choice = event.dig(:choices, 0)
|
|
201
|
+
if choice
|
|
202
|
+
delta = choice[:delta]
|
|
203
|
+
content << delta[:content].to_s if delta && delta[:content]
|
|
204
|
+
end
|
|
205
|
+
rescue JSON::ParserError
|
|
206
|
+
# Ignora linhas inválidas
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
else
|
|
211
|
+
raise LMStudioError, "API error: #{response.code}"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
OpenStruct.new(
|
|
216
|
+
content: content,
|
|
217
|
+
tool_calls: [],
|
|
218
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
219
|
+
raw: {}
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Parseia a resposta da API
|
|
224
|
+
#
|
|
225
|
+
# @param data [Hash] Dados da resposta
|
|
226
|
+
# @return [OpenStruct] Resposta parseada
|
|
227
|
+
def parse_response(data)
|
|
228
|
+
content = extract_text(data)
|
|
229
|
+
tool_calls = extract_tool_calls(data)
|
|
230
|
+
usage = extract_usage(data)
|
|
231
|
+
|
|
232
|
+
OpenStruct.new(
|
|
233
|
+
content: content,
|
|
234
|
+
tool_calls: tool_calls,
|
|
235
|
+
usage: usage,
|
|
236
|
+
raw: data
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Extrai texto da resposta
|
|
241
|
+
#
|
|
242
|
+
# @param data [Hash] Dados da resposta
|
|
243
|
+
# @return [String] Texto extraído
|
|
244
|
+
def extract_text(data)
|
|
245
|
+
choices = data['choices'] || []
|
|
246
|
+
return '' if choices.empty?
|
|
247
|
+
|
|
248
|
+
message = choices[0]['message'] || {}
|
|
249
|
+
message['content'] || ''
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Extrai tool calls da resposta
|
|
253
|
+
#
|
|
254
|
+
# @param data [Hash] Dados da resposta
|
|
255
|
+
# @return [Array] Tool calls
|
|
256
|
+
def extract_tool_calls(data)
|
|
257
|
+
# LM Studio não suporta tool calls nativamente
|
|
258
|
+
[]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Extrai usage da resposta
|
|
262
|
+
#
|
|
263
|
+
# @param data [Hash] Dados da resposta
|
|
264
|
+
# @return [Hash] Usage data
|
|
265
|
+
def extract_usage(data)
|
|
266
|
+
usage = data['usage'] || {}
|
|
267
|
+
{
|
|
268
|
+
input_tokens: usage['prompt_tokens'] || 0,
|
|
269
|
+
output_tokens: usage['completion_tokens'] || 0,
|
|
270
|
+
total_tokens: usage['total_tokens'] || 0
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Erro customizado para LM Studio
|
|
276
|
+
class LMStudioError < StandardError; end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Gsd
|
|
7
|
+
module AI
|
|
8
|
+
module Providers
|
|
9
|
+
# Ollama Provider - Integração com Ollama (LLMs locais)
|
|
10
|
+
#
|
|
11
|
+
# Suporta:
|
|
12
|
+
# - Llama 2
|
|
13
|
+
# - Mistral
|
|
14
|
+
# - Codellama
|
|
15
|
+
# - Qualquer modelo rodando no Ollama
|
|
16
|
+
# - Streaming de respostas
|
|
17
|
+
# - Modelos multimodais (LLaVA)
|
|
18
|
+
# - Fallback automático
|
|
19
|
+
class Ollama < Base
|
|
20
|
+
PROVIDER_NAME = 'Ollama'
|
|
21
|
+
DEFAULT_MODEL = 'llama2'
|
|
22
|
+
DEFAULT_HOST = 'localhost'
|
|
23
|
+
DEFAULT_PORT = '11434'
|
|
24
|
+
API_URL = '/api/generate'
|
|
25
|
+
CHAT_URL = '/api/chat'
|
|
26
|
+
MODELS_URL = '/api/tags'
|
|
27
|
+
|
|
28
|
+
# Modelos recomendados
|
|
29
|
+
RECOMMENDED_MODELS = [
|
|
30
|
+
'llama2',
|
|
31
|
+
'llama2:70b',
|
|
32
|
+
'mistral',
|
|
33
|
+
'codellama',
|
|
34
|
+
'codellama:34b',
|
|
35
|
+
'phi',
|
|
36
|
+
'gemma:7b',
|
|
37
|
+
'llava' # Multimodal
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Inicializa o provider Ollama
|
|
41
|
+
#
|
|
42
|
+
# @param model [String] Modelo do Ollama
|
|
43
|
+
# @param host [String] Host do Ollama
|
|
44
|
+
# @param port [String] Porta do Ollama
|
|
45
|
+
# @param debug [Boolean] Habilitar debug
|
|
46
|
+
# @param auto_pull [Boolean] Auto pull de modelos ausentes
|
|
47
|
+
def initialize(model: nil, host: nil, port: nil, debug: false, auto_pull: false)
|
|
48
|
+
super(model: model, debug: debug)
|
|
49
|
+
@host = host || DEFAULT_HOST
|
|
50
|
+
@port = port || DEFAULT_PORT
|
|
51
|
+
@base_url = "http://#{@host}:#{@port}"
|
|
52
|
+
@auto_pull = auto_pull
|
|
53
|
+
@token_cache = {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Chama a API do Ollama
|
|
57
|
+
#
|
|
58
|
+
# @param system_prompt [String] System prompt
|
|
59
|
+
# @param messages [Array] Histórico de mensagens
|
|
60
|
+
# @param tools [Array] Tools disponíveis
|
|
61
|
+
# @param stream [Boolean] Habilitar streaming
|
|
62
|
+
# @yield [Hash] Chunk de streaming
|
|
63
|
+
# @return [OpenStruct] Resposta
|
|
64
|
+
def call(system_prompt:, messages:, tools:, stream: false, &block)
|
|
65
|
+
validate_ollama_available!
|
|
66
|
+
|
|
67
|
+
payload = build_payload(system_prompt, messages, stream)
|
|
68
|
+
|
|
69
|
+
log_debug("Calling Ollama API with model: #{@model}")
|
|
70
|
+
log_debug("Messages count: #{messages.count}")
|
|
71
|
+
|
|
72
|
+
if stream
|
|
73
|
+
stream_request(payload, &block)
|
|
74
|
+
else
|
|
75
|
+
sync_request(payload)
|
|
76
|
+
end
|
|
77
|
+
rescue => e
|
|
78
|
+
log_debug("Error: #{e.message}")
|
|
79
|
+
raise OllamaError, "Ollama API error: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Verifica se está configurado (Ollama sempre "configurado" pois é local)
|
|
83
|
+
#
|
|
84
|
+
# @return [Boolean] true
|
|
85
|
+
def configured?
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Lista modelos disponíveis
|
|
90
|
+
#
|
|
91
|
+
# @return [Array<String>] Lista de modelos
|
|
92
|
+
def list_models
|
|
93
|
+
uri = URI("#{@base_url}/api/tags")
|
|
94
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
95
|
+
|
|
96
|
+
request = Net::HTTP::Get.new(uri.path)
|
|
97
|
+
response = http.request(request)
|
|
98
|
+
|
|
99
|
+
if response.code == '200'
|
|
100
|
+
data = JSON.parse(response.body)
|
|
101
|
+
data['models']&.map { |m| m['name'] } || []
|
|
102
|
+
else
|
|
103
|
+
[]
|
|
104
|
+
end
|
|
105
|
+
rescue => e
|
|
106
|
+
log_debug("Error listing models: #{e.message}")
|
|
107
|
+
[]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Verifica se um modelo está disponível
|
|
111
|
+
#
|
|
112
|
+
# @param model [String] Nome do modelo
|
|
113
|
+
# @return [Boolean] true se disponível
|
|
114
|
+
def model_available?(model)
|
|
115
|
+
list_models.include?(model)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Conta tokens de forma precisa
|
|
119
|
+
#
|
|
120
|
+
# @param text [String] Texto para contar
|
|
121
|
+
# @return [Integer] Número de tokens
|
|
122
|
+
def count_tokens(text)
|
|
123
|
+
return @token_cache[text] if @token_cache[text]
|
|
124
|
+
|
|
125
|
+
# Estimativa: ~4 caracteres por token
|
|
126
|
+
estimated = text.length / 4
|
|
127
|
+
@token_cache[text] = estimated
|
|
128
|
+
estimated
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Limpa o cache de tokens
|
|
132
|
+
#
|
|
133
|
+
# @return [void]
|
|
134
|
+
def clear_token_cache
|
|
135
|
+
@token_cache = {}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Formata imagem para API do Ollama (LLaVA)
|
|
139
|
+
#
|
|
140
|
+
# @param image_path [String] Caminho da imagem ou base64
|
|
141
|
+
# @return [String] Base64 da imagem
|
|
142
|
+
def format_image(image_path)
|
|
143
|
+
if image_path.start_with?('http')
|
|
144
|
+
# Download e conversão
|
|
145
|
+
require 'open-uri'
|
|
146
|
+
image_data = URI.open(image_path).read
|
|
147
|
+
elsif File.exist?(image_path)
|
|
148
|
+
image_data = File.read(image_path, mode: 'rb')
|
|
149
|
+
else
|
|
150
|
+
# Assume que já é base64
|
|
151
|
+
return image_path
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
[image_data].pack('m0') # Base64
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Verifica se modelo é multimodal
|
|
158
|
+
#
|
|
159
|
+
# @param model [String] Nome do modelo
|
|
160
|
+
# @return [Boolean] true se é multimodal
|
|
161
|
+
def multimodal?(model)
|
|
162
|
+
model.downcase.include?('llava') ||
|
|
163
|
+
model.downcase.include?('bakllava') ||
|
|
164
|
+
model.downcase.include?('vision')
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Faz pull do modelo se não existir
|
|
168
|
+
#
|
|
169
|
+
# @param model [String] Nome do modelo
|
|
170
|
+
# @return [void]
|
|
171
|
+
def pull_model_if_needed(model)
|
|
172
|
+
return if model_available?(model)
|
|
173
|
+
|
|
174
|
+
if @auto_pull
|
|
175
|
+
log_debug("Pulling model #{model}...")
|
|
176
|
+
# Implementação do pull seria adicionada aqui
|
|
177
|
+
else
|
|
178
|
+
raise OllamaError, "Model #{model} not available. Enable auto_pull or run: ollama pull #{model}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# Valida se Ollama está disponível
|
|
185
|
+
#
|
|
186
|
+
# @raise [RuntimeError] Se Ollama não estiver disponível
|
|
187
|
+
def validate_ollama_available!
|
|
188
|
+
uri = URI("#{@base_url}/api/tags")
|
|
189
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
190
|
+
http.read_timeout = 5
|
|
191
|
+
|
|
192
|
+
request = Net::HTTP::Get.new(uri.path)
|
|
193
|
+
response = http.request(request)
|
|
194
|
+
|
|
195
|
+
raise "Ollama not available at #{@base_url}" unless response.code == '200'
|
|
196
|
+
rescue => e
|
|
197
|
+
raise "Ollama not available: #{e.message}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Constrói o payload da requisição
|
|
201
|
+
#
|
|
202
|
+
# @param system_prompt [String] System prompt
|
|
203
|
+
# @param messages [Array] Mensagens
|
|
204
|
+
# @param stream [Boolean] Streaming
|
|
205
|
+
# @return [Hash] Payload
|
|
206
|
+
def build_payload(system_prompt, messages, stream)
|
|
207
|
+
# Ollama usa formato diferente para chat
|
|
208
|
+
formatted_messages = messages.map do |msg|
|
|
209
|
+
{
|
|
210
|
+
role: msg[:role],
|
|
211
|
+
content: msg[:content]
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Adiciona system prompt como primeira mensagem se não existir
|
|
216
|
+
if formatted_messages.empty? || formatted_messages.first[:role] != 'system'
|
|
217
|
+
formatted_messages.unshift({ role: 'system', content: system_prompt })
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
{
|
|
221
|
+
model: @model,
|
|
222
|
+
messages: formatted_messages,
|
|
223
|
+
stream: stream,
|
|
224
|
+
options: {
|
|
225
|
+
temperature: 0.7,
|
|
226
|
+
top_p: 0.9
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Faz requisição síncrona
|
|
232
|
+
#
|
|
233
|
+
# @param payload [Hash] Payload da requisição
|
|
234
|
+
# @return [OpenStruct] Resposta
|
|
235
|
+
def sync_request(payload)
|
|
236
|
+
uri = URI("#{@base_url}#{CHAT_URL}")
|
|
237
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
238
|
+
http.use_ssl = false
|
|
239
|
+
|
|
240
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
241
|
+
request['Content-Type'] = 'application/json'
|
|
242
|
+
request.body = JSON.generate(payload)
|
|
243
|
+
|
|
244
|
+
log_debug("Sending request to #{uri}")
|
|
245
|
+
|
|
246
|
+
response = http.request(request)
|
|
247
|
+
log_debug("Response status: #{response.code}")
|
|
248
|
+
|
|
249
|
+
if response.code == '200'
|
|
250
|
+
parse_response(JSON.parse(response.body))
|
|
251
|
+
else
|
|
252
|
+
raise OllamaError, "API error: #{response.code} - #{response.body}"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Faz requisição com streaming
|
|
257
|
+
#
|
|
258
|
+
# @param payload [Hash] Payload da requisição
|
|
259
|
+
# @yield [Hash] Chunk de streaming
|
|
260
|
+
# @return [OpenStruct] Resposta
|
|
261
|
+
def stream_request(payload, &block)
|
|
262
|
+
# Para streaming, precisamos ler a resposta chunk por chunk
|
|
263
|
+
# Cada linha é um JSON completo
|
|
264
|
+
uri = URI("#{@base_url}#{CHAT_URL}")
|
|
265
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
266
|
+
http.use_ssl = false
|
|
267
|
+
|
|
268
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
269
|
+
request['Content-Type'] = 'application/json'
|
|
270
|
+
request.body = JSON.generate(payload)
|
|
271
|
+
|
|
272
|
+
content = +''
|
|
273
|
+
total_duration = 0
|
|
274
|
+
|
|
275
|
+
http.request(request) do |response|
|
|
276
|
+
if response.code == '200'
|
|
277
|
+
response.read_body do |chunk|
|
|
278
|
+
chunk.each_line do |line|
|
|
279
|
+
begin
|
|
280
|
+
data = JSON.parse(line.strip)
|
|
281
|
+
if block_given?
|
|
282
|
+
yield(data)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
if data['message']
|
|
286
|
+
content << data['message']['content'].to_s
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
total_duration += data['total_duration'].to_i
|
|
290
|
+
rescue JSON::ParserError
|
|
291
|
+
# Ignora linhas inválidas
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
else
|
|
296
|
+
raise OllamaError, "API error: #{response.code}"
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
OpenStruct.new(
|
|
301
|
+
content: content,
|
|
302
|
+
tool_calls: [],
|
|
303
|
+
usage: {
|
|
304
|
+
input_tokens: 0,
|
|
305
|
+
output_tokens: 0,
|
|
306
|
+
total_duration: total_duration
|
|
307
|
+
},
|
|
308
|
+
raw: {}
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Parseia a resposta da API
|
|
313
|
+
#
|
|
314
|
+
# @param data [Hash] Dados da resposta
|
|
315
|
+
# @return [OpenStruct] Resposta parseada
|
|
316
|
+
def parse_response(data)
|
|
317
|
+
content = data.dig('message', 'content') || ''
|
|
318
|
+
|
|
319
|
+
OpenStruct.new(
|
|
320
|
+
content: content,
|
|
321
|
+
tool_calls: [],
|
|
322
|
+
usage: {
|
|
323
|
+
input_tokens: 0,
|
|
324
|
+
output_tokens: 0,
|
|
325
|
+
total_duration: data['total_duration'].to_i
|
|
326
|
+
},
|
|
327
|
+
raw: data
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Erro customizado para Ollama
|
|
333
|
+
class OllamaError < StandardError; end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|