htm 0.0.30 → 0.0.32
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/.irbrc +2 -3
- data/.rubocop.yml +184 -0
- data/CHANGELOG.md +46 -0
- data/README.md +2 -0
- data/Rakefile +93 -12
- data/db/migrate/00008_create_node_relationships.rb +54 -0
- data/db/migrate/00009_fix_node_relationships_column_types.rb +17 -0
- data/db/schema.sql +124 -1
- data/docs/api/database.md +35 -57
- data/docs/api/embedding-service.md +1 -1
- data/docs/api/index.md +26 -15
- data/docs/api/working-memory.md +8 -8
- data/docs/architecture/index.md +5 -7
- data/docs/architecture/overview.md +5 -8
- data/docs/assets/images/htm-architecture-overview.svg +1 -1
- data/docs/assets/images/htm-context-assembly-flow.svg +2 -2
- data/docs/assets/images/htm-layered-architecture.svg +3 -3
- data/docs/assets/images/two-tier-memory-architecture.svg +1 -1
- data/docs/database/README.md +1 -0
- data/docs/database_rake_tasks.md +20 -28
- data/docs/development/contributing.md +5 -5
- data/docs/development/index.md +4 -7
- data/docs/development/schema.md +71 -1
- data/docs/development/setup.md +40 -82
- data/docs/development/testing.md +1 -1
- data/docs/examples/file-loading.md +4 -4
- data/docs/examples/mcp-client.md +1 -1
- data/docs/getting-started/quick-start.md +4 -4
- data/docs/guides/adding-memories.md +14 -1
- data/docs/guides/configuration.md +5 -5
- data/docs/guides/context-assembly.md +4 -4
- data/docs/guides/file-loading.md +12 -12
- data/docs/guides/getting-started.md +2 -2
- data/docs/guides/long-term-memory.md +7 -27
- data/docs/guides/propositions.md +20 -19
- data/docs/guides/recalling-memories.md +5 -5
- data/docs/guides/tags.md +18 -13
- data/docs/multi_framework_support.md +1 -1
- data/docs/robots/hive-mind.md +1 -1
- data/docs/robots/multi-robot.md +2 -2
- data/docs/robots/robot-groups.md +1 -1
- data/docs/robots/two-tier-memory.md +72 -94
- data/docs/setup_local_database.md +8 -54
- data/docs/using_rake_tasks_in_your_app.md +6 -6
- data/examples/01_basic_usage.rb +1 -0
- data/examples/03_custom_llm_configuration.rb +1 -0
- data/examples/04_file_loader_usage.rb +1 -0
- data/examples/05_timeframe_demo.rb +1 -0
- data/examples/06_example_app/app.rb +1 -0
- data/examples/07_cli_app/htm_cli.rb +1 -0
- data/examples/09_mcp_client.rb +1 -0
- data/examples/10_telemetry/demo.rb +1 -0
- data/examples/11_robot_groups/multi_process.rb +1 -0
- data/examples/11_robot_groups/same_process.rb +1 -0
- data/examples/12_rails_app/.envrc +12 -0
- data/examples/12_rails_app/Gemfile +8 -3
- data/examples/12_rails_app/Gemfile.lock +94 -89
- data/examples/12_rails_app/README.md +70 -19
- data/examples/12_rails_app/app/controllers/application_controller.rb +6 -0
- data/examples/12_rails_app/app/controllers/chats_controller.rb +305 -0
- data/examples/12_rails_app/app/controllers/dashboard_controller.rb +3 -0
- data/examples/12_rails_app/app/controllers/files_controller.rb +17 -2
- data/examples/12_rails_app/app/controllers/home_controller.rb +8 -0
- data/examples/12_rails_app/app/controllers/memories_controller.rb +9 -4
- data/examples/12_rails_app/app/controllers/messages_controller.rb +214 -0
- data/examples/12_rails_app/app/controllers/robots_controller.rb +11 -1
- data/examples/12_rails_app/app/controllers/tags_controller.rb +14 -1
- data/examples/12_rails_app/app/javascript/application.js +1 -1
- data/examples/12_rails_app/app/models/application_record.rb +5 -0
- data/examples/12_rails_app/app/models/chat.rb +36 -0
- data/examples/12_rails_app/app/models/message.rb +5 -0
- data/examples/12_rails_app/app/models/model.rb +5 -0
- data/examples/12_rails_app/app/models/tool_call.rb +5 -0
- data/examples/12_rails_app/app/views/chats/index.html.erb +61 -0
- data/examples/12_rails_app/app/views/chats/show.html.erb +213 -0
- data/examples/12_rails_app/app/views/dashboard/index.html.erb +3 -0
- data/examples/12_rails_app/app/views/files/index.html.erb +10 -5
- data/examples/12_rails_app/app/views/files/new.html.erb +4 -2
- data/examples/12_rails_app/app/views/files/show.html.erb +19 -3
- data/examples/12_rails_app/app/views/home/index.html.erb +45 -0
- data/examples/12_rails_app/app/views/layouts/application.html.erb +20 -18
- data/examples/12_rails_app/app/views/memories/_memory_card.html.erb +1 -1
- data/examples/12_rails_app/app/views/memories/deleted.html.erb +3 -1
- data/examples/12_rails_app/app/views/memories/edit.html.erb +2 -0
- data/examples/12_rails_app/app/views/memories/index.html.erb +2 -0
- data/examples/12_rails_app/app/views/memories/new.html.erb +2 -0
- data/examples/12_rails_app/app/views/memories/show.html.erb +4 -2
- data/examples/12_rails_app/app/views/messages/_message.html.erb +20 -0
- data/examples/12_rails_app/app/views/robots/index.html.erb +2 -0
- data/examples/12_rails_app/app/views/robots/new.html.erb +2 -0
- data/examples/12_rails_app/app/views/robots/show.html.erb +2 -0
- data/examples/12_rails_app/app/views/search/index.html.erb +59 -8
- data/examples/12_rails_app/app/views/shared/_navbar.html.erb +75 -29
- data/examples/12_rails_app/app/views/tags/index.html.erb +2 -0
- data/examples/12_rails_app/app/views/tags/show.html.erb +3 -1
- data/examples/12_rails_app/config/application.rb +1 -1
- data/examples/12_rails_app/config/database.yml +9 -5
- data/examples/12_rails_app/config/importmap.rb +1 -1
- data/examples/12_rails_app/config/initializers/htm.rb +9 -2
- data/examples/12_rails_app/config/initializers/ruby_llm.rb +33 -0
- data/examples/12_rails_app/config/routes.rb +39 -23
- data/examples/12_rails_app/db/migrate/20250124000001_create_ruby_llm_tables.rb +34 -0
- data/examples/12_rails_app/db/migrate/20250124000002_create_models_table.rb +28 -0
- data/examples/12_rails_app/db/schema.rb +67 -0
- data/examples/examples_helper.rb +25 -0
- data/lib/htm/circuit_breaker.rb +5 -6
- data/lib/htm/config/builder.rb +12 -12
- data/lib/htm/config/database.rb +21 -27
- data/lib/htm/config/defaults.yml +25 -13
- data/lib/htm/config/validator.rb +12 -18
- data/lib/htm/config.rb +93 -173
- data/lib/htm/database.rb +193 -199
- data/lib/htm/embedding_service.rb +4 -9
- data/lib/htm/integrations/sinatra.rb +7 -7
- data/lib/htm/job_adapter.rb +14 -21
- data/lib/htm/jobs/generate_embedding_job.rb +28 -44
- data/lib/htm/jobs/generate_propositions_job.rb +29 -55
- data/lib/htm/jobs/generate_relationships_job.rb +137 -0
- data/lib/htm/jobs/generate_tags_job.rb +45 -67
- data/lib/htm/loaders/markdown_loader.rb +65 -112
- data/lib/htm/long_term_memory/fulltext_search.rb +1 -1
- data/lib/htm/long_term_memory/hybrid_search.rb +300 -128
- data/lib/htm/long_term_memory/node_operations.rb +2 -2
- data/lib/htm/long_term_memory/relevance_scorer.rb +100 -68
- data/lib/htm/long_term_memory/tag_operations.rb +87 -120
- data/lib/htm/long_term_memory/vector_search.rb +1 -1
- data/lib/htm/long_term_memory.rb +2 -1
- data/lib/htm/mcp/cli.rb +59 -58
- data/lib/htm/mcp/server.rb +5 -6
- data/lib/htm/mcp/tools.rb +30 -36
- data/lib/htm/migration.rb +10 -10
- data/lib/htm/models/node.rb +2 -3
- data/lib/htm/models/node_relationship.rb +72 -0
- data/lib/htm/models/node_tag.rb +2 -2
- data/lib/htm/models/robot_node.rb +2 -2
- data/lib/htm/models/tag.rb +41 -28
- data/lib/htm/observability.rb +45 -51
- data/lib/htm/proposition_service.rb +3 -7
- data/lib/htm/query_cache.rb +13 -15
- data/lib/htm/railtie.rb +1 -2
- data/lib/htm/robot_group.rb +9 -9
- data/lib/htm/sequel_config.rb +1 -0
- data/lib/htm/sql_builder.rb +1 -1
- data/lib/htm/tag_service.rb +2 -6
- data/lib/htm/timeframe.rb +4 -5
- data/lib/htm/timeframe_extractor.rb +42 -83
- data/lib/htm/version.rb +1 -1
- data/lib/htm/workflows/remember_workflow.rb +112 -115
- data/lib/htm/working_memory.rb +21 -26
- data/lib/htm.rb +103 -116
- data/lib/tasks/db.rake +0 -2
- data/lib/tasks/doc.rake +14 -13
- data/lib/tasks/files.rake +5 -12
- data/lib/tasks/htm.rake +70 -71
- data/lib/tasks/jobs.rake +41 -47
- data/lib/tasks/tags.rake +3 -8
- metadata +28 -106
- data/lib/htm/config/section.rb +0 -74
- data/lib/htm/loaders/defaults_loader.rb +0 -166
- data/lib/htm/loaders/xdg_config_loader.rb +0 -116
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
class ChatsController < ApplicationController
|
|
7
|
+
def index
|
|
8
|
+
@chats = Chat.order(updated_at: :desc).limit(20)
|
|
9
|
+
@current_chat = Chat.find_by(id: session[:chat_id])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
@chat = Chat.find(params[:id])
|
|
14
|
+
@messages = @chat.messages.order(:created_at)
|
|
15
|
+
@available_providers = detect_available_providers
|
|
16
|
+
current_provider = @chat.model&.provider || 'ollama'
|
|
17
|
+
@available_models = fetch_models_for_provider(current_provider)
|
|
18
|
+
@htm_settings = htm_settings
|
|
19
|
+
session[:chat_id] = @chat.id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create
|
|
23
|
+
@chat = Chat.new
|
|
24
|
+
@chat.assume_model_exists = true
|
|
25
|
+
|
|
26
|
+
# Use remembered model for default provider, or fall back to env default
|
|
27
|
+
default_provider = 'ollama'
|
|
28
|
+
remembered_model = provider_model_memory[default_provider]
|
|
29
|
+
default_model = remembered_model || ENV.fetch('CHAT_MODEL', 'gemma3:latest')
|
|
30
|
+
|
|
31
|
+
@chat.model = default_model
|
|
32
|
+
@chat.provider = default_provider
|
|
33
|
+
@chat.save!
|
|
34
|
+
|
|
35
|
+
# Remember this model for the provider
|
|
36
|
+
remember_model_for_provider(default_provider, default_model)
|
|
37
|
+
|
|
38
|
+
session[:chat_id] = @chat.id
|
|
39
|
+
redirect_to chat_path(@chat)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def update
|
|
43
|
+
@chat = Chat.find(params[:id])
|
|
44
|
+
|
|
45
|
+
# Handle provider change
|
|
46
|
+
if params[:provider].present?
|
|
47
|
+
new_provider = params[:provider]
|
|
48
|
+
models = fetch_models_for_provider(new_provider)
|
|
49
|
+
|
|
50
|
+
# Restore last-used model for this provider, or use first available
|
|
51
|
+
remembered_model = provider_model_memory[new_provider]
|
|
52
|
+
selected_model = if remembered_model && models.include?(remembered_model)
|
|
53
|
+
remembered_model
|
|
54
|
+
else
|
|
55
|
+
models.first || "#{new_provider}-default"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
model = Model.find_or_create_by!(model_id: selected_model, provider: new_provider) do |m|
|
|
59
|
+
m.name = selected_model
|
|
60
|
+
end
|
|
61
|
+
@chat.update_column(:model_id, model.id)
|
|
62
|
+
|
|
63
|
+
# Remember this model for the provider
|
|
64
|
+
remember_model_for_provider(new_provider, selected_model)
|
|
65
|
+
|
|
66
|
+
# Handle model change
|
|
67
|
+
elsif params[:model_id].present?
|
|
68
|
+
current_provider = @chat.model&.provider || 'ollama'
|
|
69
|
+
model = Model.find_or_create_by!(model_id: params[:model_id], provider: current_provider) do |m|
|
|
70
|
+
m.name = params[:model_id]
|
|
71
|
+
end
|
|
72
|
+
@chat.update_column(:model_id, model.id)
|
|
73
|
+
|
|
74
|
+
# Remember this model for the provider
|
|
75
|
+
remember_model_for_provider(current_provider, params[:model_id])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
redirect_to chat_path(@chat)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# API endpoint to get models for a provider (used by JavaScript)
|
|
82
|
+
def models
|
|
83
|
+
provider = params[:provider] || 'ollama'
|
|
84
|
+
models = fetch_models_for_provider(provider)
|
|
85
|
+
render json: models
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def destroy
|
|
89
|
+
Chat.find(params[:id]).destroy
|
|
90
|
+
session.delete(:chat_id) if session[:chat_id] == params[:id].to_i
|
|
91
|
+
redirect_to app_root_path, notice: 'Chat deleted'
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def update_htm_settings
|
|
95
|
+
@chat = Chat.find(params[:id])
|
|
96
|
+
|
|
97
|
+
# Update HTM settings in session
|
|
98
|
+
limit_param = params[:htm_limit]
|
|
99
|
+
limit_value = limit_param == 'all' ? 'all' : (limit_param || 5).to_i
|
|
100
|
+
|
|
101
|
+
session[:htm_settings] = {
|
|
102
|
+
strategy: params[:htm_strategy] || 'hybrid',
|
|
103
|
+
limit: limit_value,
|
|
104
|
+
propositions: params[:htm_propositions] == '1',
|
|
105
|
+
tags_only: params[:htm_tags_only] == '1',
|
|
106
|
+
tag_filter: params[:htm_tag_filter].presence
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
Rails.logger.info "Updated HTM settings: #{session[:htm_settings].inspect}"
|
|
110
|
+
redirect_to chat_path(@chat)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def detect_available_providers
|
|
116
|
+
providers = []
|
|
117
|
+
|
|
118
|
+
# Use RubyLLM's provider registry to dynamically detect available providers
|
|
119
|
+
RubyLLM.providers.each do |provider_class|
|
|
120
|
+
provider_id = provider_class.name.split('::').last.downcase
|
|
121
|
+
display_name = provider_display_name(provider_id)
|
|
122
|
+
|
|
123
|
+
if provider_class.local?
|
|
124
|
+
# Local providers: check if service is reachable
|
|
125
|
+
providers << { id: provider_id, name: "#{display_name} (Local)" } if local_provider_available?(provider_id)
|
|
126
|
+
else
|
|
127
|
+
# Cloud providers: check if required API key env var is set
|
|
128
|
+
providers << { id: provider_id, name: display_name } if cloud_provider_configured?(provider_class)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Add custom local providers not in RubyLLM registry (use OpenAI-compatible API)
|
|
133
|
+
providers << { id: 'lmstudio', name: 'LM Studio (Local)' } if lmstudio_available?
|
|
134
|
+
|
|
135
|
+
providers
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def provider_display_name(provider_id)
|
|
139
|
+
{
|
|
140
|
+
'openai' => 'OpenAI',
|
|
141
|
+
'anthropic' => 'Anthropic',
|
|
142
|
+
'gemini' => 'Google Gemini',
|
|
143
|
+
'deepseek' => 'DeepSeek',
|
|
144
|
+
'openrouter' => 'OpenRouter',
|
|
145
|
+
'ollama' => 'Ollama',
|
|
146
|
+
'gpustack' => 'GPUStack',
|
|
147
|
+
'bedrock' => 'AWS Bedrock',
|
|
148
|
+
'vertexai' => 'Google Vertex AI',
|
|
149
|
+
'mistral' => 'Mistral',
|
|
150
|
+
'perplexity' => 'Perplexity',
|
|
151
|
+
'xai' => 'xAI',
|
|
152
|
+
'lmstudio' => 'LM Studio'
|
|
153
|
+
}[provider_id] || provider_id.titleize
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def cloud_provider_configured?(provider_class)
|
|
157
|
+
# Check if all required configuration keys have corresponding env vars set
|
|
158
|
+
provider_class.configuration_requirements.all? do |config_key|
|
|
159
|
+
# Convert config key like :anthropic_api_key to env var ANTHROPIC_API_KEY
|
|
160
|
+
env_var = config_key.to_s.upcase
|
|
161
|
+
ENV[env_var].present?
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def local_provider_available?(provider_id)
|
|
166
|
+
case provider_id
|
|
167
|
+
when 'ollama'
|
|
168
|
+
ollama_available?
|
|
169
|
+
when 'gpustack'
|
|
170
|
+
gpustack_available?
|
|
171
|
+
else
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def ollama_available?
|
|
177
|
+
ollama_url = ENV.fetch('OLLAMA_URL', 'http://localhost:11434')
|
|
178
|
+
uri = URI("#{ollama_url}/api/tags")
|
|
179
|
+
response = Net::HTTP.get_response(uri)
|
|
180
|
+
response.is_a?(Net::HTTPSuccess)
|
|
181
|
+
rescue StandardError
|
|
182
|
+
false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def gpustack_available?
|
|
186
|
+
gpustack_url = ENV.fetch('GPUSTACK_API_BASE', 'http://localhost:8080')
|
|
187
|
+
uri = URI("#{gpustack_url}/v1/models")
|
|
188
|
+
response = Net::HTTP.get_response(uri)
|
|
189
|
+
response.is_a?(Net::HTTPSuccess)
|
|
190
|
+
rescue StandardError
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def lmstudio_available?
|
|
195
|
+
lmstudio_url = ENV.fetch('LMSTUDIO_URL', 'http://localhost:1234')
|
|
196
|
+
uri = URI("#{lmstudio_url}/v1/models")
|
|
197
|
+
response = Net::HTTP.get_response(uri)
|
|
198
|
+
response.is_a?(Net::HTTPSuccess)
|
|
199
|
+
rescue StandardError
|
|
200
|
+
false
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def fetch_models_for_provider(provider)
|
|
204
|
+
case provider
|
|
205
|
+
when 'ollama'
|
|
206
|
+
fetch_ollama_models
|
|
207
|
+
when 'gpustack'
|
|
208
|
+
fetch_gpustack_models
|
|
209
|
+
when 'lmstudio'
|
|
210
|
+
fetch_lmstudio_models
|
|
211
|
+
else
|
|
212
|
+
fetch_models_from_registry(provider)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Use RubyLLM's model registry for cloud providers
|
|
217
|
+
def fetch_models_from_registry(provider)
|
|
218
|
+
# Get chat models for this provider from RubyLLM registry
|
|
219
|
+
models = RubyLLM.models.by_provider(provider).chat_models
|
|
220
|
+
model_ids = models.map(&:id).sort
|
|
221
|
+
|
|
222
|
+
if model_ids.empty?
|
|
223
|
+
Rails.logger.warn "No models found in RubyLLM registry for provider: #{provider}"
|
|
224
|
+
# Return fallback models for common providers
|
|
225
|
+
fallback_models_for(provider)
|
|
226
|
+
else
|
|
227
|
+
model_ids
|
|
228
|
+
end
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
Rails.logger.warn "Failed to fetch models from registry for #{provider}: #{e.message}"
|
|
231
|
+
fallback_models_for(provider)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Fallback models when registry is empty (e.g., not refreshed)
|
|
235
|
+
def fallback_models_for(provider)
|
|
236
|
+
case provider
|
|
237
|
+
when 'openai'
|
|
238
|
+
%w[gpt-4o gpt-4o-mini gpt-4-turbo gpt-4 gpt-3.5-turbo]
|
|
239
|
+
when 'anthropic'
|
|
240
|
+
%w[claude-sonnet-4-20250514 claude-3-5-sonnet-20241022 claude-3-5-haiku-20241022 claude-3-opus-20240229]
|
|
241
|
+
when 'gemini'
|
|
242
|
+
%w[gemini-2.0-flash gemini-1.5-pro gemini-1.5-flash]
|
|
243
|
+
when 'deepseek'
|
|
244
|
+
%w[deepseek-chat deepseek-coder]
|
|
245
|
+
when 'openrouter'
|
|
246
|
+
%w[openai/gpt-4o anthropic/claude-3.5-sonnet meta-llama/llama-3-70b-instruct]
|
|
247
|
+
else
|
|
248
|
+
[]
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def fetch_ollama_models
|
|
253
|
+
ollama_url = ENV.fetch('OLLAMA_URL', 'http://localhost:11434')
|
|
254
|
+
response = Net::HTTP.get(URI("#{ollama_url}/api/tags"))
|
|
255
|
+
data = JSON.parse(response)
|
|
256
|
+
data['models'].map { |m| m['name'] }.sort
|
|
257
|
+
rescue StandardError => e
|
|
258
|
+
Rails.logger.warn "Failed to fetch Ollama models: #{e.message}"
|
|
259
|
+
[]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def fetch_gpustack_models
|
|
263
|
+
gpustack_url = ENV.fetch('GPUSTACK_API_BASE', 'http://localhost:8080')
|
|
264
|
+
response = Net::HTTP.get(URI("#{gpustack_url}/v1/models"))
|
|
265
|
+
data = JSON.parse(response)
|
|
266
|
+
data['data'].map { |m| m['id'] }.sort
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
Rails.logger.warn "Failed to fetch GPUStack models: #{e.message}"
|
|
269
|
+
[]
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def fetch_lmstudio_models
|
|
273
|
+
lmstudio_url = ENV.fetch('LMSTUDIO_URL', 'http://localhost:1234')
|
|
274
|
+
response = Net::HTTP.get(URI("#{lmstudio_url}/v1/models"))
|
|
275
|
+
data = JSON.parse(response)
|
|
276
|
+
data['data'].map { |m| m['id'] }.sort
|
|
277
|
+
rescue StandardError => e
|
|
278
|
+
Rails.logger.warn "Failed to fetch LM Studio models: #{e.message}"
|
|
279
|
+
[]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Per-provider model memory stored in session
|
|
283
|
+
def provider_model_memory
|
|
284
|
+
session[:provider_models] ||= {}
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def remember_model_for_provider(provider, model_id)
|
|
288
|
+
session[:provider_models] ||= {}
|
|
289
|
+
session[:provider_models][provider] = model_id
|
|
290
|
+
Rails.logger.info "Remembered model '#{model_id}' for provider '#{provider}'"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# HTM context enhancement settings stored in session
|
|
294
|
+
def htm_settings
|
|
295
|
+
defaults = {
|
|
296
|
+
strategy: 'hybrid',
|
|
297
|
+
limit: 5,
|
|
298
|
+
propositions: false,
|
|
299
|
+
tags_only: false,
|
|
300
|
+
tag_filter: nil
|
|
301
|
+
}
|
|
302
|
+
stored = session[:htm_settings] || {}
|
|
303
|
+
defaults.merge(stored.symbolize_keys)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
@@ -4,6 +4,9 @@ require 'ostruct'
|
|
|
4
4
|
|
|
5
5
|
class DashboardController < ApplicationController
|
|
6
6
|
def index
|
|
7
|
+
# Show current HTM database connection
|
|
8
|
+
@htm_database_url = HTM.db.opts[:orig_uri] || "#{HTM.db.opts[:adapter]}://#{HTM.db.opts[:user]}@#{HTM.db.opts[:host]}:#{HTM.db.opts[:port]}/#{HTM.db.opts[:database]}"
|
|
9
|
+
|
|
7
10
|
# Note: HTM::Models::Node has a default_scope that excludes deleted nodes
|
|
8
11
|
# so we don't need to call .active explicitly
|
|
9
12
|
@stats = {
|
|
@@ -7,7 +7,12 @@ class FilesController < ApplicationController
|
|
|
7
7
|
|
|
8
8
|
def show
|
|
9
9
|
@file_source = HTM::Models::FileSource[params[:id]]
|
|
10
|
-
|
|
10
|
+
unless @file_source
|
|
11
|
+
flash[:alert] = 'File source not found'
|
|
12
|
+
redirect_to files_path
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
@chunks = @file_source.nodes_dataset.order(:chunk_position).all
|
|
11
16
|
end
|
|
12
17
|
|
|
13
18
|
def new
|
|
@@ -128,6 +133,11 @@ class FilesController < ApplicationController
|
|
|
128
133
|
|
|
129
134
|
def sync
|
|
130
135
|
@file_source = HTM::Models::FileSource[params[:id]]
|
|
136
|
+
unless @file_source
|
|
137
|
+
flash[:alert] = 'File source not found'
|
|
138
|
+
redirect_to files_path
|
|
139
|
+
return
|
|
140
|
+
end
|
|
131
141
|
|
|
132
142
|
begin
|
|
133
143
|
result = htm.load_file(@file_source.file_path, force: true)
|
|
@@ -136,11 +146,16 @@ class FilesController < ApplicationController
|
|
|
136
146
|
flash[:alert] = "Error syncing file: #{e.message}"
|
|
137
147
|
end
|
|
138
148
|
|
|
139
|
-
redirect_to file_path(@file_source)
|
|
149
|
+
redirect_to file_path(@file_source.id)
|
|
140
150
|
end
|
|
141
151
|
|
|
142
152
|
def destroy
|
|
143
153
|
@file_source = HTM::Models::FileSource[params[:id]]
|
|
154
|
+
unless @file_source
|
|
155
|
+
flash[:alert] = 'File source not found'
|
|
156
|
+
redirect_to files_path
|
|
157
|
+
return
|
|
158
|
+
end
|
|
144
159
|
|
|
145
160
|
begin
|
|
146
161
|
htm.unload_file(@file_source.file_path)
|
|
@@ -17,7 +17,8 @@ class MemoriesController < ApplicationController
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
if params[:search].present?
|
|
20
|
-
|
|
20
|
+
search_pattern = "%#{Sequel.like_escape(params[:search])}%"
|
|
21
|
+
@memories = @memories.where(Sequel.ilike(:content, search_pattern))
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
# Simple pagination without Kaminari
|
|
@@ -67,13 +68,13 @@ class MemoriesController < ApplicationController
|
|
|
67
68
|
|
|
68
69
|
if content.blank?
|
|
69
70
|
flash[:alert] = 'Content is required'
|
|
70
|
-
redirect_to edit_memory_path(@memory)
|
|
71
|
+
redirect_to edit_memory_path(@memory.id)
|
|
71
72
|
return
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
@memory.update(content: content)
|
|
75
76
|
flash[:notice] = 'Memory updated successfully'
|
|
76
|
-
redirect_to memory_path(@memory)
|
|
77
|
+
redirect_to memory_path(@memory.id)
|
|
77
78
|
end
|
|
78
79
|
|
|
79
80
|
def destroy
|
|
@@ -91,7 +92,7 @@ class MemoriesController < ApplicationController
|
|
|
91
92
|
def restore
|
|
92
93
|
htm.restore(@memory.id)
|
|
93
94
|
flash[:notice] = 'Memory restored successfully.'
|
|
94
|
-
redirect_to memory_path(@memory)
|
|
95
|
+
redirect_to memory_path(@memory.id)
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
def deleted
|
|
@@ -102,5 +103,9 @@ class MemoriesController < ApplicationController
|
|
|
102
103
|
|
|
103
104
|
def set_memory
|
|
104
105
|
@memory = HTM::Models::Node.with_deleted[params[:id].to_i]
|
|
106
|
+
return if @memory
|
|
107
|
+
|
|
108
|
+
flash[:alert] = 'Memory not found'
|
|
109
|
+
redirect_to memories_path
|
|
105
110
|
end
|
|
106
111
|
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class MessagesController < ApplicationController
|
|
4
|
+
def create
|
|
5
|
+
@chat = Chat.find(params[:chat_id])
|
|
6
|
+
user_content = params[:content]
|
|
7
|
+
provider = @chat.model&.provider || 'ollama'
|
|
8
|
+
|
|
9
|
+
# Configure provider once at request start (clean slate)
|
|
10
|
+
configure_provider_cleanly(provider)
|
|
11
|
+
|
|
12
|
+
# Retrieve context from HTM (RAG)
|
|
13
|
+
context_results = fetch_htm_context(user_content)
|
|
14
|
+
context_text = format_context(context_results)
|
|
15
|
+
Rails.logger.info "Built context (#{context_text.length} chars): #{context_text[0..200]}..."
|
|
16
|
+
|
|
17
|
+
# Build system prompt with HTM context
|
|
18
|
+
system_prompt = build_system_prompt(context_text)
|
|
19
|
+
|
|
20
|
+
# Ensure chat has a valid Model record
|
|
21
|
+
ensure_model_record(@chat)
|
|
22
|
+
|
|
23
|
+
# Use acts_as_chat - the chat object handles its own LLM configuration
|
|
24
|
+
@chat.assume_model_exists = true
|
|
25
|
+
@chat.with_instructions(system_prompt, replace: true)
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
@chat.ask(user_content)
|
|
29
|
+
rescue RubyLLM::Error => e
|
|
30
|
+
handle_llm_error(@chat, user_content, e)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
redirect_to chat_path(@chat)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def fetch_htm_context(query)
|
|
39
|
+
settings = htm_settings
|
|
40
|
+
|
|
41
|
+
# If HTM is disabled, return empty context
|
|
42
|
+
if settings[:strategy] == 'off'
|
|
43
|
+
Rails.logger.info 'HTM context disabled (strategy: off)'
|
|
44
|
+
return []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Build recall options from settings
|
|
48
|
+
recall_options = {
|
|
49
|
+
strategy: settings[:strategy].to_sym
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Handle limit - 'all' means no limit (use a large number)
|
|
53
|
+
if settings[:limit] == 'all'
|
|
54
|
+
recall_options[:limit] = 10_000
|
|
55
|
+
else
|
|
56
|
+
recall_options[:limit] = settings[:limit].to_i
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Add tag filter if specified
|
|
60
|
+
recall_options[:tags] = settings[:tag_filter] if settings[:tag_filter].present?
|
|
61
|
+
|
|
62
|
+
# Add metadata filter for propositions if enabled
|
|
63
|
+
if settings[:propositions]
|
|
64
|
+
# Include both regular nodes and proposition nodes
|
|
65
|
+
Rails.logger.info 'Including proposition nodes in HTM context'
|
|
66
|
+
elsif settings[:tags_only]
|
|
67
|
+
# Only search by tags, not content
|
|
68
|
+
Rails.logger.info 'Using tags-only search mode'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
results = htm.recall(query, **recall_options)
|
|
72
|
+
limit_display = settings[:limit] == 'all' ? 'all' : settings[:limit]
|
|
73
|
+
Rails.logger.info "HTM recall (#{settings[:strategy]}, limit: #{limit_display}) returned #{results.size} results"
|
|
74
|
+
results
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
Rails.logger.warn "HTM recall failed: #{e.class}: #{e.message}"
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def htm_settings
|
|
81
|
+
defaults = {
|
|
82
|
+
strategy: 'hybrid',
|
|
83
|
+
limit: 5,
|
|
84
|
+
propositions: false,
|
|
85
|
+
tags_only: false,
|
|
86
|
+
tag_filter: nil
|
|
87
|
+
}
|
|
88
|
+
stored = session[:htm_settings] || {}
|
|
89
|
+
defaults.merge(stored.symbolize_keys)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def format_context(results)
|
|
93
|
+
return 'No relevant context found.' if results.empty?
|
|
94
|
+
|
|
95
|
+
results.map.with_index do |r, i|
|
|
96
|
+
content = case r
|
|
97
|
+
when String then r
|
|
98
|
+
when Hash then r[:content] || r['content']
|
|
99
|
+
else r.respond_to?(:content) ? r.content : r.to_s
|
|
100
|
+
end
|
|
101
|
+
"[#{i + 1}] #{content&.truncate(500)}"
|
|
102
|
+
end.join("\n\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_system_prompt(context)
|
|
106
|
+
settings = htm_settings
|
|
107
|
+
|
|
108
|
+
if settings[:strategy] == 'off'
|
|
109
|
+
<<~PROMPT
|
|
110
|
+
You are a helpful assistant.
|
|
111
|
+
Answer questions based on your training knowledge.
|
|
112
|
+
PROMPT
|
|
113
|
+
else
|
|
114
|
+
<<~PROMPT
|
|
115
|
+
You are a helpful assistant with access to a knowledge base.
|
|
116
|
+
|
|
117
|
+
Use the following context from the knowledge base to answer questions.
|
|
118
|
+
If the context doesn't contain relevant information, acknowledge that
|
|
119
|
+
and provide your best answer based on your training.
|
|
120
|
+
|
|
121
|
+
=== Knowledge Base Context ===
|
|
122
|
+
#{context}
|
|
123
|
+
=== End Context ===
|
|
124
|
+
PROMPT
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def ensure_model_record(chat)
|
|
129
|
+
stored_value = chat[:model_id]
|
|
130
|
+
|
|
131
|
+
# Already a valid Model record ID
|
|
132
|
+
if stored_value.to_s =~ /^\d+$/
|
|
133
|
+
return if Model.exists?(id: stored_value)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Create Model record from string name
|
|
137
|
+
model_name = stored_value.presence || ENV.fetch('CHAT_MODEL', 'gemma3:latest')
|
|
138
|
+
model = Model.find_or_create_by!(model_id: model_name, provider: 'ollama') do |m|
|
|
139
|
+
m.name = model_name
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
chat.update_column(:model_id, model.id)
|
|
143
|
+
chat.reload
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def handle_llm_error(chat, user_content, error)
|
|
147
|
+
chat.messages.create!(role: 'user', content: user_content)
|
|
148
|
+
chat.messages.create!(
|
|
149
|
+
role: 'assistant',
|
|
150
|
+
content: "**Error from #{chat.model&.provider || 'provider'}:** #{error.message}"
|
|
151
|
+
)
|
|
152
|
+
Rails.logger.error "LLM Error: #{error.class}: #{error.message}"
|
|
153
|
+
Rails.logger.error error.backtrace.first(5).join("\n")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Configure provider with a clean slate - no residual config from previous requests
|
|
157
|
+
def configure_provider_cleanly(provider)
|
|
158
|
+
Rails.logger.info "=== Configuring provider: #{provider} ==="
|
|
159
|
+
|
|
160
|
+
# Reset OpenAI config to prevent cross-contamination between providers
|
|
161
|
+
# (LM Studio uses openai_api_base which affects other providers)
|
|
162
|
+
reset_openai_config unless provider == 'lmstudio'
|
|
163
|
+
|
|
164
|
+
case provider
|
|
165
|
+
when 'ollama'
|
|
166
|
+
configure_ollama
|
|
167
|
+
when 'lmstudio'
|
|
168
|
+
configure_lmstudio
|
|
169
|
+
when 'gpustack'
|
|
170
|
+
configure_gpustack
|
|
171
|
+
else
|
|
172
|
+
configure_cloud_provider(provider)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def reset_openai_config
|
|
177
|
+
RubyLLM.config.openai_api_base = nil
|
|
178
|
+
RubyLLM.config.openai_api_key = ENV['OPENAI_API_KEY']
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def configure_ollama
|
|
182
|
+
base_url = ENV.fetch('OLLAMA_URL', 'http://localhost:11434')
|
|
183
|
+
base_url = "#{base_url}/v1" unless base_url.end_with?('/v1')
|
|
184
|
+
RubyLLM.config.ollama_api_base = base_url
|
|
185
|
+
Rails.logger.info " Ollama API: #{base_url}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def configure_lmstudio
|
|
189
|
+
base_url = ENV.fetch('LMSTUDIO_URL', 'http://localhost:1234')
|
|
190
|
+
base_url = "#{base_url}/v1" unless base_url.end_with?('/v1')
|
|
191
|
+
RubyLLM.config.openai_api_base = base_url
|
|
192
|
+
RubyLLM.config.openai_api_key = 'lm-studio'
|
|
193
|
+
Rails.logger.info " LM Studio API: #{base_url}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def configure_gpustack
|
|
197
|
+
base_url = ENV.fetch('GPUSTACK_API_BASE', 'http://localhost:8080')
|
|
198
|
+
RubyLLM.config.gpustack_api_base = base_url
|
|
199
|
+
Rails.logger.info " GPUStack API: #{base_url}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def configure_cloud_provider(provider)
|
|
203
|
+
provider_class = RubyLLM.providers.find { |p| p.name.split('::').last.downcase == provider }
|
|
204
|
+
return unless provider_class
|
|
205
|
+
|
|
206
|
+
provider_class.configuration_requirements.each do |config_key|
|
|
207
|
+
env_var = config_key.to_s.upcase
|
|
208
|
+
if ENV[env_var].present?
|
|
209
|
+
RubyLLM.config.send("#{config_key}=", ENV[env_var])
|
|
210
|
+
Rails.logger.info " Set #{config_key} from #{env_var}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -7,6 +7,11 @@ class RobotsController < ApplicationController
|
|
|
7
7
|
|
|
8
8
|
def show
|
|
9
9
|
@robot = HTM::Models::Robot[params[:id]]
|
|
10
|
+
unless @robot
|
|
11
|
+
flash[:alert] = 'Robot not found'
|
|
12
|
+
redirect_to robots_path
|
|
13
|
+
return
|
|
14
|
+
end
|
|
10
15
|
# Default scope already excludes deleted nodes, so no .active needed
|
|
11
16
|
@memory_count = @robot.nodes_dataset.count
|
|
12
17
|
@recent_memories = @robot.nodes_dataset.order(Sequel.desc(:created_at)).limit(10).all
|
|
@@ -37,8 +42,13 @@ class RobotsController < ApplicationController
|
|
|
37
42
|
|
|
38
43
|
def switch
|
|
39
44
|
robot = HTM::Models::Robot[params[:id]]
|
|
45
|
+
unless robot
|
|
46
|
+
flash[:alert] = 'Robot not found'
|
|
47
|
+
redirect_to robots_path
|
|
48
|
+
return
|
|
49
|
+
end
|
|
40
50
|
self.current_robot_name = robot.name
|
|
41
51
|
flash[:notice] = "Switched to robot '#{robot.name}'"
|
|
42
|
-
redirect_to
|
|
52
|
+
redirect_to htm_root_path
|
|
43
53
|
end
|
|
44
54
|
end
|
|
@@ -31,6 +31,19 @@ class TagsController < ApplicationController
|
|
|
31
31
|
|
|
32
32
|
def show
|
|
33
33
|
@tag = HTM::Models::Tag[params[:id]]
|
|
34
|
-
|
|
34
|
+
unless @tag
|
|
35
|
+
flash[:alert] = 'Tag not found'
|
|
36
|
+
redirect_to tags_path
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
# Note: Node has a default scope that excludes deleted nodes, so we don't need .active
|
|
40
|
+
# We need to filter through node_tags that aren't deleted and join properly
|
|
41
|
+
@memories = HTM::Models::Node
|
|
42
|
+
.join(:node_tags, node_id: Sequel[:nodes][:id])
|
|
43
|
+
.where(Sequel[:node_tags][:tag_id] => @tag.id)
|
|
44
|
+
.where(Sequel[:node_tags][:deleted_at] => nil)
|
|
45
|
+
.eager(:tags)
|
|
46
|
+
.order(Sequel.desc(Sequel[:nodes][:created_at]))
|
|
47
|
+
.all
|
|
35
48
|
end
|
|
36
49
|
end
|