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.
@@ -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
- File.write(abs, content)
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
- # サーバー側は会話履歴を保持しないため、これは「表示ログを短い要約 1 件に畳む」
276
- # ための機能 (AI の文脈には元々影響しない)。chat_client 未注入なら 503。
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
- TOOL_LABELS = {
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
- TOOL_LABELS.fetch(tool.to_s, "🔧 #{tool}")
675
+ tool.to_s.tr("_", " ").upcase
459
676
  end
460
677
 
461
678
  # 引数から「何を対象にしたか」を 1 行で要約する (例: 対象ファイル名 / 実行コマンド)。
@@ -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.timeout_sec,
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.timeout_sec,
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.timeout_sec,
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
- def answer(instruction, context: nil, trace_id: nil)
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: [{ "role" => "user", "content" => content }],
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
- def propose_step(request, done_steps = [], trace_id: nil)
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
- def propose(request, feedback: nil, trace_id: nil)
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
 
@@ -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
- def run(request, expectations: nil, on_event: nil)
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
@@ -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
- LLM_COLUMNS = %w[no trace_id role model response_status content created_at].freeze
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>