chocomint 1.0.3 → 1.1.1
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/README.md +35 -2
- data/THIRD_PARTY_LICENSES.md +1 -0
- data/bin/chocomint-server +2 -1
- data/config/config.yml +39 -37
- data/lib/chocomint/config.rb +137 -5
- data/lib/chocomint/edit_archive_handlers.rb +323 -0
- data/lib/chocomint/edit_handlers.rb +249 -32
- data/lib/chocomint/factory.rb +3 -3
- data/lib/chocomint/llm/chat_client.rb +5 -2
- data/lib/chocomint/llm/primary_client.rb +7 -4
- data/lib/chocomint/planner.rb +8 -6
- data/lib/chocomint/server.rb +53 -3
- data/lib/chocomint.rb +1 -1
- data/public/edit/edit.css +100 -2
- data/public/edit/edit.js +512 -23
- data/public/edit/index.html +60 -0
- metadata +16 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "cgi"
|
|
5
5
|
require "set"
|
|
6
|
+
require "faraday"
|
|
6
7
|
|
|
7
8
|
module Chocomint
|
|
8
9
|
# /edit AI エディタの HTTP ハンドラ群 (Server に include して使う)。
|
|
@@ -110,6 +111,18 @@ module Chocomint
|
|
|
110
111
|
|
|
111
112
|
abs = @path_guard.resolve(path)
|
|
112
113
|
return edit_json(res, 404, "error" => "no such file") unless File.file?(abs)
|
|
114
|
+
|
|
115
|
+
# 画像 / PDF / 動画 / 音声は拡張子で先に判定し、中身を読まずにメディア種別だけ返す
|
|
116
|
+
# (フロントは /edit/raw で取得する)。SVG のようにテキストでもある形式もここで拾う。
|
|
117
|
+
# メディアは巨大化しやすい (動画等) ため @max_file_bytes の上限は掛けない。
|
|
118
|
+
media = media_kind(abs)
|
|
119
|
+
return edit_json(res, 200, "path" => path, "media" => media) if media
|
|
120
|
+
|
|
121
|
+
# zip / tar / tar.gz / bz2 等のアーカイブは第一階層一覧を表示する専用ビューに回す
|
|
122
|
+
# (フロントは /edit/archive で取得する)。中身の展開はここでは行わない。
|
|
123
|
+
return edit_json(res, 200, "path" => path, "archive" => true) if archive?(abs)
|
|
124
|
+
|
|
125
|
+
# テキストとして開くものだけサイズ上限を掛ける (Monaco に丸ごと載せるため)。
|
|
113
126
|
if File.size(abs) > @max_file_bytes
|
|
114
127
|
return edit_json(res, 413, "error" => "file too large")
|
|
115
128
|
end
|
|
@@ -127,6 +140,126 @@ module Chocomint
|
|
|
127
140
|
edit_json(res, 500, "error" => e.message)
|
|
128
141
|
end
|
|
129
142
|
|
|
143
|
+
# ---- 生ファイル配信 (画像 / PDF / 動画 / 音声のプレビュー用) ---------------
|
|
144
|
+
#
|
|
145
|
+
# GET /edit/raw?path=foo.png
|
|
146
|
+
#
|
|
147
|
+
# workspace 内のファイルを、拡張子から推定した Content-Type でそのまま返す。
|
|
148
|
+
# <img> / <video> / <audio> / <iframe> から参照される。テキストと違い巨大化しやすい
|
|
149
|
+
# (動画等) ため @max_file_bytes の上限は掛けず、Range リクエストに応じて部分配信もする
|
|
150
|
+
# (動画のシークや音声再生に必要)。
|
|
151
|
+
def handle_edit_raw(req, res)
|
|
152
|
+
return edit_method_guard(res) unless req.request_method == "GET"
|
|
153
|
+
|
|
154
|
+
path = req.query["path"].to_s
|
|
155
|
+
return edit_json(res, 400, "error" => "path required") if path.empty?
|
|
156
|
+
|
|
157
|
+
abs = @path_guard.resolve(path)
|
|
158
|
+
return edit_json(res, 404, "error" => "no such file") unless File.file?(abs)
|
|
159
|
+
|
|
160
|
+
content_type = media_content_type(abs)
|
|
161
|
+
res["cache-control"] = "no-cache"
|
|
162
|
+
res["accept-ranges"] = "bytes"
|
|
163
|
+
serve_raw_file(req, res, abs, content_type)
|
|
164
|
+
rescue Chocomint::PathAccessError => e
|
|
165
|
+
edit_json(res, 403, "error" => e.message)
|
|
166
|
+
rescue Chocomint::Error => e
|
|
167
|
+
edit_json(res, 500, "error" => e.message)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# 拡張子から Content-Type を推定する。未知の拡張子は octet-stream。
|
|
171
|
+
RAW_CONTENT_TYPES = {
|
|
172
|
+
# 画像 (ラスター)
|
|
173
|
+
".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
|
|
174
|
+
".gif" => "image/gif", ".webp" => "image/webp", ".bmp" => "image/bmp",
|
|
175
|
+
".ico" => "image/x-icon", ".avif" => "image/avif", ".apng" => "image/apng",
|
|
176
|
+
".tif" => "image/tiff", ".tiff" => "image/tiff",
|
|
177
|
+
# 画像 (ベクター)
|
|
178
|
+
".svg" => "image/svg+xml",
|
|
179
|
+
# 文書
|
|
180
|
+
".pdf" => "application/pdf",
|
|
181
|
+
# 動画
|
|
182
|
+
".mp4" => "video/mp4", ".m4v" => "video/mp4", ".webm" => "video/webm",
|
|
183
|
+
".ogv" => "video/ogg", ".mkv" => "video/x-matroska", ".mov" => "video/quicktime",
|
|
184
|
+
".avi" => "video/x-msvideo",
|
|
185
|
+
# 音声
|
|
186
|
+
".mp3" => "audio/mpeg", ".wav" => "audio/wav", ".ogg" => "audio/ogg",
|
|
187
|
+
".oga" => "audio/ogg", ".m4a" => "audio/mp4", ".aac" => "audio/aac",
|
|
188
|
+
".flac" => "audio/flac", ".opus" => "audio/opus", ".weba" => "audio/webm"
|
|
189
|
+
}.freeze
|
|
190
|
+
|
|
191
|
+
def media_content_type(abs)
|
|
192
|
+
RAW_CONTENT_TYPES.fetch(File.extname(abs).downcase, "application/octet-stream")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# 拡張子 → メディア種別 ("image" / "pdf" / "video" / "audio")。フロントはこの値で
|
|
196
|
+
# 表示ウィジェット (<img> / <iframe> / <video> / <audio>) を選ぶ。非メディアは nil。
|
|
197
|
+
MEDIA_KINDS = {
|
|
198
|
+
"image" => %w[.png .jpg .jpeg .gif .webp .bmp .ico .avif .apng .tif .tiff .svg],
|
|
199
|
+
"pdf" => %w[.pdf],
|
|
200
|
+
"video" => %w[.mp4 .m4v .webm .ogv .mkv .mov .avi],
|
|
201
|
+
"audio" => %w[.mp3 .wav .ogg .oga .m4a .aac .flac .opus .weba]
|
|
202
|
+
}.freeze
|
|
203
|
+
|
|
204
|
+
# 拡張子 (小文字) → メディア種別の逆引き表。
|
|
205
|
+
MEDIA_KIND_BY_EXT = MEDIA_KINDS.each_with_object({}) do |(kind, exts), h|
|
|
206
|
+
exts.each { |ext| h[ext] = kind }
|
|
207
|
+
end.freeze
|
|
208
|
+
|
|
209
|
+
def media_kind(abs)
|
|
210
|
+
MEDIA_KIND_BY_EXT[File.extname(abs).downcase]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# ファイルを配信する。Range ヘッダがあれば 206 Partial Content で部分配信する
|
|
214
|
+
# (動画のシーク・一部ブラウザの音声/動画再生に必須)。範囲不正なら 416。
|
|
215
|
+
def serve_raw_file(req, res, abs, content_type)
|
|
216
|
+
size = File.size(abs)
|
|
217
|
+
range = parse_byte_range(req["range"], size)
|
|
218
|
+
|
|
219
|
+
if range.nil? && req["range"].to_s.strip != ""
|
|
220
|
+
# Range 指定はあるが解釈できない → 416 で全長を知らせる。
|
|
221
|
+
res.status = 416
|
|
222
|
+
res["content-range"] = "bytes */#{size}"
|
|
223
|
+
res.body = ""
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
res["content-type"] = content_type
|
|
228
|
+
if range
|
|
229
|
+
first, last = range
|
|
230
|
+
res.status = 206
|
|
231
|
+
res["content-range"] = "bytes #{first}-#{last}/#{size}"
|
|
232
|
+
res["content-length"] = (last - first + 1).to_s
|
|
233
|
+
res.body = File.open(abs, "rb") { |f| f.seek(first); f.read(last - first + 1) }
|
|
234
|
+
else
|
|
235
|
+
res.status = 200
|
|
236
|
+
res["content-length"] = size.to_s
|
|
237
|
+
res.body = File.binread(abs)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# "bytes=first-last" 形式の Range を [first, last] に解釈する (単一範囲のみ対応)。
|
|
242
|
+
# 範囲を持たない/不正/範囲外なら nil を返す。"bytes=500-" や "bytes=-500" にも対応。
|
|
243
|
+
def parse_byte_range(header, size)
|
|
244
|
+
return nil if header.nil? || size.zero?
|
|
245
|
+
|
|
246
|
+
m = /\Abytes=(\d*)-(\d*)\z/.match(header.strip)
|
|
247
|
+
return nil unless m
|
|
248
|
+
|
|
249
|
+
first_s, last_s = m[1], m[2]
|
|
250
|
+
if first_s.empty? && last_s.empty?
|
|
251
|
+
nil
|
|
252
|
+
elsif first_s.empty?
|
|
253
|
+
# 末尾 N バイト。
|
|
254
|
+
n = last_s.to_i
|
|
255
|
+
n.zero? ? nil : [[size - n, 0].max, size - 1]
|
|
256
|
+
else
|
|
257
|
+
first = first_s.to_i
|
|
258
|
+
last = last_s.empty? ? size - 1 : [last_s.to_i, size - 1].min
|
|
259
|
+
(first <= last && first < size) ? [first, last] : nil
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
130
263
|
# テキストとして扱えないか判定する。NUL バイトを含む、または先頭ブロックが
|
|
131
264
|
# 妥当な UTF-8 でないものはバイナリ (エディタで開けない) とみなす。
|
|
132
265
|
BINARY_SNIFF_BYTES = 8192
|
|
@@ -155,7 +288,9 @@ module Chocomint
|
|
|
155
288
|
|
|
156
289
|
abs = @path_guard.resolve(path)
|
|
157
290
|
FileUtils.mkdir_p(File.dirname(abs))
|
|
158
|
-
|
|
291
|
+
# バイナリモードで書き込む。Windows の既定 (テキストモード) だと \n が \r\n に
|
|
292
|
+
# 変換され、クライアントが選んだ改行コード (CRLF/LF) を無視して常に CRLF になってしまう。
|
|
293
|
+
File.binwrite(abs, content)
|
|
159
294
|
edit_json(res, 200, "path" => path, "bytes" => content.bytesize)
|
|
160
295
|
rescue JSON::ParserError => e
|
|
161
296
|
edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
|
|
@@ -173,18 +308,19 @@ module Chocomint
|
|
|
173
308
|
body = JSON.parse(req.body.to_s)
|
|
174
309
|
instruction = body["instruction"].to_s.strip
|
|
175
310
|
path = body["path"].to_s
|
|
311
|
+
history = parse_chat_history(body["history"])
|
|
176
312
|
return edit_json(res, 400, "error" => "instruction required") if instruction.empty?
|
|
177
313
|
|
|
178
314
|
# stream=true なら SSE でツール実行の進捗を逐次配信する (UI のリアルタイム表示用)。
|
|
179
|
-
return handle_edit_chat_stream(res, instruction, path) if body["stream"]
|
|
315
|
+
return handle_edit_chat_stream(res, instruction, path, history) if body["stream"]
|
|
180
316
|
|
|
181
317
|
# ツール不要の普通の質問なら会話として直接応答する。それ以外は Planner に流す。
|
|
182
318
|
if chat_instruction?(instruction)
|
|
183
|
-
return edit_json(res, 200, edit_chat_reply(instruction, path))
|
|
319
|
+
return edit_json(res, 200, edit_chat_reply(instruction, path, history))
|
|
184
320
|
end
|
|
185
321
|
|
|
186
322
|
request = build_chat_request(path, instruction)
|
|
187
|
-
result = @planner.run(request, expectations: instruction)
|
|
323
|
+
result = @planner.run(request, expectations: instruction, history: history)
|
|
188
324
|
edit_json(res, 200, edit_chat_result(result, path, instruction))
|
|
189
325
|
rescue JSON::ParserError => e
|
|
190
326
|
edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
|
|
@@ -199,7 +335,7 @@ module Chocomint
|
|
|
199
335
|
# SSE 版のチャット処理。Planner のステップ進捗を逐次 event として流し、
|
|
200
336
|
# 最後に従来の JSON 結果を "result" event として送って締める。
|
|
201
337
|
# イベント: tool_start / tool_done / result / error (data は JSON 1 行)。
|
|
202
|
-
def handle_edit_chat_stream(res, instruction, path)
|
|
338
|
+
def handle_edit_chat_stream(res, instruction, path, history)
|
|
203
339
|
res.status = 200
|
|
204
340
|
res["content-type"] = "text/event-stream; charset=utf-8"
|
|
205
341
|
res["cache-control"] = "no-cache"
|
|
@@ -207,7 +343,7 @@ module Chocomint
|
|
|
207
343
|
res.body = lambda do |out|
|
|
208
344
|
emit_sse = ->(event, data) { out.write("event: #{event}\ndata: #{JSON.generate(deep_scrub(data))}\n\n") }
|
|
209
345
|
begin
|
|
210
|
-
run_chat_stream(instruction, path, emit_sse)
|
|
346
|
+
run_chat_stream(instruction, path, history, emit_sse)
|
|
211
347
|
rescue Chocomint::InvalidProposalError, Chocomint::UnknownToolError,
|
|
212
348
|
Chocomint::RetryLimitExceededError => e
|
|
213
349
|
emit_sse.call("result", "status" => "FAIL", "error" => humanize_chat_error(e), "steps" => [])
|
|
@@ -218,10 +354,10 @@ module Chocomint
|
|
|
218
354
|
end
|
|
219
355
|
|
|
220
356
|
# SSE 本体: 会話 or Planner 実行を行い、進捗と最終結果を emit_sse で送る。
|
|
221
|
-
def run_chat_stream(instruction, path, emit_sse)
|
|
357
|
+
def run_chat_stream(instruction, path, history, emit_sse)
|
|
222
358
|
# 会話 (ツール不要) は進捗が無いので、そのまま最終結果だけ送る。
|
|
223
359
|
if chat_instruction?(instruction)
|
|
224
|
-
emit_sse.call("result", edit_chat_reply(instruction, path))
|
|
360
|
+
emit_sse.call("result", edit_chat_reply(instruction, path, history))
|
|
225
361
|
return
|
|
226
362
|
end
|
|
227
363
|
|
|
@@ -250,7 +386,7 @@ module Chocomint
|
|
|
250
386
|
end
|
|
251
387
|
end
|
|
252
388
|
|
|
253
|
-
result = @planner.run(request, expectations: instruction, on_event: on_event)
|
|
389
|
+
result = @planner.run(request, expectations: instruction, on_event: on_event, history: history)
|
|
254
390
|
emit_sse.call("result", edit_chat_result(result, path, instruction))
|
|
255
391
|
end
|
|
256
392
|
|
|
@@ -272,8 +408,9 @@ module Chocomint
|
|
|
272
408
|
# ---- 会話ログの圧縮 (/compact) -----------------------------------------
|
|
273
409
|
#
|
|
274
410
|
# ブラウザから現在のチャット表示テキストを受け取り、AI に要点を要約させて返す。
|
|
275
|
-
#
|
|
276
|
-
#
|
|
411
|
+
# 会話履歴はブラウザ側 (表示ログ) にのみ保持され、/edit/chat 送信時に毎回
|
|
412
|
+
# history として渡される。この要約 1 件でログを畳めば、以後の history にも
|
|
413
|
+
# 圧縮済みの文脈として引き継がれる。chat_client 未注入なら 503。
|
|
277
414
|
|
|
278
415
|
# 要約に渡すログの上限 (これを超える古い部分は末尾を優先して切り詰める)。
|
|
279
416
|
COMPACT_INPUT_LIMIT = 12_000
|
|
@@ -299,6 +436,81 @@ module Chocomint
|
|
|
299
436
|
edit_json(res, 500, "error" => e.message)
|
|
300
437
|
end
|
|
301
438
|
|
|
439
|
+
# ---- モデル選択 (ステータスバー) ---------------------------------------
|
|
440
|
+
#
|
|
441
|
+
# ステータスバーのモデルボタンから、現在使っているモデルと選択肢を取得し、
|
|
442
|
+
# 別のモデル (Ollama のローカルモデル / OpenRouter のモデル) に切り替える。
|
|
443
|
+
# config 未注入 (テスト等) なら 503 を返す。
|
|
444
|
+
|
|
445
|
+
# 現在の provider / model と、選択肢 (Ollama のインストール済みモデル一覧、
|
|
446
|
+
# OpenRouter が利用可能かどうか) を返す。
|
|
447
|
+
# GET /edit/models
|
|
448
|
+
def handle_edit_models(req, res)
|
|
449
|
+
return edit_method_guard(res) unless req.request_method == "GET"
|
|
450
|
+
return edit_json(res, 503, "error" => "config not available") unless @config
|
|
451
|
+
|
|
452
|
+
openrouter_key = ENV["OPENROUTER_API_KEY"].to_s
|
|
453
|
+
edit_json(res, 200,
|
|
454
|
+
"provider" => @config.llm_provider,
|
|
455
|
+
"model" => @config.llm_model,
|
|
456
|
+
"ollama_models" => ollama_model_names,
|
|
457
|
+
"openrouter_available" => !openrouter_key.empty?)
|
|
458
|
+
rescue Chocomint::Error => e
|
|
459
|
+
edit_json(res, 500, "error" => e.message)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# provider / model を切り替え、Planner・chat_client を再構築して config.yml に保存する。
|
|
463
|
+
# POST /edit/model { "provider": "ollama"|"openrouter", "model": "..." }
|
|
464
|
+
def handle_edit_model(req, res)
|
|
465
|
+
return edit_method_guard(res) unless req.request_method == "POST"
|
|
466
|
+
return edit_json(res, 503, "error" => "config not available") unless @config
|
|
467
|
+
|
|
468
|
+
body = JSON.parse(req.body.to_s)
|
|
469
|
+
provider = body["provider"].to_s
|
|
470
|
+
model = body["model"].to_s.strip
|
|
471
|
+
return edit_json(res, 400, "error" => "provider required") unless %w[ollama openrouter].include?(provider)
|
|
472
|
+
return edit_json(res, 400, "error" => "model required") if model.empty?
|
|
473
|
+
|
|
474
|
+
# OpenRouter は API キーが環境変数に無いと呼べないので、切り替え前に確認する。
|
|
475
|
+
if provider == "openrouter" && ENV["OPENROUTER_API_KEY"].to_s.empty?
|
|
476
|
+
return edit_json(res, 400,
|
|
477
|
+
"error" => "環境変数 OPENROUTER_API_KEY が設定されていません。" \
|
|
478
|
+
"設定してからサーバーを再起動してください。")
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
@config.set_llm!(provider: provider, model: model)
|
|
482
|
+
rebuild_llm!
|
|
483
|
+
@config.persist_llm!
|
|
484
|
+
|
|
485
|
+
edit_json(res, 200, "provider" => @config.llm_provider, "model" => @config.llm_model)
|
|
486
|
+
rescue JSON::ParserError => e
|
|
487
|
+
edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
|
|
488
|
+
rescue Chocomint::Error => e
|
|
489
|
+
edit_json(res, 500, "error" => e.message)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# 変更後の @config で Planner と chat_client を作り直し、以後のリクエストに反映する。
|
|
493
|
+
def rebuild_llm!
|
|
494
|
+
@planner = Chocomint::Factory.build(@config, base_dir: @base_dir)
|
|
495
|
+
@chat_client = Chocomint::Factory.build_chat_client(@config, base_dir: @base_dir)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Ollama にインストール済みのモデル名一覧を取得する。Ollama ネイティブ API の
|
|
499
|
+
# /api/tags を叩く。現在の provider が OpenRouter でも Ollama タブの候補は
|
|
500
|
+
# Ollama から取るため、config.ollama_api_base (provider 非依存) を使う。
|
|
501
|
+
# 取得失敗時は空配列を返す (UI では「Ollama に接続できません」等の空表示になる)。
|
|
502
|
+
def ollama_model_names
|
|
503
|
+
base = @config.ollama_api_base
|
|
504
|
+
conn = Faraday.new { |f| f.options.timeout = 5; f.options.open_timeout = 5 }
|
|
505
|
+
response = conn.get("#{base}/api/tags")
|
|
506
|
+
return [] unless response.success?
|
|
507
|
+
|
|
508
|
+
data = JSON.parse(response.body)
|
|
509
|
+
Array(data["models"]).filter_map { |m| m["name"] if m.is_a?(Hash) }.sort
|
|
510
|
+
rescue Faraday::Error, JSON::ParserError
|
|
511
|
+
[]
|
|
512
|
+
end
|
|
513
|
+
|
|
302
514
|
# chat_client があり、指示が「会話」と分類されたときだけ会話として扱う。
|
|
303
515
|
# chat_client 未注入なら常に false (従来どおり全て Planner に流す)。
|
|
304
516
|
def chat_instruction?(instruction)
|
|
@@ -312,8 +524,8 @@ module Chocomint
|
|
|
312
524
|
|
|
313
525
|
# 会話 (普通の質問) への直接応答を UI 向け JSON に整形する。
|
|
314
526
|
# 開いているファイルがあれば内容を文脈として渡す。
|
|
315
|
-
def edit_chat_reply(instruction, path)
|
|
316
|
-
answer = @chat_client.answer(instruction, context: chat_file_context(path))
|
|
527
|
+
def edit_chat_reply(instruction, path, history = [])
|
|
528
|
+
answer = @chat_client.answer(instruction, context: chat_file_context(path), history: history)
|
|
317
529
|
{ "status" => "PASS", "reply" => answer, "steps" => [] }
|
|
318
530
|
rescue Chocomint::Error => e
|
|
319
531
|
{ "status" => "FAIL", "error" => e.message, "steps" => [] }
|
|
@@ -336,6 +548,8 @@ module Chocomint
|
|
|
336
548
|
end
|
|
337
549
|
|
|
338
550
|
# 対象ファイルがあれば文脈として明示し、無ければ指示だけを渡す。
|
|
551
|
+
# 過去のやり取りは history として別途 messages の先頭に user/assistant のまま積むため
|
|
552
|
+
# (chat_client#answer / Planner#run 経由)、ここでは今回の指示だけを組み立てる。
|
|
339
553
|
def build_chat_request(path, instruction)
|
|
340
554
|
if path.empty?
|
|
341
555
|
instruction
|
|
@@ -344,6 +558,25 @@ module Chocomint
|
|
|
344
558
|
end
|
|
345
559
|
end
|
|
346
560
|
|
|
561
|
+
# HTTP リクエストの history (JSON 配列) を chat_client / Planner へ渡せる
|
|
562
|
+
# [{ "role" => "user"/"assistant", "content" => String }, ...] に正規化する。
|
|
563
|
+
# 不正な要素は無視する (壊れた履歴で応答全体を失敗させないため)。
|
|
564
|
+
CHAT_HISTORY_LIMIT = 20
|
|
565
|
+
|
|
566
|
+
def parse_chat_history(raw)
|
|
567
|
+
return [] unless raw.is_a?(Array)
|
|
568
|
+
|
|
569
|
+
raw.filter_map do |entry|
|
|
570
|
+
next unless entry.is_a?(Hash)
|
|
571
|
+
|
|
572
|
+
role = entry["role"].to_s
|
|
573
|
+
content = entry["content"].to_s
|
|
574
|
+
next if content.empty? || !%w[user assistant].include?(role)
|
|
575
|
+
|
|
576
|
+
{ "role" => role, "content" => content }
|
|
577
|
+
end.last(CHAT_HISTORY_LIMIT)
|
|
578
|
+
end
|
|
579
|
+
|
|
347
580
|
# Planner::Result を UI 向けの JSON に整形する。編集後内容も返して Monaco を更新する。
|
|
348
581
|
# instruction: 要約生成のための元の要求文 (任意)。
|
|
349
582
|
def edit_chat_result(result, path, instruction = nil)
|
|
@@ -436,26 +669,10 @@ module Chocomint
|
|
|
436
669
|
result.fetch(key) { result[key.to_s] }
|
|
437
670
|
end
|
|
438
671
|
|
|
439
|
-
# 内部ツール名を UI
|
|
440
|
-
|
|
441
|
-
"write_file" => "📝 ファイル書き込み",
|
|
442
|
-
"read_file" => "📖 ファイル読み取り",
|
|
443
|
-
"append_file" => "➕ ファイル追記",
|
|
444
|
-
"delete_file" => "🗑 ファイル削除",
|
|
445
|
-
"edit" => "✏️ ファイル編集",
|
|
446
|
-
"make_dir" => "📁 ディレクトリ作成",
|
|
447
|
-
"file_info" => "ℹ️ ファイル情報",
|
|
448
|
-
"list_dir" => "📂 ディレクトリ一覧",
|
|
449
|
-
"ls" => "📂 一覧表示",
|
|
450
|
-
"glob" => "🔍 ファイル検索",
|
|
451
|
-
"grep" => "🔍 内容検索",
|
|
452
|
-
"run_command" => "▶️ コマンド実行",
|
|
453
|
-
"bash" => "▶️ コマンド実行",
|
|
454
|
-
"shell" => "▶️ コマンド実行"
|
|
455
|
-
}.freeze
|
|
456
|
-
|
|
672
|
+
# 内部ツール名を UI 向けの表示ラベルに変換する。
|
|
673
|
+
# 絵文字は付けず、"_" を空白に、小文字を大文字にする (例: run_command → RUN COMMAND)。
|
|
457
674
|
def tool_label(tool)
|
|
458
|
-
|
|
675
|
+
tool.to_s.tr("_", " ").upcase
|
|
459
676
|
end
|
|
460
677
|
|
|
461
678
|
# 引数から「何を対象にしたか」を 1 行で要約する (例: 対象ファイル名 / 実行コマンド)。
|
data/lib/chocomint/factory.rb
CHANGED
|
@@ -99,7 +99,7 @@ module Chocomint
|
|
|
99
99
|
model: config.llm.fetch("model"),
|
|
100
100
|
api_key: api_key(config.llm),
|
|
101
101
|
max_tokens: config.llm.fetch("max_tokens", 1024),
|
|
102
|
-
timeout: config.
|
|
102
|
+
timeout: config.llm_timeout_sec,
|
|
103
103
|
payload_logger: logger,
|
|
104
104
|
role: "chat"
|
|
105
105
|
)
|
|
@@ -112,7 +112,7 @@ module Chocomint
|
|
|
112
112
|
model: config.llm.fetch("model"),
|
|
113
113
|
api_key: api_key(config.llm),
|
|
114
114
|
max_tokens: config.llm.fetch("max_tokens", 1024),
|
|
115
|
-
timeout: config.
|
|
115
|
+
timeout: config.llm_timeout_sec,
|
|
116
116
|
payload_logger: logger,
|
|
117
117
|
role: "primary"
|
|
118
118
|
)
|
|
@@ -130,7 +130,7 @@ module Chocomint
|
|
|
130
130
|
model: config.llm.fetch("model"),
|
|
131
131
|
api_key: api_key(config.llm),
|
|
132
132
|
max_tokens: config.verifier.fetch("max_tokens", 1024),
|
|
133
|
-
timeout: config.
|
|
133
|
+
timeout: config.llm_timeout_sec,
|
|
134
134
|
payload_logger: logger,
|
|
135
135
|
role: "verifier"
|
|
136
136
|
)
|
|
@@ -96,12 +96,15 @@ module Chocomint
|
|
|
96
96
|
|
|
97
97
|
# 会話としての自然文回答を返す。現在日時・OS・シェルの情報を毎回付加する
|
|
98
98
|
# (ローカルモデルが日付や実行環境を誤解しないようにするため)。
|
|
99
|
-
|
|
99
|
+
# history: これまでのやり取り [{ "role" => "user"/"assistant", "content" => String }, ...]
|
|
100
|
+
# (直近の指示より前の文脈として messages の先頭に積む)。
|
|
101
|
+
def answer(instruction, context: nil, history: nil, trace_id: nil)
|
|
100
102
|
content = context.to_s.empty? ? instruction.to_s : "#{context}\n\n#{instruction}"
|
|
101
103
|
system_prompt = "#{ANSWER_SYSTEM_PROMPT}\n#{PrimaryClient.environment_prompt}"
|
|
104
|
+
messages = Array(history) + [{ "role" => "user", "content" => content }]
|
|
102
105
|
response = @client.create_message(
|
|
103
106
|
system: system_prompt,
|
|
104
|
-
messages:
|
|
107
|
+
messages: messages,
|
|
105
108
|
trace_id: trace_id
|
|
106
109
|
)
|
|
107
110
|
text = strip_emoji(extract_text(response))
|
|
@@ -99,11 +99,13 @@ module Chocomint
|
|
|
99
99
|
# 完了していれば tool="finish" を返す (Planner がループ終了に使う)。
|
|
100
100
|
# 戻り値: { "tool" => name, "arguments" => Hash }
|
|
101
101
|
# done_steps: [{ tool:, arguments:, result: }, ...] 形式の完了済みステップ列。
|
|
102
|
-
|
|
102
|
+
# history: これまでの会話ターン [{ "role" => "user"/"assistant", "content" => String }, ...]
|
|
103
|
+
# (request より前の文脈として messages の先頭に user/assistant のまま積む)。
|
|
104
|
+
def propose_step(request, done_steps = [], trace_id: nil, history: nil)
|
|
103
105
|
response = @client.create_message(
|
|
104
106
|
system: self.class.step_system_prompt,
|
|
105
107
|
tools: @tool_definitions + [FINISH_TOOL_DEFINITION],
|
|
106
|
-
messages: build_step_messages(request, done_steps),
|
|
108
|
+
messages: Array(history) + build_step_messages(request, done_steps),
|
|
107
109
|
trace_id: trace_id
|
|
108
110
|
)
|
|
109
111
|
|
|
@@ -120,11 +122,12 @@ module Chocomint
|
|
|
120
122
|
# 戻り値: { "tool" => name, "arguments" => Hash }
|
|
121
123
|
# feedback: 初回〜前回までの全試行履歴 (再提案時に引数を変えさせるため / DESIGN §6)。
|
|
122
124
|
# [{ attempt:, proposal:, error: }, ...] 形式。旧来の単一 Hash も許容する。
|
|
123
|
-
|
|
125
|
+
# history: これまでの会話ターン (propose_step と同様。messages の先頭に積む)。
|
|
126
|
+
def propose(request, feedback: nil, trace_id: nil, history: nil)
|
|
124
127
|
response = @client.create_message(
|
|
125
128
|
system: self.class.system_prompt,
|
|
126
129
|
tools: @tool_definitions,
|
|
127
|
-
messages: build_messages(request, feedback),
|
|
130
|
+
messages: Array(history) + build_messages(request, feedback),
|
|
128
131
|
trace_id: trace_id
|
|
129
132
|
)
|
|
130
133
|
|
data/lib/chocomint/planner.rb
CHANGED
|
@@ -42,14 +42,16 @@ module Chocomint
|
|
|
42
42
|
# { type: "step_start", step: n, tool:, label:, target: } … ツール実行の直前
|
|
43
43
|
# { type: "step_done", step: n, tool:, label:, target:, status:, exit_code:, output: } … 実行後
|
|
44
44
|
# コールバック内の例外は握りつぶす (通知失敗で実行本体を止めないため)。
|
|
45
|
-
|
|
45
|
+
# history: これまでの会話ターン [{ "role" => "user"/"assistant", "content" => String }, ...]
|
|
46
|
+
# (request より前の文脈として主 LLM への messages 先頭に user/assistant のまま積む)。
|
|
47
|
+
def run(request, expectations: nil, on_event: nil, history: nil)
|
|
46
48
|
trace_id = SecureRandom.uuid
|
|
47
49
|
done_steps = []
|
|
48
50
|
total_attempts = 0
|
|
49
51
|
|
|
50
52
|
@max_steps.times do |i|
|
|
51
53
|
step_no = i + 1
|
|
52
|
-
proposal, error = propose_and_execute(request, done_steps, trace_id, step_no, on_event)
|
|
54
|
+
proposal, error = propose_and_execute(request, done_steps, trace_id, step_no, on_event, history)
|
|
53
55
|
total_attempts += 1
|
|
54
56
|
|
|
55
57
|
if proposal.nil?
|
|
@@ -96,10 +98,10 @@ module Chocomint
|
|
|
96
98
|
# 次ステップを提案させ、machine 検証を通るまで最大 step_retry 回まで引数を変えて再提案する。
|
|
97
99
|
# 成功時は proposal に実行結果を :__result で埋めて返す。
|
|
98
100
|
# 戻り値: [proposal_or_nil, error_or_nil]。
|
|
99
|
-
def propose_and_execute(request, done_steps, trace_id, step_no, on_event = nil)
|
|
101
|
+
def propose_and_execute(request, done_steps, trace_id, step_no, on_event = nil, history = nil)
|
|
100
102
|
last_error = nil
|
|
101
103
|
@step_retry.times do
|
|
102
|
-
proposal = safe_propose(request, done_steps, trace_id)
|
|
104
|
+
proposal = safe_propose(request, done_steps, trace_id, history)
|
|
103
105
|
return [nil, "proposal failed: #{@propose_error}"] if proposal.nil?
|
|
104
106
|
|
|
105
107
|
# finish はそのまま返す (実行不要)。
|
|
@@ -130,9 +132,9 @@ module Chocomint
|
|
|
130
132
|
end
|
|
131
133
|
|
|
132
134
|
# LLM 通信エラーは一時的な障害として nil を返し、呼び出し元でリトライさせる。
|
|
133
|
-
def safe_propose(request, done_steps, trace_id)
|
|
135
|
+
def safe_propose(request, done_steps, trace_id, history = nil)
|
|
134
136
|
@propose_error = nil
|
|
135
|
-
@primary.propose_step(request, done_steps, trace_id: trace_id)
|
|
137
|
+
@primary.propose_step(request, done_steps, trace_id: trace_id, history: history)
|
|
136
138
|
rescue InvalidProposalError, UnknownToolError
|
|
137
139
|
raise
|
|
138
140
|
rescue Chocomint::Error => e
|
data/lib/chocomint/server.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "errors"
|
|
|
8
8
|
require_relative "logger/sqlite_logger"
|
|
9
9
|
require_relative "edit_handlers"
|
|
10
10
|
require_relative "edit_git_handlers"
|
|
11
|
+
require_relative "edit_archive_handlers"
|
|
11
12
|
|
|
12
13
|
module Chocomint
|
|
13
14
|
# 監督完結型の Anthropic Messages API 互換プロキシサーバー。
|
|
@@ -20,17 +21,21 @@ module Chocomint
|
|
|
20
21
|
class Server
|
|
21
22
|
include EditHandlers
|
|
22
23
|
include EditGitHandlers
|
|
24
|
+
include EditArchiveHandlers
|
|
23
25
|
|
|
24
26
|
# planner: Planner インスタンス (run(request, expectations:) -> Planner::Result)。
|
|
25
27
|
# edit UI 用に PathGuard / base_dir / vendor / console 情報も受け取る (任意)。
|
|
26
28
|
# chat_client: 会話 (ツール不要の質問) を判定・応答する ChatClient (任意)。
|
|
27
29
|
# 未指定なら /edit チャットは従来どおり全指示を Planner に流す。
|
|
30
|
+
# config: Config インスタンス (任意)。渡すと /edit のモデル切り替え
|
|
31
|
+
# (POST /edit/model) で Planner / chat_client を再構築できるようになる。
|
|
28
32
|
def initialize(planner, host: "127.0.0.1", port: 9210, logger: $stderr,
|
|
29
33
|
sqlite_path: nil, path_guard: nil, base_dir: Dir.pwd,
|
|
30
34
|
max_file_bytes: 10_485_760, ws_port: nil, ws_token: nil,
|
|
31
|
-
chat_client: nil)
|
|
35
|
+
chat_client: nil, config: nil)
|
|
32
36
|
@planner = planner
|
|
33
37
|
@chat_client = chat_client
|
|
38
|
+
@config = config
|
|
34
39
|
@host = host
|
|
35
40
|
@port = port
|
|
36
41
|
@logger = logger
|
|
@@ -63,9 +68,14 @@ module Chocomint
|
|
|
63
68
|
server.mount_proc("/edit/static") { |req, res| handle_edit_static(req, res) }
|
|
64
69
|
server.mount_proc("/edit/dir") { |req, res| handle_edit_dir(req, res) }
|
|
65
70
|
server.mount_proc("/edit/file") { |req, res| handle_edit_file(req, res) }
|
|
71
|
+
server.mount_proc("/edit/raw") { |req, res| handle_edit_raw(req, res) }
|
|
72
|
+
server.mount_proc("/edit/archive") { |req, res| handle_edit_archive(req, res) }
|
|
66
73
|
server.mount_proc("/edit/save") { |req, res| handle_edit_save(req, res) }
|
|
67
74
|
server.mount_proc("/edit/chat") { |req, res| handle_edit_chat(req, res) }
|
|
68
75
|
server.mount_proc("/edit/compact") { |req, res| handle_edit_compact(req, res) }
|
|
76
|
+
# モデル選択 (ステータスバー): 現在値/候補の取得と切り替え。
|
|
77
|
+
server.mount_proc("/edit/models") { |req, res| handle_edit_models(req, res) }
|
|
78
|
+
server.mount_proc("/edit/model") { |req, res| handle_edit_model(req, res) }
|
|
69
79
|
# ファイル操作 (右クリックメニュー): 削除 / 名前変更 / 追加。
|
|
70
80
|
server.mount_proc("/edit/fs/delete") { |req, res| handle_edit_fs_delete(req, res) }
|
|
71
81
|
server.mount_proc("/edit/fs/rename") { |req, res| handle_edit_fs_rename(req, res) }
|
|
@@ -266,7 +276,13 @@ module Chocomint
|
|
|
266
276
|
|
|
267
277
|
# 一覧の表列。id は詳細リンク、trace_id はフィルタリンクにする。
|
|
268
278
|
# content はレスポンスの返答要約 (text 冒頭 / tool_use:名前)。
|
|
269
|
-
|
|
279
|
+
# in/cache/out はトークン数 (入力=キャッシュ除く / 入力キャッシュ / 出力)。
|
|
280
|
+
LLM_COLUMNS = %w[no trace_id role model response_status
|
|
281
|
+
in cache out content created_at].freeze
|
|
282
|
+
# 一覧のトークン列は短い見出しだと意味が伝わりにくいので日本語ラベルを添える。
|
|
283
|
+
LLM_COLUMN_LABELS = {
|
|
284
|
+
"in" => "入力", "cache" => "入力キャッシュ", "out" => "出力"
|
|
285
|
+
}.freeze
|
|
270
286
|
|
|
271
287
|
def llm_logs_html(rows, total:, page:, trace_id:)
|
|
272
288
|
base = trace_id ? "/logs/llm?trace_id=#{CGI.escape(trace_id)}&" : "/logs/llm?"
|
|
@@ -299,6 +315,7 @@ module Chocomint
|
|
|
299
315
|
th { background: #f5f5f5; position: sticky; top: 0; }
|
|
300
316
|
td.no a, td.trace a { color: #1155cc; text-decoration: none; }
|
|
301
317
|
td.no a:hover, td.trace a:hover { text-decoration: underline; }
|
|
318
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; color: #444; }
|
|
302
319
|
td.content { white-space: normal; max-width: 28rem; color: #444; }
|
|
303
320
|
tr:nth-child(even) { background: #fafafa; }
|
|
304
321
|
.role-primary { color: #1155cc; font-weight: 600; }
|
|
@@ -315,7 +332,7 @@ module Chocomint
|
|
|
315
332
|
<div class="table-wrap">
|
|
316
333
|
<table>
|
|
317
334
|
<thead>
|
|
318
|
-
<tr>#{LLM_COLUMNS.map { |c| "<th>#{CGI.escapeHTML(c)}</th>" }.join}</tr>
|
|
335
|
+
<tr>#{LLM_COLUMNS.map { |c| "<th>#{CGI.escapeHTML(LLM_COLUMN_LABELS[c] || c)}</th>" }.join}</tr>
|
|
319
336
|
</thead>
|
|
320
337
|
<tbody>
|
|
321
338
|
#{rows.empty? ? %(<tr><td colspan="#{LLM_COLUMNS.size}" class="empty">ペイロードはありません</td></tr>) : body_rows}
|
|
@@ -336,6 +353,7 @@ module Chocomint
|
|
|
336
353
|
trace_cell = tid.empty? ? "(なし)" : %(<a href="/logs/llm?trace_id=#{CGI.escape(tid)}">#{CGI.escapeHTML(tid)}</a>)
|
|
337
354
|
status = row["response_status"].nil? ? "-" : row["response_status"].to_s
|
|
338
355
|
content = content_summary(row["response_body"])
|
|
356
|
+
usage = usage_tokens(row["response_body"])
|
|
339
357
|
<<~ROW
|
|
340
358
|
<tr>
|
|
341
359
|
<td class="no">#{no_link}</td>
|
|
@@ -343,18 +361,49 @@ module Chocomint
|
|
|
343
361
|
<td class="role-#{CGI.escapeHTML(role)}">#{CGI.escapeHTML(role)}</td>
|
|
344
362
|
<td>#{CGI.escapeHTML(row['model'].to_s)}</td>
|
|
345
363
|
<td>#{CGI.escapeHTML(status)}</td>
|
|
364
|
+
<td class="num">#{CGI.escapeHTML(token_cell(usage[:input]))}</td>
|
|
365
|
+
<td class="num">#{CGI.escapeHTML(token_cell(usage[:cache]))}</td>
|
|
366
|
+
<td class="num">#{CGI.escapeHTML(token_cell(usage[:output]))}</td>
|
|
346
367
|
<td class="content">#{CGI.escapeHTML(content)}</td>
|
|
347
368
|
<td>#{CGI.escapeHTML(row['created_at'].to_s)}</td>
|
|
348
369
|
</tr>
|
|
349
370
|
ROW
|
|
350
371
|
end
|
|
351
372
|
|
|
373
|
+
# response_body(JSON) の usage からトークン数を取り出す。
|
|
374
|
+
# :input … 入力トークン (入力キャッシュを除く。Anthropic 系の input_tokens)
|
|
375
|
+
# :cache … 入力キャッシュ (cache_read + cache_creation)
|
|
376
|
+
# :output … 出力トークン
|
|
377
|
+
# usage が無い/パース不能なら各値 nil (表示は "-")。
|
|
378
|
+
def usage_tokens(response_body)
|
|
379
|
+
return { input: nil, cache: nil, output: nil } if response_body.nil?
|
|
380
|
+
|
|
381
|
+
parsed = begin
|
|
382
|
+
JSON.parse(response_body)
|
|
383
|
+
rescue JSON::ParserError
|
|
384
|
+
nil
|
|
385
|
+
end
|
|
386
|
+
usage = parsed.is_a?(Hash) ? parsed["usage"] : nil
|
|
387
|
+
return { input: nil, cache: nil, output: nil } unless usage.is_a?(Hash)
|
|
388
|
+
|
|
389
|
+
cache = usage["cache_read_input_tokens"].to_i + usage["cache_creation_input_tokens"].to_i
|
|
390
|
+
{ input: usage["input_tokens"], cache: cache, output: usage["output_tokens"] }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# トークン数セルの表示。nil は "-"、数値は 3 桁区切りにする。
|
|
394
|
+
def token_cell(value)
|
|
395
|
+
return "-" if value.nil?
|
|
396
|
+
|
|
397
|
+
value.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
398
|
+
end
|
|
399
|
+
|
|
352
400
|
# ペイロード詳細ページ (リクエスト + レスポンス + ツール正常性判定)。
|
|
353
401
|
def llm_detail_html(row, outcomes = [])
|
|
354
402
|
role = row["role"].to_s
|
|
355
403
|
tid = row["trace_id"].to_s
|
|
356
404
|
trace_link = tid.empty? ? "(なし)" : %(<a href="/logs/llm?trace_id=#{CGI.escape(tid)}">#{CGI.escapeHTML(tid)}</a>)
|
|
357
405
|
status = row["response_status"].nil? ? "(未記録)" : row["response_status"].to_s
|
|
406
|
+
usage = usage_tokens(row["response_body"])
|
|
358
407
|
<<~HTML
|
|
359
408
|
<!DOCTYPE html>
|
|
360
409
|
<html lang="ja">
|
|
@@ -403,6 +452,7 @@ module Chocomint
|
|
|
403
452
|
<span>model: #{CGI.escapeHTML(row['model'].to_s)}</span>
|
|
404
453
|
<span>trace: #{trace_link}</span>
|
|
405
454
|
<span>response_status: #{CGI.escapeHTML(status)}</span>
|
|
455
|
+
<span>tokens: 入力 #{CGI.escapeHTML(token_cell(usage[:input]))} / 入力キャッシュ #{CGI.escapeHTML(token_cell(usage[:cache]))} / 出力 #{CGI.escapeHTML(token_cell(usage[:output]))}</span>
|
|
406
456
|
<span>#{CGI.escapeHTML(row['created_at'].to_s)}</span>
|
|
407
457
|
</div>
|
|
408
458
|
<div class="lbl">request messages(LLM への送信メッセージ)</div>
|