glancer 1.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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +96 -0
- data/.rubocop.yml +54 -0
- data/CHANGELOG.md +88 -0
- data/CLAUDE.md +115 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/README.md +354 -0
- data/app/assets/config/glancer_manifest.js +1 -0
- data/app/assets/javascripts/glancer/application.js +15 -0
- data/app/assets/javascripts/glancer/controllers/chat_controller.js +101 -0
- data/app/assets/javascripts/glancer/controllers/message_controller.js +1052 -0
- data/app/assets/javascripts/glancer/controllers/toast_controller.js +63 -0
- data/app/assets/stylesheets/glancer/application.css +350 -0
- data/app/assets/stylesheets/glancer/code-blocks.css +6 -0
- data/app/assets/stylesheets/glancer/list.css +31 -0
- data/app/assets/stylesheets/glancer/scrollbar.css +16 -0
- data/app/assets/stylesheets/glancer/table.css +97 -0
- data/app/controllers/glancer/application_controller.rb +33 -0
- data/app/controllers/glancer/chats_controller.rb +49 -0
- data/app/controllers/glancer/messages_controller.rb +144 -0
- data/app/controllers/glancer/schema_controller.rb +29 -0
- data/app/controllers/glancer/settings_controller.rb +23 -0
- data/app/helpers/glancer/application_helper.rb +17 -0
- data/app/jobs/glancer/application_job.rb +6 -0
- data/app/jobs/glancer/process_message_job.rb +38 -0
- data/app/models/glancer/audit.rb +12 -0
- data/app/models/glancer/chat.rb +8 -0
- data/app/models/glancer/code_version.rb +12 -0
- data/app/models/glancer/embedding.rb +6 -0
- data/app/models/glancer/message.rb +25 -0
- data/app/models/glancer/setting.rb +23 -0
- data/app/models/glancer/sql_version.rb +6 -0
- data/app/views/glancer/_data/_importmap.json.erb +7 -0
- data/app/views/glancer/chats/_chat_sidebar.html.erb +2 -0
- data/app/views/glancer/chats/_show.html.erb +52 -0
- data/app/views/glancer/chats/_sidebar_chat_list.html.erb +30 -0
- data/app/views/glancer/chats/index.html.erb +10 -0
- data/app/views/glancer/chats/show.html.erb +1 -0
- data/app/views/glancer/messages/_data_table.html.erb +268 -0
- data/app/views/glancer/messages/_execution_error.html.erb +26 -0
- data/app/views/glancer/messages/_form.html.erb +93 -0
- data/app/views/glancer/messages/_message.html.erb +206 -0
- data/app/views/glancer/messages/_message_info.html.erb +176 -0
- data/app/views/glancer/messages/_temp_form.html.erb +100 -0
- data/app/views/glancer/messages/create.turbo_stream.erb +25 -0
- data/app/views/glancer/schema/show.html.erb +123 -0
- data/app/views/glancer/settings/show.html.erb +306 -0
- data/app/views/glancer/shared/_icons.html.erb +126 -0
- data/app/views/layouts/glancer/application.html.erb +234 -0
- data/config/locales/glancer.en.yml +90 -0
- data/config/locales/glancer.es.yml +90 -0
- data/config/locales/glancer.pt-BR.yml +90 -0
- data/config/routes.rb +20 -0
- data/db/migrate/20250629212642_create_glancer_audits.rb +19 -0
- data/db/migrate/20250629212643_create_glancer_chats.rb +10 -0
- data/db/migrate/20250629212645_create_glancer_embeddings.rb +17 -0
- data/db/migrate/20250629212647_create_glancer_messages.rb +29 -0
- data/db/migrate/20260513204129_add_user_edited_sql_to_glancer_messages.rb +11 -0
- data/db/migrate/20260513210647_create_glancer_sql_versions.rb +18 -0
- data/db/migrate/20260513210648_add_message_id_to_glancer_audits.rb +8 -0
- data/db/migrate/20260513220000_create_glancer_settings.rb +12 -0
- data/db/migrate/20260514083509_add_llm_model_to_glancer_messages.rb +9 -0
- data/db/migrate/20260523120000_rename_code_columns_in_glancer_messages.rb +8 -0
- data/db/migrate/20260523120001_rename_code_column_in_glancer_audits.rb +7 -0
- data/db/migrate/20260523120002_add_code_type_to_glancer_tables.rb +10 -0
- data/db/migrate/20260523120003_rename_glancer_sql_versions_to_code_versions.rb +8 -0
- data/db/migrate/20260523130000_add_enriched_question_to_glancer_messages.rb +7 -0
- data/db/migrate/20260524100000_add_status_to_glancer_messages.rb +9 -0
- data/lib/generators/glancer/install/install_generator.rb +74 -0
- data/lib/generators/glancer/install/templates/glancer.rb +227 -0
- data/lib/generators/glancer/install/templates/llm_context.glancer.md +51 -0
- data/lib/glancer/async_runner.rb +50 -0
- data/lib/glancer/chart_analyzer.rb +230 -0
- data/lib/glancer/configuration.rb +372 -0
- data/lib/glancer/engine.rb +90 -0
- data/lib/glancer/indexer/context_indexer.rb +58 -0
- data/lib/glancer/indexer/model_indexer.rb +64 -0
- data/lib/glancer/indexer/schema_indexer.rb +171 -0
- data/lib/glancer/indexer.rb +50 -0
- data/lib/glancer/retriever.rb +114 -0
- data/lib/glancer/utils/logger.rb +83 -0
- data/lib/glancer/utils/markdown_helper.rb +56 -0
- data/lib/glancer/utils/result_formatter.rb +25 -0
- data/lib/glancer/utils/table_stats.rb +18 -0
- data/lib/glancer/utils/transaction.rb +59 -0
- data/lib/glancer/version.rb +5 -0
- data/lib/glancer/workflow/ar_executor.rb +104 -0
- data/lib/glancer/workflow/ar_extractor.rb +25 -0
- data/lib/glancer/workflow/ar_prompt_builder.rb +64 -0
- data/lib/glancer/workflow/ar_sanitizer.rb +88 -0
- data/lib/glancer/workflow/builder.rb +129 -0
- data/lib/glancer/workflow/cache.rb +55 -0
- data/lib/glancer/workflow/executor.rb +72 -0
- data/lib/glancer/workflow/llm.rb +123 -0
- data/lib/glancer/workflow/prompt_builder.rb +143 -0
- data/lib/glancer/workflow/query_enricher.rb +117 -0
- data/lib/glancer/workflow/sql_extractor.rb +42 -0
- data/lib/glancer/workflow/sql_sanitizer.rb +42 -0
- data/lib/glancer/workflow/sql_validator.rb +67 -0
- data/lib/glancer/workflow.rb +158 -0
- data/lib/glancer.rb +50 -0
- data/lib/tasks/glancer/tailwind.rake +8 -0
- data/lib/tasks/glancer.rake +99 -0
- data/spec/glancer_spec.rb +62 -0
- data/spec/lib/glancer/async_runner_spec.rb +133 -0
- data/spec/lib/glancer/chart_analyzer_spec.rb +296 -0
- data/spec/lib/glancer/configuration_spec.rb +858 -0
- data/spec/lib/glancer/engine_spec.rb +209 -0
- data/spec/lib/glancer/indexer/context_indexer_spec.rb +96 -0
- data/spec/lib/glancer/indexer/model_indexer_spec.rb +103 -0
- data/spec/lib/glancer/indexer/schema_indexer_spec.rb +382 -0
- data/spec/lib/glancer/indexer_spec.rb +95 -0
- data/spec/lib/glancer/retriever_spec.rb +179 -0
- data/spec/lib/glancer/utils/logger_spec.rb +85 -0
- data/spec/lib/glancer/utils/markdown_helper_spec.rb +92 -0
- data/spec/lib/glancer/utils/result_formatter_spec.rb +73 -0
- data/spec/lib/glancer/utils/table_stats_spec.rb +34 -0
- data/spec/lib/glancer/utils/transaction_spec.rb +73 -0
- data/spec/lib/glancer/workflow/ar_executor_spec.rb +155 -0
- data/spec/lib/glancer/workflow/ar_extractor_spec.rb +50 -0
- data/spec/lib/glancer/workflow/ar_prompt_builder_spec.rb +79 -0
- data/spec/lib/glancer/workflow/ar_sanitizer_spec.rb +175 -0
- data/spec/lib/glancer/workflow/builder_spec.rb +204 -0
- data/spec/lib/glancer/workflow/cache_spec.rb +142 -0
- data/spec/lib/glancer/workflow/executor_spec.rb +149 -0
- data/spec/lib/glancer/workflow/llm_spec.rb +124 -0
- data/spec/lib/glancer/workflow/prompt_builder_spec.rb +196 -0
- data/spec/lib/glancer/workflow/query_enricher_spec.rb +184 -0
- data/spec/lib/glancer/workflow/sql_extractor_spec.rb +82 -0
- data/spec/lib/glancer/workflow/sql_sanitizer_spec.rb +98 -0
- data/spec/lib/glancer/workflow/sql_validator_spec.rb +166 -0
- data/spec/lib/glancer/workflow_spec.rb +308 -0
- data/spec/models/glancer/audit_spec.rb +82 -0
- data/spec/models/glancer/chat_spec.rb +60 -0
- data/spec/models/glancer/code_version_spec.rb +71 -0
- data/spec/models/glancer/embedding_spec.rb +73 -0
- data/spec/models/glancer/message_spec.rb +144 -0
- data/spec/models/glancer/setting_spec.rb +88 -0
- data/spec/models/glancer/sql_version_spec.rb +4 -0
- data/spec/spec_helper.rb +128 -0
- data/spec/support/schema.rb +55 -0
- metadata +255 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
class MessagesController < Glancer::ApplicationController
|
|
5
|
+
def create
|
|
6
|
+
@chat = Glancer::Chat.find(params[:chat_id])
|
|
7
|
+
@message = @chat.messages.create!(message_params.merge(role: :user))
|
|
8
|
+
|
|
9
|
+
# Placeholder written immediately; ProcessMessageJob fills it in async.
|
|
10
|
+
@response_message = @chat.messages.create!(
|
|
11
|
+
role: :assistant,
|
|
12
|
+
content: "",
|
|
13
|
+
code_type: "sql",
|
|
14
|
+
user_message: @message,
|
|
15
|
+
status: :processing
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
Glancer::AsyncRunner.call(@response_message.id, @message.content)
|
|
19
|
+
|
|
20
|
+
@chats = Glancer::Chat.order(created_at: :desc)
|
|
21
|
+
|
|
22
|
+
respond_to do |format|
|
|
23
|
+
format.turbo_stream
|
|
24
|
+
format.html { redirect_to glancer.chat_path(@chat) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Clients poll this endpoint every ~2 s while waiting for the async runner.
|
|
29
|
+
# Returns 204 while still processing; Turbo Stream when complete or failed.
|
|
30
|
+
# A hard timeout marks stale messages as failed so the UI doesn't loop forever.
|
|
31
|
+
PROCESSING_TIMEOUT_SECONDS = 300
|
|
32
|
+
|
|
33
|
+
def poll
|
|
34
|
+
@message = Glancer::Message.find(params[:id])
|
|
35
|
+
|
|
36
|
+
if (@message.processing? || @message.pending?) &&
|
|
37
|
+
@message.updated_at < PROCESSING_TIMEOUT_SECONDS.seconds.ago
|
|
38
|
+
@message.update!(content: "Request timed out. Please try again.", successful: false, status: :failed)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
return head(:no_content) if @message.processing? || @message.pending?
|
|
42
|
+
|
|
43
|
+
respond_to do |format|
|
|
44
|
+
format.turbo_stream do
|
|
45
|
+
render turbo_stream: turbo_stream.replace(
|
|
46
|
+
"message-#{@message.id}",
|
|
47
|
+
partial: "glancer/messages/message",
|
|
48
|
+
locals: { message: @message }
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run_code
|
|
55
|
+
@message = Glancer::Message.find(params[:id])
|
|
56
|
+
custom_code = params[:custom_code].presence
|
|
57
|
+
|
|
58
|
+
if custom_code
|
|
59
|
+
if @message.code_type == "activerecord"
|
|
60
|
+
Glancer::Workflow::ARSanitizer.ensure_safe!(custom_code)
|
|
61
|
+
@message.update!(code: custom_code, user_edited_code: true)
|
|
62
|
+
@message.code_versions.create!(code: custom_code, source: :user_edited)
|
|
63
|
+
@data = Glancer::Workflow::ARExecutor.execute(@message.code, message_id: @message.id)
|
|
64
|
+
else
|
|
65
|
+
Glancer::Workflow::SQLSanitizer.ensure_safe!(custom_code)
|
|
66
|
+
@message.update!(code: custom_code, user_edited_code: true)
|
|
67
|
+
@message.code_versions.create!(code: custom_code, source: :user_edited)
|
|
68
|
+
@data = Glancer::Workflow::Executor.execute(@message.code, message_id: @message.id)
|
|
69
|
+
end
|
|
70
|
+
elsif @message.code_type == "activerecord"
|
|
71
|
+
@data = Glancer::Workflow::ARExecutor.execute(@message.code, message_id: @message.id)
|
|
72
|
+
else
|
|
73
|
+
@data = Glancer::Workflow::Executor.execute(@message.code, message_id: @message.id)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
respond_to do |format|
|
|
77
|
+
format.turbo_stream do
|
|
78
|
+
if @data.is_a?(Hash) && @data[:error]
|
|
79
|
+
render turbo_stream: turbo_stream.update("results-#{@message.id}",
|
|
80
|
+
partial: "glancer/messages/execution_error",
|
|
81
|
+
locals: { error_message: @data[:message] })
|
|
82
|
+
else
|
|
83
|
+
chart_data = Glancer::ChartAnalyzer.analyze(@data)
|
|
84
|
+
render turbo_stream: turbo_stream.update("results-#{@message.id}",
|
|
85
|
+
partial: "glancer/messages/data_table",
|
|
86
|
+
locals: { data: @data, chart_data: chart_data })
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def open_in_blazer
|
|
93
|
+
@message = Glancer::Message.find(params[:id])
|
|
94
|
+
blazer_path = Glancer.configuration.resolved_blazer_path
|
|
95
|
+
|
|
96
|
+
unless blazer_path.present? && defined?(Blazer::Query)
|
|
97
|
+
redirect_to glancer.root_path, alert: "Blazer não está disponível."
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
unless @message.code_type == "sql" || @message.code_type.nil?
|
|
102
|
+
redirect_to glancer.root_path, alert: "Only SQL queries can be opened in Blazer."
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
query = Blazer::Query.create!(
|
|
107
|
+
name: "Glancer: #{@message.user_message&.content&.truncate(60) || "Query"}",
|
|
108
|
+
statement: @message.code.strip
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
redirect_to "#{blazer_path}/queries/#{query.id}/edit", allow_other_host: true
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
Glancer::Utils::Logger.error("MessagesController", "Failed to create Blazer query: #{e.message}")
|
|
114
|
+
redirect_to glancer.root_path, alert: "Não foi possível criar a query no Blazer: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def message_info
|
|
118
|
+
@message_for_info = begin
|
|
119
|
+
Glancer::Message.find(params[:id])
|
|
120
|
+
rescue StandardError
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
respond_to do |format|
|
|
125
|
+
format.turbo_stream do
|
|
126
|
+
render turbo_stream: [
|
|
127
|
+
turbo_stream.replace("message-info", partial: "glancer/messages/message_info",
|
|
128
|
+
locals: { message_info: @message_for_info })
|
|
129
|
+
]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def message_params
|
|
137
|
+
params.require(:message).permit(:content)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def format_response(result)
|
|
141
|
+
result[:content]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
class SchemaController < Glancer::ApplicationController
|
|
5
|
+
layout "glancer/application"
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
@chats = Glancer::Chat.order(created_at: :desc)
|
|
9
|
+
connection = ActiveRecord::Base.connection
|
|
10
|
+
@tables = connection.tables.sort.filter_map do |table_name|
|
|
11
|
+
next if %w[schema_migrations ar_internal_metadata].include?(table_name)
|
|
12
|
+
|
|
13
|
+
columns = connection.columns(table_name).map do |col|
|
|
14
|
+
{ name: col.name, type: col.sql_type, null: col.null, default: col.default }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
fks = begin
|
|
18
|
+
connection.foreign_keys(table_name).map do |fk|
|
|
19
|
+
{ from_column: fk.column, to_table: fk.to_table, to_column: fk.primary_key }
|
|
20
|
+
end
|
|
21
|
+
rescue StandardError
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
{ name: table_name, columns: columns, foreign_keys: fks }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
class SettingsController < ApplicationController
|
|
5
|
+
layout "glancer/application"
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
@chats = Glancer::Chat.order(created_at: :desc)
|
|
9
|
+
@settings = {
|
|
10
|
+
ui_language: Glancer::Setting.get("ui_language", default: "en"),
|
|
11
|
+
speech_language: Glancer::Setting.get("speech_language", default: "auto"),
|
|
12
|
+
custom_instructions: Glancer::Setting.get("custom_instructions", default: "")
|
|
13
|
+
}
|
|
14
|
+
@glancer_config = Glancer.configuration
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update
|
|
18
|
+
allowed = params.require(:settings).permit(:ui_language, :speech_language, :custom_instructions)
|
|
19
|
+
Glancer::Setting.store_many(allowed.to_h)
|
|
20
|
+
redirect_to glancer.settings_path, notice: t("glancer.settings.saved")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
# Returns the list of indexed table names once per request (memoized).
|
|
6
|
+
def glancer_table_names
|
|
7
|
+
@glancer_table_names ||= Glancer::Embedding
|
|
8
|
+
.where(source_type: "schema")
|
|
9
|
+
.pluck(:source_path)
|
|
10
|
+
.filter_map { |p| p.split("#").last if p.include?("#") }
|
|
11
|
+
.reject { |n| n == "foreign_keys" }
|
|
12
|
+
.uniq
|
|
13
|
+
rescue StandardError
|
|
14
|
+
[]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
class ProcessMessageJob < Glancer::ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform(message_id, question)
|
|
8
|
+
message = Glancer::Message.find(message_id)
|
|
9
|
+
chat = message.chat
|
|
10
|
+
|
|
11
|
+
message.update!(status: :processing)
|
|
12
|
+
|
|
13
|
+
result = Glancer::Workflow.run(chat.id, question)
|
|
14
|
+
|
|
15
|
+
cfg = Glancer.configuration
|
|
16
|
+
used_model = "#{cfg.resolved_chat_provider}/#{cfg.resolved_chat_model}"
|
|
17
|
+
|
|
18
|
+
message.update!(
|
|
19
|
+
content: result[:content].to_s,
|
|
20
|
+
code: result[:code],
|
|
21
|
+
code_type: result[:code_type] || "sql",
|
|
22
|
+
successful: result[:successful],
|
|
23
|
+
llm_model: used_model,
|
|
24
|
+
enriched_question: result[:enriched_question],
|
|
25
|
+
status: :complete
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
message.code_versions.create!(code: message.code, source: :generated) if message.code.present?
|
|
29
|
+
|
|
30
|
+
# Generate chat title on the first user message
|
|
31
|
+
chat.update!(title: Glancer::Workflow::LLM.generate_title(question)) if chat.messages.where(role: :user).count == 1
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Glancer::Utils::Logger.error("ProcessMessageJob", "Failed for message #{message_id}: #{e.message}")
|
|
34
|
+
message&.update!(content: e.message, successful: false, status: :failed)
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
class Audit < ApplicationRecord
|
|
5
|
+
self.table_name = "glancer_audits"
|
|
6
|
+
|
|
7
|
+
belongs_to :message, class_name: "Glancer::Message", optional: true
|
|
8
|
+
|
|
9
|
+
validates :code, :adapter, :run_id, :executed_at, presence: true
|
|
10
|
+
validates :run_id, uniqueness: true
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
class CodeVersion < ApplicationRecord
|
|
5
|
+
self.table_name = "glancer_code_versions"
|
|
6
|
+
|
|
7
|
+
belongs_to :message, class_name: "Glancer::Message"
|
|
8
|
+
enum :source, generated: "generated", user_edited: "user_edited"
|
|
9
|
+
|
|
10
|
+
validates :code, :source, presence: true
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
class Message < ApplicationRecord
|
|
5
|
+
belongs_to :chat, class_name: "Glancer::Chat"
|
|
6
|
+
belongs_to :user_message, class_name: "Glancer::Message", optional: true
|
|
7
|
+
has_many :code_versions, class_name: "Glancer::CodeVersion", dependent: :destroy
|
|
8
|
+
has_many :audits, class_name: "Glancer::Audit", foreign_key: :message_id, dependent: :nullify
|
|
9
|
+
|
|
10
|
+
# Nullify self-referential FK before destroy to avoid MySQL constraint violation
|
|
11
|
+
# when the chat cascade reaches user messages before their assistant counterparts
|
|
12
|
+
before_destroy { self.class.where(user_message_id: id).update_all(user_message_id: nil) }
|
|
13
|
+
enum :role, user: "user", assistant: "assistant", system: "system"
|
|
14
|
+
enum :status, { pending: 0, processing: 1, complete: 2, failed: 3 }, default: :complete
|
|
15
|
+
|
|
16
|
+
# User messages always require content; assistant placeholders start empty.
|
|
17
|
+
validates :content, presence: true, if: :user?
|
|
18
|
+
|
|
19
|
+
def sql_result_json
|
|
20
|
+
JSON.parse(content || "[]")
|
|
21
|
+
rescue JSON::ParserError
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Glancer
|
|
4
|
+
class Setting < ApplicationRecord
|
|
5
|
+
self.table_name = "glancer_settings"
|
|
6
|
+
|
|
7
|
+
validates :key, presence: true, uniqueness: true
|
|
8
|
+
|
|
9
|
+
def self.get(key, default: nil)
|
|
10
|
+
find_by(key: key.to_s)&.value || default
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.set(key, value)
|
|
14
|
+
record = find_or_initialize_by(key: key.to_s)
|
|
15
|
+
record.value = value.to_s
|
|
16
|
+
record.save!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.store_many(hash)
|
|
20
|
+
hash.each { |k, v| set(k, v) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<% if chat %>
|
|
2
|
+
<div class="flex-1 flex flex-col min-h-0">
|
|
3
|
+
<div id="chat-messages"
|
|
4
|
+
class="flex-1 overflow-y-auto py-6 flex flex-col"
|
|
5
|
+
role="log"
|
|
6
|
+
aria-live="polite"
|
|
7
|
+
aria-label="Conversation messages">
|
|
8
|
+
<div id="messages-list" class="flex-1 flex flex-col max-w-3xl lg:max-w-4xl xl:max-w-5xl 2xl:max-w-6xl mx-auto px-4 space-y-6 w-full">
|
|
9
|
+
<% if messages.blank? %>
|
|
10
|
+
<div id="chat-empty-state" class="flex-1 flex flex-col items-center justify-center text-center">
|
|
11
|
+
<div class="w-14 h-14 rounded-2xl bg-primary-50 dark:bg-primary-950 flex items-center justify-center mb-4">
|
|
12
|
+
<svg class="w-7 h-7 text-primary-600 dark:text-primary-400" aria-hidden="true"><use href="#icon-database"/></svg>
|
|
13
|
+
</div>
|
|
14
|
+
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-1"><%= t("glancer.chat.empty_title") %></h2>
|
|
15
|
+
<p class="text-sm text-gray-400 dark:text-gray-500 max-w-xs"><%= t("glancer.chat.empty_subtitle") %></p>
|
|
16
|
+
</div>
|
|
17
|
+
<% else %>
|
|
18
|
+
<% messages.each do |message| %>
|
|
19
|
+
<%= render "glancer/messages/message", message: message %>
|
|
20
|
+
<% end %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</div><%# /messages-list %>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="flex-shrink-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 px-4 py-4">
|
|
26
|
+
<%= render "glancer/messages/form", chat: chat %>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<% else %>
|
|
31
|
+
<%# Temporary chat — no DB record yet. Chat is created on first message send. %>
|
|
32
|
+
<div class="flex-1 flex flex-col min-h-0">
|
|
33
|
+
<div id="chat-messages"
|
|
34
|
+
class="flex-1 overflow-y-auto py-6 flex flex-col"
|
|
35
|
+
role="log"
|
|
36
|
+
aria-live="polite">
|
|
37
|
+
<div id="messages-list" class="flex-1 flex flex-col max-w-3xl lg:max-w-4xl xl:max-w-5xl 2xl:max-w-6xl mx-auto px-4 space-y-6 w-full">
|
|
38
|
+
<div id="chat-empty-state" class="flex-1 flex flex-col items-center justify-center text-center">
|
|
39
|
+
<div class="w-14 h-14 rounded-2xl bg-primary-50 dark:bg-primary-950 flex items-center justify-center mb-4">
|
|
40
|
+
<svg class="w-7 h-7 text-primary-600 dark:text-primary-400" aria-hidden="true"><use href="#icon-database"/></svg>
|
|
41
|
+
</div>
|
|
42
|
+
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-1"><%= t("glancer.chat.empty_title") %></h2>
|
|
43
|
+
<p class="text-sm text-gray-400 dark:text-gray-500 max-w-xs"><%= t("glancer.chat.empty_subtitle") %></p>
|
|
44
|
+
</div>
|
|
45
|
+
</div><%# /messages-list %>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="flex-shrink-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 px-4 py-4">
|
|
49
|
+
<%= render "glancer/messages/temp_form" %>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<div id="sidebar-chat-list" class="flex-1 overflow-y-auto px-2 py-1">
|
|
2
|
+
<% if @chats.blank? %>
|
|
3
|
+
<p class="px-3 py-6 text-xs text-center text-gray-400 dark:text-gray-500">No chats yet. Create one to get started.</p>
|
|
4
|
+
<% else %>
|
|
5
|
+
<% @chats.each do |c| %>
|
|
6
|
+
<div class="group relative flex items-center rounded-lg my-0.5 <%= c == @chat ? 'bg-primary-50 dark:bg-primary-950' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %> transition-colors">
|
|
7
|
+
<%= link_to glancer.chat_path(c),
|
|
8
|
+
class: "flex items-center gap-2.5 flex-1 min-w-0 px-3 py-2.5",
|
|
9
|
+
data: { action: "click->chat#select", chat_id: c.id, turbo: "false" },
|
|
10
|
+
aria: { current: (c == @chat ? "page" : nil) } do %>
|
|
11
|
+
<svg class="w-3.5 h-3.5 flex-shrink-0 <%= c == @chat ? 'text-primary-600 dark:text-primary-400' : 'text-gray-400' %>" aria-hidden="true">
|
|
12
|
+
<use href="#icon-chat"/>
|
|
13
|
+
</svg>
|
|
14
|
+
<span class="truncate text-sm <%= c == @chat ? 'font-medium text-primary-700 dark:text-primary-300' : 'text-gray-700 dark:text-gray-300' %>">
|
|
15
|
+
<%= c.title %>
|
|
16
|
+
</span>
|
|
17
|
+
<% end %>
|
|
18
|
+
|
|
19
|
+
<div class="flex-shrink-0 pr-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
20
|
+
<%= link_to glancer.chat_path(c),
|
|
21
|
+
class: "p-1 rounded text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors",
|
|
22
|
+
data: { turbo_method: :delete, turbo_confirm: "Delete this chat?" },
|
|
23
|
+
aria: { label: "Delete chat" } do %>
|
|
24
|
+
<svg class="w-3.5 h-3.5" aria-hidden="true"><use href="#icon-trash"/></svg>
|
|
25
|
+
<% end %>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
<% end %>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<div class="flex h-full w-full">
|
|
2
|
+
<%= render "glancer/chats/chat_sidebar" %>
|
|
3
|
+
|
|
4
|
+
<div id="main-content" class="flex-1 flex flex-col bg-white dark:bg-gray-900 relative">
|
|
5
|
+
<%= render "glancer/chats/show",
|
|
6
|
+
chat: @chat || @chats.first,
|
|
7
|
+
chats: @chats,
|
|
8
|
+
messages: (@chat || @chats.first)&.messages || [] %>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%# Layout renders the chat UI via _show partial using @chat / @chats instance variables %>
|