openclacky 1.5.12 → 1.5.13
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 +48 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/message_compressor_helper.rb +52 -1
- data/lib/clacky/agent/session_serializer.rb +35 -2
- data/lib/clacky/agent/time_machine.rb +9 -1
- data/lib/clacky/agent.rb +11 -7
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/platform_billing.rb +84 -1
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +6 -2
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/providers.rb +18 -6
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +147 -103
- data/lib/clacky/tools/terminal.rb +27 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/utils/model_pricing.rb +70 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +412 -108
- data/lib/clacky/web/components/composer.js +45 -7
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +58 -2
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/view.js +20 -6
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +31 -22
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +54 -3
- data/lib/clacky/web/i18n.js +73 -11
- data/lib/clacky/web/index.html +92 -37
- data/lib/clacky/web/sessions.js +550 -592
- data/lib/clacky/web/settings.js +163 -190
- data/lib/clacky.rb +3 -0
- metadata +8 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
# Reassembles an OpenAI Responses API event stream into the non-streaming
|
|
7
|
+
# response shape that MessageFormat::OpenAIResponses.parse_response consumes,
|
|
8
|
+
# while invoking on_chunk(input_tokens:, output_tokens:) for progress updates.
|
|
9
|
+
#
|
|
10
|
+
# The Responses API uses typed SSE events rather than the Chat Completions
|
|
11
|
+
# delta-based format. Key event types:
|
|
12
|
+
#
|
|
13
|
+
# response.created -> response object created
|
|
14
|
+
# response.output_item.added -> new output item (message or function_call)
|
|
15
|
+
# response.output_text.delta -> text content delta
|
|
16
|
+
# response.output_text.done -> text content complete
|
|
17
|
+
# response.reasoning_text.delta -> reasoning content delta (OpenAI official / DeepSeek)
|
|
18
|
+
# response.function_call_arguments.delta -> tool call argument delta
|
|
19
|
+
# response.output_item.done -> output item complete
|
|
20
|
+
# response.completed -> final event with full response + usage
|
|
21
|
+
# response.done -> terminal event (OpenAI official API)
|
|
22
|
+
# response.incomplete -> response ended prematurely
|
|
23
|
+
#
|
|
24
|
+
# Unlike Chat Completions, there is no "[DONE]" sentinel; the stream ends
|
|
25
|
+
# with a "response.completed" / "response.done" (or "response.incomplete") event.
|
|
26
|
+
class OpenAIResponsesStreamAggregator
|
|
27
|
+
def initialize(on_chunk: nil)
|
|
28
|
+
@on_chunk = on_chunk
|
|
29
|
+
@text = +""
|
|
30
|
+
@reasoning_text = +""
|
|
31
|
+
@function_calls = {} # call_id -> { name:, arguments: }
|
|
32
|
+
@call_id_by_item_id = {} # item_id -> call_id (for delta routing)
|
|
33
|
+
@usage = nil
|
|
34
|
+
@status = nil
|
|
35
|
+
@incomplete_details = nil
|
|
36
|
+
@last_input_tokens = 0
|
|
37
|
+
@last_output_tokens = 0
|
|
38
|
+
@parse_failures = 0
|
|
39
|
+
@frames_seen = 0
|
|
40
|
+
@bytes_seen = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_reader :parse_failures, :frames_seen, :bytes_seen
|
|
44
|
+
|
|
45
|
+
def saw_done?
|
|
46
|
+
!@status.nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def handle(data_str)
|
|
50
|
+
@bytes_seen += data_str.bytesize
|
|
51
|
+
@frames_seen += 1
|
|
52
|
+
data = parse_or_nil(data_str)
|
|
53
|
+
return unless data
|
|
54
|
+
|
|
55
|
+
event_type = data["type"]
|
|
56
|
+
|
|
57
|
+
case event_type
|
|
58
|
+
when "response.output_text.delta"
|
|
59
|
+
@text << data["delta"].to_s
|
|
60
|
+
emit_estimate_progress
|
|
61
|
+
# OpenAI's official API emits response.reasoning_text.delta; keep
|
|
62
|
+
# response.reasoning.delta as a tolerant alias for providers that
|
|
63
|
+
# rename it.
|
|
64
|
+
when "response.reasoning_text.delta", "response.reasoning.delta"
|
|
65
|
+
@reasoning_text << data["delta"].to_s
|
|
66
|
+
when "response.output_item.added"
|
|
67
|
+
handle_item_added(data)
|
|
68
|
+
when "response.function_call_arguments.delta"
|
|
69
|
+
handle_function_call_delta(data)
|
|
70
|
+
when "response.output_item.done"
|
|
71
|
+
handle_item_done(data)
|
|
72
|
+
when "response.completed", "response.done"
|
|
73
|
+
# OpenAI's official API sends response.completed followed by
|
|
74
|
+
# response.done as the terminal event; DeepSeek sends only
|
|
75
|
+
# response.completed. Either one marks a successful end.
|
|
76
|
+
@status = "completed"
|
|
77
|
+
response = data["response"]
|
|
78
|
+
@usage ||= response["usage"] if response && response["usage"]
|
|
79
|
+
# Also sync output items from the final response if our incremental
|
|
80
|
+
# tracking missed anything (e.g. provider sent complete items only).
|
|
81
|
+
sync_from_final_output(response["output"]) if response && response["output"]
|
|
82
|
+
emit_usage_progress(@usage) if @usage
|
|
83
|
+
when "response.incomplete"
|
|
84
|
+
@status = "incomplete"
|
|
85
|
+
# Preserve incomplete_details (e.g. reason: "max_output_tokens" vs
|
|
86
|
+
# "content_filter") so finish_reason mapping can distinguish a token
|
|
87
|
+
# limit from a safety-filtered response.
|
|
88
|
+
response = data["response"]
|
|
89
|
+
@incomplete_details = response["incomplete_details"] if response.is_a?(Hash) && response["incomplete_details"]
|
|
90
|
+
when "response.created", "response.in_progress"
|
|
91
|
+
# Informational events, no action needed
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Render into the non-streaming Responses API response shape
|
|
96
|
+
# so MessageFormat::OpenAIResponses.parse_response works unchanged.
|
|
97
|
+
def to_h
|
|
98
|
+
output = []
|
|
99
|
+
|
|
100
|
+
# Message item with text content
|
|
101
|
+
unless @text.empty?
|
|
102
|
+
output << {
|
|
103
|
+
"type" => "message",
|
|
104
|
+
"role" => "assistant",
|
|
105
|
+
"content" => [{ "type" => "output_text", "text" => @text.to_s }]
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Reasoning item. Both providers stream reasoning via
|
|
110
|
+
# response.reasoning(_text).delta; emit it as a top-level "reasoning"
|
|
111
|
+
# item (DeepSeek shape) so MessageFormat::OpenAIResponses#extract_reasoning
|
|
112
|
+
# can consume it. Without this the streaming path silently drops
|
|
113
|
+
# reasoning content while the non-streaming path keeps it.
|
|
114
|
+
unless @reasoning_text.empty?
|
|
115
|
+
output << {
|
|
116
|
+
"type" => "reasoning",
|
|
117
|
+
"content" => [{ "type" => "reasoning_text", "text" => @reasoning_text.to_s }]
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Function call items
|
|
122
|
+
@function_calls.each_value do |fc|
|
|
123
|
+
output << {
|
|
124
|
+
"type" => "function_call",
|
|
125
|
+
"call_id" => fc[:call_id],
|
|
126
|
+
"name" => fc[:name],
|
|
127
|
+
"arguments" => fc[:arguments].to_s
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
result = {
|
|
132
|
+
"output" => output,
|
|
133
|
+
"status" => @status || "completed",
|
|
134
|
+
"usage" => @usage || {}
|
|
135
|
+
}
|
|
136
|
+
result["incomplete_details"] = @incomplete_details if @incomplete_details
|
|
137
|
+
result
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private def handle_item_added(data)
|
|
141
|
+
item = data["item"]
|
|
142
|
+
return unless item
|
|
143
|
+
|
|
144
|
+
if item["type"] == "function_call"
|
|
145
|
+
call_id = item["call_id"]
|
|
146
|
+
item_id = item["id"]
|
|
147
|
+
@call_id_by_item_id[item_id] = call_id if item_id
|
|
148
|
+
@function_calls[call_id] = {
|
|
149
|
+
call_id: call_id,
|
|
150
|
+
name: item["name"],
|
|
151
|
+
arguments: +""
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private def handle_function_call_delta(data)
|
|
157
|
+
# The delta event carries item_id to identify which function call
|
|
158
|
+
# the arguments belong to. Map it to the call_id we registered in
|
|
159
|
+
# handle_item_added. Fall back to the most recent call if mapping fails.
|
|
160
|
+
item_id = data["item_id"]
|
|
161
|
+
call_id = @call_id_by_item_id[item_id] || @function_calls.keys.last
|
|
162
|
+
|
|
163
|
+
return unless call_id && @function_calls[call_id]
|
|
164
|
+
|
|
165
|
+
@function_calls[call_id][:arguments] << data["delta"].to_s
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private def handle_item_done(data)
|
|
169
|
+
item = data["item"]
|
|
170
|
+
return unless item
|
|
171
|
+
|
|
172
|
+
if item["type"] == "function_call"
|
|
173
|
+
call_id = item["call_id"]
|
|
174
|
+
fc = @function_calls[call_id]
|
|
175
|
+
# Use the complete arguments from the done event if available -
|
|
176
|
+
# more reliable than accumulated deltas.
|
|
177
|
+
if fc && item["arguments"]
|
|
178
|
+
fc[:arguments] = item["arguments"]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private def sync_from_final_output(output)
|
|
184
|
+
return unless output.is_a?(Array)
|
|
185
|
+
|
|
186
|
+
# Sync per-item: only backfill what incremental tracking missed.
|
|
187
|
+
# A provider may send complete message items but only deltas for
|
|
188
|
+
# function calls (or vice versa), so don't require both to be empty.
|
|
189
|
+
output.each do |item|
|
|
190
|
+
case item["type"]
|
|
191
|
+
when "message"
|
|
192
|
+
content = item["content"]
|
|
193
|
+
next unless content.is_a?(Array)
|
|
194
|
+
|
|
195
|
+
content.each do |block|
|
|
196
|
+
case block["type"]
|
|
197
|
+
when "output_text"
|
|
198
|
+
@text << block["text"].to_s if @text.empty?
|
|
199
|
+
when "reasoning"
|
|
200
|
+
backfill_reasoning(block["content"], block["summary"])
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
when "reasoning"
|
|
204
|
+
backfill_reasoning(item["content"], item["summary"])
|
|
205
|
+
when "function_call"
|
|
206
|
+
next if @function_calls.key?(item["call_id"])
|
|
207
|
+
@function_calls[item["call_id"]] = {
|
|
208
|
+
call_id: item["call_id"],
|
|
209
|
+
name: item["name"],
|
|
210
|
+
arguments: item["arguments"].to_s
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Backfill @reasoning_text from the final response snapshot when no
|
|
217
|
+
# reasoning delta was seen. Handles both item shapes: OpenAI's official
|
|
218
|
+
# reasoning block inside the message item, and DeepSeek's top-level
|
|
219
|
+
# reasoning item. Prefers full reasoning text; falls back to the summary
|
|
220
|
+
# (mirrors MessageFormat::OpenAIResponses#extract_reasoning).
|
|
221
|
+
private def backfill_reasoning(content_blocks, summary_blocks = nil)
|
|
222
|
+
return unless @reasoning_text.empty?
|
|
223
|
+
|
|
224
|
+
return if append_typed_blocks(content_blocks, "reasoning_text")
|
|
225
|
+
|
|
226
|
+
append_typed_blocks(content_blocks, "summary_text") || append_typed_blocks(summary_blocks, "summary_text")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Append every block of the given type to @reasoning_text.
|
|
230
|
+
# Returns true when at least one block matched.
|
|
231
|
+
private def append_typed_blocks(blocks, type)
|
|
232
|
+
return false unless blocks.is_a?(Array)
|
|
233
|
+
|
|
234
|
+
found = false
|
|
235
|
+
blocks.each do |block|
|
|
236
|
+
next unless block.is_a?(Hash) && block["type"] == type
|
|
237
|
+
|
|
238
|
+
@reasoning_text << block["text"].to_s if block["text"]
|
|
239
|
+
found = true
|
|
240
|
+
end
|
|
241
|
+
found
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private def parse_or_nil(s)
|
|
245
|
+
# Some providers / proxies append a "[DONE]" sentinel (inherited from
|
|
246
|
+
# Chat Completions SSE) even for the Responses API. Silently ignore it
|
|
247
|
+
# instead of logging a parse failure.
|
|
248
|
+
return nil if s.to_s.strip == "[DONE]"
|
|
249
|
+
|
|
250
|
+
JSON.parse(s)
|
|
251
|
+
rescue JSON::ParserError => e
|
|
252
|
+
@parse_failures += 1
|
|
253
|
+
if @parse_failures == 1
|
|
254
|
+
Clacky::Logger.warn("stream.parse_failure",
|
|
255
|
+
provider: "openai-responses",
|
|
256
|
+
error: "#{e.class}: #{e.message}",
|
|
257
|
+
frame_head: s.to_s[0, 200],
|
|
258
|
+
frame_bytes: s.to_s.bytesize
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private def emit_estimate_progress
|
|
265
|
+
return unless @on_chunk
|
|
266
|
+
output = approximate_output_tokens
|
|
267
|
+
return if output == @last_output_tokens
|
|
268
|
+
@last_output_tokens = output
|
|
269
|
+
@on_chunk.call(input_tokens: @last_input_tokens, output_tokens: output)
|
|
270
|
+
rescue => e
|
|
271
|
+
Clacky::Logger.warn("[OpenAIResponsesStreamAggregator] on_chunk: #{e.class}: #{e.message}")
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Rough char/4 estimate; replaced by the real count when the upstream
|
|
275
|
+
# emits the response.completed event with usage data.
|
|
276
|
+
private def approximate_output_tokens
|
|
277
|
+
total_chars = @text.bytesize + @reasoning_text.bytesize +
|
|
278
|
+
@function_calls.values.sum { |tc| tc[:arguments].to_s.bytesize }
|
|
279
|
+
(total_chars / 4.0).ceil
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private def emit_usage_progress(u)
|
|
283
|
+
return unless @on_chunk
|
|
284
|
+
total_prompt = u["input_tokens"].to_i
|
|
285
|
+
output = u["output_tokens"].to_i
|
|
286
|
+
return if total_prompt == @last_input_tokens && output == @last_output_tokens
|
|
287
|
+
@last_input_tokens = total_prompt
|
|
288
|
+
@last_output_tokens = output
|
|
289
|
+
@on_chunk.call(input_tokens: total_prompt, output_tokens: output)
|
|
290
|
+
rescue => e
|
|
291
|
+
Clacky::Logger.warn("[OpenAIResponsesStreamAggregator] on_chunk: #{e.class}: #{e.message}")
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
data/lib/clacky/providers.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Clacky
|
|
|
31
31
|
"api" => "bedrock",
|
|
32
32
|
"default_model" => "abs-claude-sonnet-5",
|
|
33
33
|
"models" => [
|
|
34
|
+
"abs-claude-fable-5-1",
|
|
34
35
|
"abs-claude-fable-5",
|
|
35
36
|
"abs-claude-opus-5",
|
|
36
37
|
"abs-claude-opus-4-8",
|
|
@@ -44,6 +45,7 @@ module Clacky
|
|
|
44
45
|
"dsk-deepseek-v4-flash",
|
|
45
46
|
"dsk-deepseek-v4-flash-vision-exp",
|
|
46
47
|
"or-gemini-3-1-pro",
|
|
48
|
+
"or-gemini-3-8-flash",
|
|
47
49
|
"or-gemini-3-7-flash",
|
|
48
50
|
"or-gemini-3-6-flash",
|
|
49
51
|
"or-gemini-3-5-flash"
|
|
@@ -97,34 +99,38 @@ module Clacky
|
|
|
97
99
|
# routes them to Vertex AI Gemini (generateContent with inline
|
|
98
100
|
# audio parts). The gateway returns transcription text.
|
|
99
101
|
"stt_models" => [
|
|
102
|
+
"or-stt-gemini-3-8-flash",
|
|
100
103
|
"or-stt-gemini-3-7-flash",
|
|
101
104
|
"or-stt-gemini-3-6-flash",
|
|
102
105
|
"or-stt-gemini-3-5-flash",
|
|
103
106
|
"or-stt-gemini-1-5-pro"
|
|
104
107
|
],
|
|
105
108
|
"stt_model_aliases" => {
|
|
109
|
+
"or-stt-gemini-3-8-flash" => "Gemini 3.8 Flash STT",
|
|
106
110
|
"or-stt-gemini-3-7-flash" => "Gemini 3.7 Flash STT",
|
|
107
111
|
"or-stt-gemini-3-6-flash" => "Gemini 3.6 Flash STT",
|
|
108
112
|
"or-stt-gemini-3-5-flash" => "Gemini 3.5 Flash STT",
|
|
109
113
|
"or-stt-gemini-1-5-pro" => "Gemini 1.5 Pro STT"
|
|
110
114
|
},
|
|
111
|
-
"default_stt_model" => "or-stt-gemini-3-
|
|
115
|
+
"default_stt_model" => "or-stt-gemini-3-8-flash",
|
|
112
116
|
# Video understanding models served by the openclacky gateway, which
|
|
113
117
|
# routes video frames to Gemini (generateContent with inline image
|
|
114
118
|
# parts). The gateway returns analysis text.
|
|
115
119
|
"video_understanding_models" => [
|
|
120
|
+
"or-gemini-3-8-flash",
|
|
116
121
|
"or-gemini-3-7-flash",
|
|
117
122
|
"or-gemini-3-6-flash",
|
|
118
123
|
"or-gemini-3-5-flash",
|
|
119
124
|
"or-gemini-3-1-pro"
|
|
120
125
|
],
|
|
121
126
|
"video_understanding_model_aliases" => {
|
|
127
|
+
"or-gemini-3-8-flash" => "Gemini 3.8 Flash",
|
|
122
128
|
"or-gemini-3-7-flash" => "Gemini 3.7 Flash",
|
|
123
129
|
"or-gemini-3-6-flash" => "Gemini 3.6 Flash",
|
|
124
130
|
"or-gemini-3-5-flash" => "Gemini 3.5 Flash",
|
|
125
131
|
"or-gemini-3-1-pro" => "Gemini 3.1 Pro"
|
|
126
132
|
},
|
|
127
|
-
"default_video_understanding_model" => "or-gemini-3-
|
|
133
|
+
"default_video_understanding_model" => "or-gemini-3-8-flash",
|
|
128
134
|
# Default OCR sidecar — used when the primary model is text-only.
|
|
129
135
|
# Candidates are derived from the provider's vision-capable models;
|
|
130
136
|
# this just picks the cheap+fast default to surface in "auto" mode.
|
|
@@ -148,6 +154,7 @@ module Clacky
|
|
|
148
154
|
# themselves, so they're intentionally not listed here as keys —
|
|
149
155
|
# no injection happens when the default model is already lite-class.
|
|
150
156
|
"lite_models" => {
|
|
157
|
+
"abs-claude-fable-5-1" => "abs-claude-haiku-4-5",
|
|
151
158
|
"abs-claude-fable-5" => "abs-claude-haiku-4-5",
|
|
152
159
|
"abs-claude-opus-5" => "abs-claude-haiku-4-5",
|
|
153
160
|
"abs-claude-opus-4-8" => "abs-claude-haiku-4-5",
|
|
@@ -162,6 +169,7 @@ module Clacky
|
|
|
162
169
|
# Fallback chain: if a model is unavailable, try the next one in order.
|
|
163
170
|
# Keys are primary model names; values are the fallback model to use instead.
|
|
164
171
|
"fallback_models" => {
|
|
172
|
+
"abs-claude-fable-5-1" => "abs-claude-fable-5",
|
|
165
173
|
"abs-claude-fable-5" => "abs-claude-opus-5",
|
|
166
174
|
"abs-claude-opus-5" => "abs-claude-opus-4-8",
|
|
167
175
|
"abs-claude-sonnet-5" => "abs-claude-sonnet-4-6",
|
|
@@ -276,7 +284,7 @@ module Clacky
|
|
|
276
284
|
"base_url" => "https://open.bigmodel.cn/api/paas/v4",
|
|
277
285
|
"api" => "openai-completions",
|
|
278
286
|
"default_model" => "glm-5.3",
|
|
279
|
-
"models" => ["glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
|
|
287
|
+
"models" => ["glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7"],
|
|
280
288
|
# Zhipu / Z.ai expose four functionally-equivalent endpoints:
|
|
281
289
|
# two regional sites (mainland open.bigmodel.cn + international api.z.ai)
|
|
282
290
|
# each with a general-billing and a Coding-Plan subpath. They share the
|
|
@@ -292,10 +300,12 @@ module Clacky
|
|
|
292
300
|
{ "label" => "International · Pay-as-you-go", "label_key" => "settings.models.baseurl.variant.international_payg", "base_url" => "https://api.z.ai/api/paas/v4", "region" => "intl" }.freeze,
|
|
293
301
|
{ "label" => "International · Coding Plan", "label_key" => "settings.models.baseurl.variant.international_coding","base_url" => "https://api.z.ai/api/coding/paas/v4", "region" => "intl" }.freeze
|
|
294
302
|
].freeze,
|
|
295
|
-
# GLM models are text-only except
|
|
303
|
+
# GLM models are text-only except the vision-capable SKUs: glm-5v-turbo
|
|
304
|
+
# ("v" = visual) and glm-5.3-flash (GLM-5's first natively-multimodal model).
|
|
296
305
|
"capabilities" => { "vision" => false }.freeze,
|
|
297
306
|
"model_capabilities" => {
|
|
298
|
-
"glm-5v-turbo" => { "vision" => true }.freeze
|
|
307
|
+
"glm-5v-turbo" => { "vision" => true }.freeze,
|
|
308
|
+
"glm-5.3-flash" => { "vision" => true }.freeze
|
|
299
309
|
}.freeze,
|
|
300
310
|
"default_ocr_model" => "glm-5v-turbo",
|
|
301
311
|
"website_url" => "https://open.bigmodel.cn/console/overview"
|
|
@@ -446,8 +456,9 @@ module Clacky
|
|
|
446
456
|
"name" => "Qwen (Alibaba)",
|
|
447
457
|
"base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
448
458
|
"api" => "openai-completions",
|
|
449
|
-
"default_model" => "qwen3.
|
|
459
|
+
"default_model" => "qwen3.8-max",
|
|
450
460
|
"models" => [
|
|
461
|
+
"qwen3.8-max",
|
|
451
462
|
"qwen3.7-max",
|
|
452
463
|
"qwen3.6-plus",
|
|
453
464
|
"qwen3.6-max",
|
|
@@ -466,6 +477,7 @@ module Clacky
|
|
|
466
477
|
}.freeze,
|
|
467
478
|
"default_ocr_model" => "qwen3.6-flash",
|
|
468
479
|
"lite_models" => {
|
|
480
|
+
"qwen3.8-max" => "qwen3.6-flash",
|
|
469
481
|
"qwen3.7-max" => "qwen3.6-flash",
|
|
470
482
|
"qwen3.6-plus" => "qwen3.6-flash",
|
|
471
483
|
"qwen3.6-max" => "qwen3.6-flash",
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
module Server
|
|
5
|
+
# Directory-picker quick-access places + WSL/Windows drive detection.
|
|
6
|
+
# Extracted from HttpServer to keep the path/place helpers cohesive.
|
|
7
|
+
module DirPicker
|
|
8
|
+
# Windows "special" folders under C:\Users that aren't real user profiles.
|
|
9
|
+
WINDOWS_PROFILE_DIRS = ["Public", "Default", "Default User", "All Users"].freeze
|
|
10
|
+
|
|
11
|
+
# Sidebar quick-access favorites for the directory picker (Finder-style).
|
|
12
|
+
# Translated client-side via `id`. On WSL every favorite targets the
|
|
13
|
+
# Windows profile (/mnt/<drive>/Users/<name>/...) so they match where a
|
|
14
|
+
# Windows browser actually saves files; elsewhere they stay under Dir.home.
|
|
15
|
+
private def dir_picker_places
|
|
16
|
+
home = Dir.home
|
|
17
|
+
win_home = wsl_windows_home
|
|
18
|
+
places = []
|
|
19
|
+
|
|
20
|
+
[["home", win_home || home],
|
|
21
|
+
["desktop", File.join(win_home || home, "Desktop")],
|
|
22
|
+
["downloads", File.join(win_home || home, "Downloads")],
|
|
23
|
+
["documents", File.join(win_home || home, "Documents")]].each do |id, path|
|
|
24
|
+
places << { id: id, path: path, kind: "favorite" } if Dir.exist?(path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
places.concat(dir_picker_drives)
|
|
28
|
+
places
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Drive letters exposed to the picker so Windows users can reach D:/E:/…
|
|
32
|
+
# directly. Under WSL these are the /mnt/<drive> mounts; on native
|
|
33
|
+
# Windows the actual drive letters. Empty on macOS/Linux.
|
|
34
|
+
private def dir_picker_drives
|
|
35
|
+
if wsl?
|
|
36
|
+
mounts = "/mnt"
|
|
37
|
+
return [] unless Dir.exist?(mounts)
|
|
38
|
+
|
|
39
|
+
drives = []
|
|
40
|
+
Dir.children(mounts).sort.each do |drive|
|
|
41
|
+
next unless drive.match?(/\A[a-zA-Z]\z/)
|
|
42
|
+
path = File.join(mounts, drive)
|
|
43
|
+
next unless Dir.exist?(path)
|
|
44
|
+
drives << { id: "drive_#{drive.downcase}", path: path, letter: drive.upcase, kind: "drive" }
|
|
45
|
+
end
|
|
46
|
+
drives
|
|
47
|
+
elsif Gem.win_platform?
|
|
48
|
+
drives = []
|
|
49
|
+
("A".."Z").each do |letter|
|
|
50
|
+
path = "#{letter}:/"
|
|
51
|
+
next unless Dir.exist?(path)
|
|
52
|
+
drives << { id: "drive_#{letter.downcase}", path: path, letter: letter, kind: "drive" }
|
|
53
|
+
end
|
|
54
|
+
drives
|
|
55
|
+
else
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# True when running inside Windows Subsystem for Linux.
|
|
61
|
+
private def wsl?
|
|
62
|
+
return true if ENV["WSL_DISTRO_NAME"]
|
|
63
|
+
return true if File.exist?("/proc/sys/fs/binfmt_misc/WSLInterop")
|
|
64
|
+
version = begin
|
|
65
|
+
File.read("/proc/version")
|
|
66
|
+
rescue StandardError
|
|
67
|
+
""
|
|
68
|
+
end
|
|
69
|
+
version.downcase.include?("microsoft")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Best-effort path to the Windows user profile under WSL, or nil when it
|
|
73
|
+
# can't be determined (then callers fall back to Dir.home). Scans every
|
|
74
|
+
# mounted drive's Users dir instead of assuming the system drive is C:.
|
|
75
|
+
# Username sources are tried most-trusted first:
|
|
76
|
+
# 1. WINDOWS_USERNAME / USERNAME env (explicitly set or WSLENV-forwarded)
|
|
77
|
+
# 2. the live Windows %USERNAME%, read via cmd.exe interop
|
|
78
|
+
# 3. USER (the WSL Linux username, which usually matches the Windows one)
|
|
79
|
+
# 4. the first non-system profile dir found (last-resort guess)
|
|
80
|
+
private def wsl_windows_home
|
|
81
|
+
return nil unless wsl?
|
|
82
|
+
|
|
83
|
+
roots = wsl_windows_users_roots
|
|
84
|
+
wsl_profile_for(roots, ENV["WINDOWS_USERNAME"]) ||
|
|
85
|
+
wsl_profile_for(roots, ENV["USERNAME"]) ||
|
|
86
|
+
wsl_profile_for(roots, wsl_windows_username) ||
|
|
87
|
+
wsl_profile_for(roots, ENV["USER"]) ||
|
|
88
|
+
wsl_first_user_profile(roots)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The first mounted profile dir named exactly `username`, or nil. WSL
|
|
92
|
+
# never inherits Windows env vars by default, which is why `username`
|
|
93
|
+
# may also come from cmd.exe interop rather than ENV.
|
|
94
|
+
private def wsl_profile_for(roots, username)
|
|
95
|
+
return nil if username.nil? || username.empty?
|
|
96
|
+
|
|
97
|
+
roots.each do |root|
|
|
98
|
+
candidate = File.join(root, username)
|
|
99
|
+
return candidate if Dir.exist?(candidate)
|
|
100
|
+
end
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Last-resort guess: the first non-system profile dir under any mounted
|
|
105
|
+
# Users root. Filesystem enumeration order is arbitrary, so on
|
|
106
|
+
# multi-profile machines this may pick an inactive profile — every
|
|
107
|
+
# username source in wsl_windows_home exists to keep this unreached.
|
|
108
|
+
private def wsl_first_user_profile(roots)
|
|
109
|
+
roots.each do |root|
|
|
110
|
+
profiles = Dir.children(root).select do |name|
|
|
111
|
+
full = File.join(root, name)
|
|
112
|
+
File.directory?(full) &&
|
|
113
|
+
!WINDOWS_PROFILE_DIRS.include?(name) &&
|
|
114
|
+
!name.start_with?(".")
|
|
115
|
+
end
|
|
116
|
+
return File.join(root, profiles.first) if profiles.any?
|
|
117
|
+
end
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The live Windows-side %USERNAME%, read via cmd.exe interop. Returns
|
|
122
|
+
# nil when interop is disabled or cmd.exe is unreachable; callers must
|
|
123
|
+
# still validate against the mounted Users directory. Cached —
|
|
124
|
+
# including failures — because interop availability doesn't change
|
|
125
|
+
# within a process and the spawn otherwise costs 100ms+ on every
|
|
126
|
+
# directory listing.
|
|
127
|
+
private def wsl_windows_username
|
|
128
|
+
return @wsl_windows_username if defined?(@wsl_windows_username)
|
|
129
|
+
|
|
130
|
+
@wsl_windows_username = begin
|
|
131
|
+
require "open3"
|
|
132
|
+
out, _err, status = Open3.capture3("cmd.exe", "/c", "echo %USERNAME%", binmode: true)
|
|
133
|
+
status.success? ? out.lines.first&.strip : nil
|
|
134
|
+
rescue StandardError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Every mounted drive that exposes a Windows Users directory, e.g.
|
|
140
|
+
# /mnt/c/Users and /mnt/d/Users.
|
|
141
|
+
private def wsl_windows_users_roots
|
|
142
|
+
mounts = "/mnt"
|
|
143
|
+
return [] unless Dir.exist?(mounts)
|
|
144
|
+
|
|
145
|
+
roots = []
|
|
146
|
+
Dir.children(mounts).each do |drive|
|
|
147
|
+
root = File.join(mounts, drive, "Users")
|
|
148
|
+
roots << root if Dir.exist?(root)
|
|
149
|
+
end
|
|
150
|
+
roots
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -7,8 +7,8 @@ module Clacky
|
|
|
7
7
|
# Read-mostly git operations scoped to a session's working directory, backing
|
|
8
8
|
# the official "git" WebUI panel. Commands run with explicit argv (no shell),
|
|
9
9
|
# so user-supplied values (paths, messages) cannot inject. Write operations
|
|
10
|
-
# are limited to a guarded `commit
|
|
11
|
-
# commands are never exposed here.
|
|
10
|
+
# are limited to a guarded `commit` and a single-file `restore`;
|
|
11
|
+
# history-rewriting / remote-mutating commands are never exposed here.
|
|
12
12
|
module GitPanel
|
|
13
13
|
module_function
|
|
14
14
|
|
|
@@ -56,14 +56,24 @@ module Clacky
|
|
|
56
56
|
{ branch: branch, ahead: ahead, behind: behind, files: files }
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# Unified diff
|
|
60
|
-
#
|
|
61
|
-
#
|
|
59
|
+
# Unified diff for one file, or the whole tree when `file` is nil.
|
|
60
|
+
# Baseline is HEAD, not the index, so changes the agent staged with
|
|
61
|
+
# `git add` still show up. Untracked files have no baseline at all, so
|
|
62
|
+
# they diff against /dev/null via --no-index (rendered as pure
|
|
63
|
+
# additions); the trailing `--` keeps a leading-dash filename from
|
|
64
|
+
# being read as an option in that mode too.
|
|
62
65
|
def diff(dir, file: nil)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
if file && !file.empty?
|
|
67
|
+
others, _e, _ok = git(dir, "ls-files", "--others", "--exclude-standard", "--", file)
|
|
68
|
+
if others.strip.empty?
|
|
69
|
+
out, _e, _ok = git(dir, "diff", "HEAD", "--", file)
|
|
70
|
+
else
|
|
71
|
+
out, _e, _ok = git(dir, "diff", "--no-index", "--", "/dev/null", file)
|
|
72
|
+
end
|
|
73
|
+
out
|
|
74
|
+
else
|
|
75
|
+
git(dir, "diff", "HEAD")[0]
|
|
76
|
+
end
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
# Recent commits: [{ hash:, short:, author:, date:, subject: }].
|
|
@@ -92,6 +102,48 @@ module Clacky
|
|
|
92
102
|
end
|
|
93
103
|
end
|
|
94
104
|
|
|
105
|
+
# Discard uncommitted changes to one file and return it to its HEAD
|
|
106
|
+
# state (same baseline the diff API uses, so "what you saw is what you
|
|
107
|
+
# lose"). Files that never existed in HEAD (staged-new or untracked)
|
|
108
|
+
# have no earlier version, so they are removed instead. argv-only and
|
|
109
|
+
# guarded against path traversal; history rewriting and remote-mutating
|
|
110
|
+
# commands remain out of bounds.
|
|
111
|
+
def restore(dir, file:)
|
|
112
|
+
path = file.to_s.strip
|
|
113
|
+
return { ok: false, error: "file is required" } if path.empty?
|
|
114
|
+
return { ok: false, error: "invalid path" } if invalid_path?(path)
|
|
115
|
+
|
|
116
|
+
head_tree, _e, _ok = git(dir, "ls-tree", "HEAD", "--", path)
|
|
117
|
+
if head_tree.include?("\t#{path}")
|
|
118
|
+
_o, err, ok = git(dir, "checkout", "HEAD", "--", path)
|
|
119
|
+
return ok ? { ok: true } : { ok: false, error: "git checkout failed: #{err.strip}" }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
in_index, _e, idx_ok = git(dir, "ls-files", "--", path)
|
|
123
|
+
if idx_ok && !in_index.strip.empty?
|
|
124
|
+
_o, err, ok = git(dir, "rm", "-f", "--", path)
|
|
125
|
+
return ok ? { ok: true } : { ok: false, error: "git rm failed: #{err.strip}" }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
target = File.expand_path(path, dir.to_s)
|
|
129
|
+
root = File.expand_path(dir.to_s)
|
|
130
|
+
return { ok: false, error: "invalid path" } unless target.start_with?(root + File::SEPARATOR)
|
|
131
|
+
|
|
132
|
+
begin
|
|
133
|
+
File.delete(target)
|
|
134
|
+
{ ok: true }
|
|
135
|
+
rescue Errno::ENOENT
|
|
136
|
+
{ ok: true }
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
{ ok: false, error: e.message }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Relative repo paths only: no absolute paths, no traversal segments.
|
|
143
|
+
def invalid_path?(path)
|
|
144
|
+
path.start_with?("/") || path.split("/").include?("..")
|
|
145
|
+
end
|
|
146
|
+
|
|
95
147
|
# Stage `files` (relative paths) and commit with `message`. Returns
|
|
96
148
|
# { ok:, error?:, hash? }. Refuses empty message / empty file set. Uses
|
|
97
149
|
# argv so paths/message cannot inject; no --no-verify, no amend.
|