smart_prompt 0.4.4 → 0.5.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 +4 -4
- data/CHANGELOG.md +10 -10
- data/README.cn.md +307 -64
- data/README.md +311 -64
- data/Rakefile +10 -1
- data/config/anthropic_config.yml +151 -0
- data/config/image_generation_config.yml +22 -0
- data/config/multimodal_config.yml +85 -0
- data/config/sensenova_config.yml +63 -0
- data/config/zhipu_config.yml +73 -0
- data/examples/anthropic_basic_chat.rb +143 -0
- data/examples/anthropic_example.rb +232 -0
- data/examples/anthropic_multimodal.rb +212 -0
- data/examples/anthropic_streaming.rb +312 -0
- data/examples/anthropic_tool_calling.rb +393 -0
- data/examples/automatic_cleanup_example.rb +109 -0
- data/examples/history_management_examples.rb +522 -0
- data/examples/image_generation_example.rb +130 -0
- data/examples/monitoring_example.rb +121 -0
- data/examples/multimodal_example.rb +63 -0
- data/examples/relevance_based_strategy_example.rb +87 -0
- data/examples/sensenova_example.rb +129 -0
- data/examples/stt_example.rb +287 -0
- data/examples/tts_example.rb +244 -0
- data/examples/video_generation_example.rb +189 -0
- data/examples/zhipu_example.rb +151 -0
- data/lib/smart_prompt/anthropic_adapter.rb +363 -281
- data/lib/smart_prompt/compression_engine.rb +201 -0
- data/lib/smart_prompt/context_strategy.rb +22 -0
- data/lib/smart_prompt/conversation.rb +81 -191
- data/lib/smart_prompt/engine.rb +36 -19
- data/lib/smart_prompt/history_manager.rb +596 -0
- data/lib/smart_prompt/hybrid_strategy.rb +222 -0
- data/lib/smart_prompt/image_generation_adapter.rb +297 -0
- data/lib/smart_prompt/lru_cache.rb +133 -0
- data/lib/smart_prompt/message.rb +57 -0
- data/lib/smart_prompt/multimodal_adapter.rb +277 -0
- data/lib/smart_prompt/openai_adapter.rb +1 -25
- data/lib/smart_prompt/persistence_layer.rb +197 -0
- data/lib/smart_prompt/relevance_based_strategy.rb +221 -0
- data/lib/smart_prompt/sensenova_adapter.rb +410 -0
- data/lib/smart_prompt/session.rb +140 -0
- data/lib/smart_prompt/sliding_window_strategy.rb +100 -0
- data/lib/smart_prompt/stt_adapter.rb +381 -0
- data/lib/smart_prompt/summary_based_strategy.rb +152 -0
- data/lib/smart_prompt/token_counter.rb +74 -0
- data/lib/smart_prompt/tts_adapter.rb +403 -0
- data/lib/smart_prompt/version.rb +1 -1
- data/lib/smart_prompt/video_generation_adapter.rb +330 -0
- data/lib/smart_prompt/worker.rb +25 -3
- data/lib/smart_prompt/zhipu_adapter.rb +616 -0
- data/lib/smart_prompt.rb +22 -2
- data/workers/history_management_examples.rb +407 -0
- data/workers/image_generation_workers.rb +119 -0
- data/workers/multimodal_workers.rb +110 -0
- data/workers/sensenova_workers.rb +62 -0
- data/workers/stt_workers.rb +195 -0
- data/workers/tts_workers.rb +388 -0
- data/workers/video_generation_workers.rb +264 -0
- data/workers/zhipu_workers.rb +113 -0
- metadata +84 -8
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require 'time'
|
|
2
|
+
|
|
3
|
+
module SmartPrompt
|
|
4
|
+
# Message represents a single message in a conversation history
|
|
5
|
+
# It contains role, content, timestamp, and metadata
|
|
6
|
+
class Message
|
|
7
|
+
attr_reader :role, :content, :timestamp, :metadata, :token_count
|
|
8
|
+
attr_accessor :importance_score, :is_summary
|
|
9
|
+
|
|
10
|
+
def initialize(data)
|
|
11
|
+
@role = data[:role] || data["role"]
|
|
12
|
+
@content = data[:content] || data["content"]
|
|
13
|
+
@timestamp = parse_timestamp(data[:timestamp] || data["timestamp"])
|
|
14
|
+
@metadata = data[:metadata] || data["metadata"] || {}
|
|
15
|
+
@token_count = nil # Lazy calculation
|
|
16
|
+
@importance_score = data[:importance_score] || data["importance_score"]
|
|
17
|
+
@is_summary = data[:is_summary] || data["is_summary"] || false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Calculate token count using provided counter
|
|
21
|
+
def calculate_tokens(counter)
|
|
22
|
+
@token_count ||= counter.count(@content)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Check if this is a system message
|
|
26
|
+
def system_message?
|
|
27
|
+
@role == "system" || @role == :system
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Convert message to hash format
|
|
31
|
+
def to_h
|
|
32
|
+
{
|
|
33
|
+
role: @role,
|
|
34
|
+
content: @content,
|
|
35
|
+
timestamp: @timestamp.iso8601,
|
|
36
|
+
metadata: @metadata,
|
|
37
|
+
importance_score: @importance_score,
|
|
38
|
+
is_summary: @is_summary
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def parse_timestamp(timestamp)
|
|
45
|
+
case timestamp
|
|
46
|
+
when Time
|
|
47
|
+
timestamp
|
|
48
|
+
when String
|
|
49
|
+
Time.parse(timestamp)
|
|
50
|
+
when nil
|
|
51
|
+
Time.now
|
|
52
|
+
else
|
|
53
|
+
Time.now
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
require "openai"
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
module SmartPrompt
|
|
5
|
+
class MultimodalAdapter < LLMAdapter
|
|
6
|
+
SUPPORTED_IMAGE_FORMATS = %w[jpg jpeg png gif bmp webp]
|
|
7
|
+
SUPPORTED_VIDEO_FORMATS = %w[mp4 mov avi mkv webm]
|
|
8
|
+
|
|
9
|
+
def initialize(config)
|
|
10
|
+
super
|
|
11
|
+
api_key = @config["api_key"]
|
|
12
|
+
if api_key.is_a?(String) && api_key.start_with?("ENV[") && api_key.end_with?("]")
|
|
13
|
+
api_key = eval(api_key)
|
|
14
|
+
end
|
|
15
|
+
begin
|
|
16
|
+
@client = OpenAI::Client.new(
|
|
17
|
+
access_token: api_key,
|
|
18
|
+
uri_base: @config["url"],
|
|
19
|
+
request_timeout: 240,
|
|
20
|
+
)
|
|
21
|
+
rescue OpenAI::ConfigurationError => e
|
|
22
|
+
SmartPrompt.logger.error "Failed to initialize Multimodal client: #{e.message}"
|
|
23
|
+
raise LLMAPIError, "Invalid Multimodal configuration: #{e.message}"
|
|
24
|
+
rescue OpenAI::Error => e
|
|
25
|
+
SmartPrompt.logger.error "Failed to initialize Multimodal client: #{e.message}"
|
|
26
|
+
raise LLMAPIError, "Multimodal authentication failed: #{e.message}"
|
|
27
|
+
rescue SocketError => e
|
|
28
|
+
SmartPrompt.logger.error "Failed to initialize Multimodal client: #{e.message}"
|
|
29
|
+
raise LLMAPIError, "Network error: Unable to connect to Multimodal API"
|
|
30
|
+
rescue => e
|
|
31
|
+
SmartPrompt.logger.error "Failed to initialize Multimodal client: #{e.message}"
|
|
32
|
+
raise Error, "Unexpected error initializing Multimodal client: #{e.message}"
|
|
33
|
+
ensure
|
|
34
|
+
SmartPrompt.logger.info "Successfully created a Multimodal client."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def send_request(messages, model = nil, temperature = 0.7, tools = nil, proc = nil)
|
|
39
|
+
SmartPrompt.logger.info "MultimodalAdapter: Sending multimodal request"
|
|
40
|
+
|
|
41
|
+
# Process messages to handle multimodal content
|
|
42
|
+
processed_messages = process_multimodal_messages(messages)
|
|
43
|
+
|
|
44
|
+
temperature = 0.7 if temperature.nil?
|
|
45
|
+
model_name = model || @config["model"]
|
|
46
|
+
|
|
47
|
+
SmartPrompt.logger.info "MultimodalAdapter: Using model #{model_name}"
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
parameters = {
|
|
51
|
+
model: model_name,
|
|
52
|
+
messages: processed_messages,
|
|
53
|
+
temperature: @config["temperature"] || temperature,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if proc
|
|
57
|
+
parameters[:stream] = proc
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if tools
|
|
61
|
+
parameters[:tools] = tools
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
SmartPrompt.logger.info "Send parameters is: #{parameters}"
|
|
65
|
+
response = @client.chat(parameters: parameters)
|
|
66
|
+
|
|
67
|
+
rescue OpenAI::Error => e
|
|
68
|
+
SmartPrompt.logger.error "Multimodal API error: #{e.message}"
|
|
69
|
+
raise LLMAPIError, "Multimodal API error: #{e.message}"
|
|
70
|
+
rescue OpenAI::MiddlewareErrors => e
|
|
71
|
+
SmartPrompt.logger.error "Multimodal HTTP Error: #{e.message}"
|
|
72
|
+
raise LLMAPIError, "Multimodal HTTP Error"
|
|
73
|
+
rescue JSON::ParserError => e
|
|
74
|
+
SmartPrompt.logger.error "Failed to parse Multimodal API response"
|
|
75
|
+
raise LLMAPIError, "Failed to parse Multimodal API response"
|
|
76
|
+
rescue => e
|
|
77
|
+
SmartPrompt.logger.error "Unexpected error during Multimodal request: #{e.message}"
|
|
78
|
+
raise Error, "Unexpected error during Multimodal request: #{e.message}"
|
|
79
|
+
ensure
|
|
80
|
+
SmartPrompt.logger.info "Successfully sent multimodal message"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
SmartPrompt.logger.info "MultimodalAdapter: Received response from Multimodal API"
|
|
84
|
+
|
|
85
|
+
if proc.nil?
|
|
86
|
+
@last_response = response
|
|
87
|
+
return response.dig("choices", 0, "message", "content")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Analyze image with text prompt
|
|
92
|
+
def analyze_image(image_input, prompt, model = nil, detail: "auto", max_tokens: nil)
|
|
93
|
+
SmartPrompt.logger.info "MultimodalAdapter: Analyzing image"
|
|
94
|
+
|
|
95
|
+
messages = [
|
|
96
|
+
{
|
|
97
|
+
role: "user",
|
|
98
|
+
content: [
|
|
99
|
+
{ type: "text", text: prompt },
|
|
100
|
+
{ type: "image_url", image_url: prepare_image_input(image_input, detail) }
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
model_name = model || @config["model"]
|
|
106
|
+
parameters = {
|
|
107
|
+
model: model_name,
|
|
108
|
+
messages: messages,
|
|
109
|
+
temperature: @config["temperature"] || 0.7,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
parameters[:max_tokens] = max_tokens if max_tokens
|
|
113
|
+
|
|
114
|
+
response = @client.chat(parameters: parameters)
|
|
115
|
+
@last_response = response
|
|
116
|
+
response.dig("choices", 0, "message", "content")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Analyze video with text prompt
|
|
120
|
+
def analyze_video(video_input, prompt, model = nil, max_frames: 10, fps: 1, detail: "auto")
|
|
121
|
+
SmartPrompt.logger.info "MultimodalAdapter: Analyzing video"
|
|
122
|
+
|
|
123
|
+
messages = [
|
|
124
|
+
{
|
|
125
|
+
role: "user",
|
|
126
|
+
content: [
|
|
127
|
+
{ type: "text", text: prompt },
|
|
128
|
+
{ type: "video_url", video_url: prepare_video_input(video_input, max_frames, fps, detail) }
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
model_name = model || @config["model"]
|
|
134
|
+
response = @client.chat(parameters: {
|
|
135
|
+
model: model_name,
|
|
136
|
+
messages: messages,
|
|
137
|
+
temperature: @config["temperature"] || 0.7,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
@last_response = response
|
|
141
|
+
response.dig("choices", 0, "message", "content")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Multi-image analysis
|
|
145
|
+
def analyze_multiple_images(images, prompt, model = nil, detail: "auto")
|
|
146
|
+
SmartPrompt.logger.info "MultimodalAdapter: Analyzing multiple images"
|
|
147
|
+
|
|
148
|
+
content = [{ type: "text", text: prompt }]
|
|
149
|
+
images.each do |image_input|
|
|
150
|
+
content << { type: "image_url", image_url: prepare_image_input(image_input, detail) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
messages = [{ role: "user", content: content }]
|
|
154
|
+
|
|
155
|
+
model_name = model || @config["model"]
|
|
156
|
+
response = @client.chat(parameters: {
|
|
157
|
+
model: model_name,
|
|
158
|
+
messages: messages,
|
|
159
|
+
temperature: @config["temperature"] || 0.7,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
@last_response = response
|
|
163
|
+
response.dig("choices", 0, "message", "content")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def process_multimodal_messages(messages)
|
|
169
|
+
messages.map do |message|
|
|
170
|
+
if message[:content].is_a?(Array)
|
|
171
|
+
# Process content array with multimodal elements
|
|
172
|
+
processed_content = message[:content].map do |content_item|
|
|
173
|
+
if content_item.is_a?(Hash)
|
|
174
|
+
case content_item[:type]
|
|
175
|
+
when "image_url"
|
|
176
|
+
{ type: "image_url", image_url: prepare_image_input(content_item[:image_url], content_item[:detail]) }
|
|
177
|
+
when "video_url"
|
|
178
|
+
{ type: "video_url", video_url: prepare_video_input(content_item[:video_url], content_item[:max_frames], content_item[:fps], content_item[:detail]) }
|
|
179
|
+
else
|
|
180
|
+
content_item
|
|
181
|
+
end
|
|
182
|
+
else
|
|
183
|
+
{ type: "text", text: content_item.to_s }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
{ role: message[:role], content: processed_content }
|
|
187
|
+
else
|
|
188
|
+
message
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def prepare_image_input(image_input, detail = "auto")
|
|
194
|
+
detail ||= "auto"
|
|
195
|
+
|
|
196
|
+
case image_input
|
|
197
|
+
when String
|
|
198
|
+
if image_input.start_with?("http://", "https://")
|
|
199
|
+
{ url: image_input, detail: detail }
|
|
200
|
+
elsif File.exist?(image_input)
|
|
201
|
+
# Convert local file to base64
|
|
202
|
+
file_ext = File.extname(image_input).downcase.delete(".")
|
|
203
|
+
unless SUPPORTED_IMAGE_FORMATS.include?(file_ext)
|
|
204
|
+
raise Error, "Unsupported image format: #{file_ext}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
image_data = File.binread(image_input)
|
|
208
|
+
base64_data = Base64.strict_encode64(image_data)
|
|
209
|
+
mime_type = "image/#{file_ext == 'jpg' ? 'jpeg' : file_ext}"
|
|
210
|
+
|
|
211
|
+
{ url: "data:#{mime_type};base64,#{base64_data}", detail: detail }
|
|
212
|
+
else
|
|
213
|
+
raise Error, "Invalid image input: #{image_input}"
|
|
214
|
+
end
|
|
215
|
+
when Hash
|
|
216
|
+
# Assume it's already formatted
|
|
217
|
+
image_input[:detail] ||= detail
|
|
218
|
+
image_input
|
|
219
|
+
else
|
|
220
|
+
raise Error, "Unsupported image input type: #{image_input.class}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def prepare_video_input(video_input, max_frames = 10, fps = 1, detail = "auto")
|
|
225
|
+
max_frames ||= 10
|
|
226
|
+
fps ||= 1
|
|
227
|
+
detail ||= "auto"
|
|
228
|
+
|
|
229
|
+
case video_input
|
|
230
|
+
when String
|
|
231
|
+
if video_input.start_with?("http://", "https://")
|
|
232
|
+
{
|
|
233
|
+
url: video_input,
|
|
234
|
+
detail: detail,
|
|
235
|
+
max_frames: max_frames,
|
|
236
|
+
fps: fps
|
|
237
|
+
}
|
|
238
|
+
elsif File.exist?(video_input)
|
|
239
|
+
# For local files, we'd need to upload or convert
|
|
240
|
+
# Currently only support URLs for videos
|
|
241
|
+
raise Error, "Local video files not yet supported. Please provide a URL."
|
|
242
|
+
else
|
|
243
|
+
raise Error, "Invalid video input: #{video_input}"
|
|
244
|
+
end
|
|
245
|
+
when Hash
|
|
246
|
+
# Assume it's already formatted
|
|
247
|
+
video_input[:max_frames] ||= max_frames
|
|
248
|
+
video_input[:fps] ||= fps
|
|
249
|
+
video_input[:detail] ||= detail
|
|
250
|
+
video_input
|
|
251
|
+
else
|
|
252
|
+
raise Error, "Unsupported video input type: #{video_input.class}"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def embeddings(text, model)
|
|
257
|
+
SmartPrompt.logger.info "MultimodalAdapter: Getting embeddings"
|
|
258
|
+
|
|
259
|
+
model_name = model || @config["model"]
|
|
260
|
+
begin
|
|
261
|
+
response = @client.embeddings(
|
|
262
|
+
parameters: {
|
|
263
|
+
model: model_name,
|
|
264
|
+
input: text.to_s,
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
rescue => e
|
|
268
|
+
SmartPrompt.logger.error "Unexpected error during embeddings request: #{e.message}"
|
|
269
|
+
raise Error, "Unexpected error during embeddings request: #{e.message}"
|
|
270
|
+
ensure
|
|
271
|
+
SmartPrompt.logger.info "Successfully got embeddings"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
response.dig("data", 0, "embedding")
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -31,19 +31,7 @@ module SmartPrompt
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
max_tokens
|
|
36
|
-
max_completion_tokens
|
|
37
|
-
top_p
|
|
38
|
-
top_k
|
|
39
|
-
response_format
|
|
40
|
-
tool_choice
|
|
41
|
-
parallel_tool_calls
|
|
42
|
-
seed
|
|
43
|
-
stop
|
|
44
|
-
].freeze
|
|
45
|
-
|
|
46
|
-
def send_request(messages, model = nil, temperature = 0.7, tools = nil, proc = nil, request_options = {})
|
|
34
|
+
def send_request(messages, model = nil, temperature = 0.7, tools = nil, proc = nil)
|
|
47
35
|
SmartPrompt.logger.info "OpenAIAdapter: Sending request to OpenAI"
|
|
48
36
|
temperature = 0.7 if temperature == nil
|
|
49
37
|
if model
|
|
@@ -58,8 +46,6 @@ module SmartPrompt
|
|
|
58
46
|
messages: messages,
|
|
59
47
|
temperature: @config["temperature"] || temperature,
|
|
60
48
|
}
|
|
61
|
-
parameters.merge!(configured_request_parameters)
|
|
62
|
-
parameters.merge!(request_options || {})
|
|
63
49
|
if proc
|
|
64
50
|
parameters[:stream] = proc
|
|
65
51
|
end
|
|
@@ -113,15 +99,5 @@ module SmartPrompt
|
|
|
113
99
|
end
|
|
114
100
|
return response.dig("data", 0, "embedding")
|
|
115
101
|
end
|
|
116
|
-
|
|
117
|
-
private
|
|
118
|
-
|
|
119
|
-
def configured_request_parameters
|
|
120
|
-
REQUEST_PARAMETER_KEYS.each_with_object({}) do |key, parameters|
|
|
121
|
-
next unless @config.key?(key)
|
|
122
|
-
|
|
123
|
-
parameters[key.to_sym] = @config[key]
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
102
|
end
|
|
127
103
|
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'thread'
|
|
4
|
+
|
|
5
|
+
module SmartPrompt
|
|
6
|
+
# AsyncWriter handles asynchronous write operations to avoid blocking
|
|
7
|
+
class AsyncWriter
|
|
8
|
+
def initialize
|
|
9
|
+
@queue = Queue.new
|
|
10
|
+
@worker_thread = nil
|
|
11
|
+
@running = false
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Enqueue a block to be executed asynchronously
|
|
16
|
+
def enqueue(&block)
|
|
17
|
+
ensure_worker_running
|
|
18
|
+
@queue << block
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Stop the worker thread gracefully
|
|
22
|
+
def stop
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
@running = false
|
|
25
|
+
end
|
|
26
|
+
@queue << :stop if @worker_thread
|
|
27
|
+
@worker_thread&.join
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Check if the worker is running
|
|
31
|
+
def running?
|
|
32
|
+
@mutex.synchronize { @running }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def ensure_worker_running
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
return if @running
|
|
40
|
+
|
|
41
|
+
@running = true
|
|
42
|
+
@worker_thread = Thread.new { worker_loop }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def worker_loop
|
|
47
|
+
loop do
|
|
48
|
+
task = @queue.pop
|
|
49
|
+
break if task == :stop
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
task.call if task.respond_to?(:call)
|
|
53
|
+
rescue => e
|
|
54
|
+
SmartPrompt.logger.error "AsyncWriter task failed: #{e.message}\n#{e.backtrace.join("\n")}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
rescue => e
|
|
58
|
+
SmartPrompt.logger.error "AsyncWriter worker loop crashed: #{e.message}"
|
|
59
|
+
ensure
|
|
60
|
+
@mutex.synchronize { @running = false }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# PersistenceLayer handles saving and loading session data to/from disk
|
|
65
|
+
class PersistenceLayer
|
|
66
|
+
attr_reader :storage_path, :enabled
|
|
67
|
+
|
|
68
|
+
def initialize(config = {})
|
|
69
|
+
@backend = config[:backend] || :filesystem
|
|
70
|
+
@storage_path = config[:storage_path] || "./history_data"
|
|
71
|
+
@async_writer = AsyncWriter.new
|
|
72
|
+
@enabled = config[:enabled] != false
|
|
73
|
+
@async = config[:async] != false
|
|
74
|
+
|
|
75
|
+
# Create storage directory if persistence is enabled
|
|
76
|
+
ensure_storage_directory if @enabled
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Save a session synchronously
|
|
80
|
+
def save(session)
|
|
81
|
+
return unless @enabled
|
|
82
|
+
|
|
83
|
+
file_path = session_file_path(session.id)
|
|
84
|
+
data = serialize_session(session)
|
|
85
|
+
|
|
86
|
+
File.write(file_path, data)
|
|
87
|
+
SmartPrompt.logger.info "Session #{session.id} saved to #{file_path}"
|
|
88
|
+
rescue => e
|
|
89
|
+
SmartPrompt.logger.error "Failed to save session #{session.id}: #{e.message}"
|
|
90
|
+
# Continue operating with in-memory storage (fallback behavior)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Save a session asynchronously
|
|
94
|
+
def save_async(session)
|
|
95
|
+
return unless @enabled
|
|
96
|
+
|
|
97
|
+
if @async
|
|
98
|
+
@async_writer.enqueue do
|
|
99
|
+
save(session)
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
# If async is disabled, fall back to synchronous save
|
|
103
|
+
save(session)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Load a session from disk
|
|
108
|
+
def load(session_id)
|
|
109
|
+
return nil unless @enabled
|
|
110
|
+
|
|
111
|
+
file_path = session_file_path(session_id)
|
|
112
|
+
return nil unless File.exist?(file_path)
|
|
113
|
+
|
|
114
|
+
data = File.read(file_path)
|
|
115
|
+
session_data = deserialize_session(data)
|
|
116
|
+
|
|
117
|
+
SmartPrompt.logger.info "Session #{session_id} loaded from #{file_path}"
|
|
118
|
+
session_data
|
|
119
|
+
rescue => e
|
|
120
|
+
SmartPrompt.logger.error "Failed to load session #{session_id}: #{e.message}"
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Delete a session from disk
|
|
125
|
+
def delete(session_id)
|
|
126
|
+
return unless @enabled
|
|
127
|
+
|
|
128
|
+
file_path = session_file_path(session_id)
|
|
129
|
+
if File.exist?(file_path)
|
|
130
|
+
File.delete(file_path)
|
|
131
|
+
SmartPrompt.logger.info "Session #{session_id} deleted from disk"
|
|
132
|
+
end
|
|
133
|
+
rescue => e
|
|
134
|
+
SmartPrompt.logger.error "Failed to delete session #{session_id}: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Check if a session exists on disk
|
|
138
|
+
def exists?(session_id)
|
|
139
|
+
return false unless @enabled
|
|
140
|
+
|
|
141
|
+
file_path = session_file_path(session_id)
|
|
142
|
+
File.exist?(file_path)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# List all session IDs stored on disk
|
|
146
|
+
def list_sessions
|
|
147
|
+
return [] unless @enabled
|
|
148
|
+
|
|
149
|
+
Dir.glob(File.join(@storage_path, "*.json")).map do |file|
|
|
150
|
+
File.basename(file, ".json")
|
|
151
|
+
end
|
|
152
|
+
rescue => e
|
|
153
|
+
SmartPrompt.logger.error "Failed to list sessions: #{e.message}"
|
|
154
|
+
[]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Stop the async writer gracefully
|
|
158
|
+
def shutdown
|
|
159
|
+
@async_writer.stop if @async_writer
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# Ensure the storage directory exists
|
|
165
|
+
def ensure_storage_directory
|
|
166
|
+
return if Dir.exist?(@storage_path)
|
|
167
|
+
|
|
168
|
+
FileUtils.mkdir_p(@storage_path)
|
|
169
|
+
SmartPrompt.logger.info "Created storage directory: #{@storage_path}"
|
|
170
|
+
rescue => e
|
|
171
|
+
SmartPrompt.logger.error "Failed to create storage directory #{@storage_path}: #{e.message}"
|
|
172
|
+
@enabled = false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Get the file path for a session
|
|
176
|
+
def session_file_path(session_id)
|
|
177
|
+
File.join(@storage_path, "#{session_id}.json")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Serialize a session to JSON
|
|
181
|
+
def serialize_session(session)
|
|
182
|
+
JSON.pretty_generate({
|
|
183
|
+
id: session.id,
|
|
184
|
+
messages: session.messages.map(&:to_h),
|
|
185
|
+
metadata: session.metadata,
|
|
186
|
+
created_at: session.created_at.iso8601,
|
|
187
|
+
updated_at: session.updated_at.iso8601,
|
|
188
|
+
config: session.config
|
|
189
|
+
})
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Deserialize session data from JSON
|
|
193
|
+
def deserialize_session(data)
|
|
194
|
+
JSON.parse(data, symbolize_names: true)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|