Module: Chocomint::EditHandlers

Included in:
Server
Defined in:
lib/chocomint/edit_handlers.rb,
lib/chocomint/edit_html.rb

Overview

/edit AI エディタの HTTP ハンドラ群 (Server に include して使う)。

提供機能:

GET  /edit            エディタ画面 (メニュー/ファイル一覧/Monaco/AIチャット/コンソール)
GET  /edit/assets/*   Monaco / xterm.js のローカル同梱アセット配信
GET  /edit/static/*   自前の CSS/JS 配信 (public/edit/)
GET  /edit/files      workspace のファイル一覧 (JSON)
GET  /edit/file?path= ファイル内容 (JSON)
POST /edit/save       ファイル保存 (PathGuard で workspace 内に限定)
POST /edit/chat       自然言語指示で AI 編集 (Planner が提案→実行→検証→再試行)

ファイルアクセスはすべて @path_guard 経由で許可ディレクトリ内に制限する。

Constant Summary collapse

EDIT_HTML_TEMPLATE =

/edit のシングルページ HTML。実体は public/edit/index.html (CSS/JS も同ディレクトリ) で、 ここでは読み込むだけ。__WS_CONFIG__ はサーバー側で WebSocket 接続情報 (JSON) に置換される。

File.read(File.expand_path("../../public/edit/index.html", __dir__))
EXCLUDED_TREE_DIRS =

中身を展開しない VCS メタデータ・依存パッケージ等 (一覧を埋め尽くすため除外する)。 ディレクトリ名 (basename) で判定するため、"vendor/bundle" のようなパス階層は含めない。

%w[.git .hg .svn .venv venv node_modules .bundle].freeze
MAX_DIR_ENTRIES =

1ディレクトリあたりの列挙上限。通常は到達しないが、数万エントリを持つ異常な ディレクトリでブラウザを固めないための安全弁。到達時は capped=true を返す。

5000
BINARY_SNIFF_BYTES =

テキストとして扱えないか判定する。NUL バイトを含む、または先頭ブロックが 妥当な UTF-8 でないものはバイナリ (エディタで開けない) とみなす。

8192
COMPACT_INPUT_LIMIT =

要約に渡すログの上限 (これを超える古い部分は末尾を優先して切り詰める)。

12_000
CHAT_CONTEXT_LIMIT =

会話の文脈として渡すファイル内容 (先頭のみ)。開いていない/読めない場合は nil。

4000
ANSI_ESCAPE =

ANSI エスケープシーケンス (色・装飾。CSI \e[...m 等) を取り除く。 端末を経由しない chocomint の出力表示では色コードは無意味で、文字化けに見えるだけ。

/\e\[[0-9;?]*[ -\/]*[@-~]/
TOOL_LABELS =

内部ツール名を UI 向けの日本語ラベル (アイコン付き) に変換する。未知の名前は原文。

{
  "write_file" => "📝 ファイル書き込み",
  "read_file" => "📖 ファイル読み取り",
  "append_file" => "➕ ファイル追記",
  "delete_file" => "🗑 ファイル削除",
  "edit" => "✏️ ファイル編集",
  "make_dir" => "📁 ディレクトリ作成",
  "file_info" => "ℹ️ ファイル情報",
  "list_dir" => "📂 ディレクトリ一覧",
  "ls" => "📂 一覧表示",
  "glob" => "🔍 ファイル検索",
  "grep" => "🔍 内容検索",
  "run_command" => "▶️ コマンド実行",
  "bash" => "▶️ コマンド実行",
  "shell" => "▶️ コマンド実行"
}.freeze
TARGET_SUMMARY_LIMIT =

引数から「何を対象にしたか」を 1 行で要約する (例: 対象ファイル名 / 実行コマンド)。

120
ASSET_CONTENT_TYPES =

---- アセット配信 -------------------------------------------------------

{
  ".js" => "application/javascript; charset=utf-8",
  ".css" => "text/css; charset=utf-8",
  ".ttf" => "font/ttf",
  ".map" => "application/json",
  ".svg" => "image/svg+xml",
  ".html" => "text/html; charset=utf-8"
}.freeze

Instance Method Summary collapse

Instance Method Details

#binary_content?(raw) ⇒ Boolean

Returns:

  • (Boolean)


134
135
136
137
138
139
140
141
# File 'lib/chocomint/edit_handlers.rb', line 134

def binary_content?(raw)
  return false if raw.empty?

  head = raw.byteslice(0, BINARY_SNIFF_BYTES)
  return true if head.include?("\x00")

  !head.dup.force_encoding("UTF-8").valid_encoding?
end

#build_chat_request(path, instruction) ⇒ Object

対象ファイルがあれば文脈として明示し、無ければ指示だけを渡す。



339
340
341
342
343
344
345
# File 'lib/chocomint/edit_handlers.rb', line 339

def build_chat_request(path, instruction)
  if path.empty?
    instruction
  else
    "ファイル #{path} に対して次の指示を反映してください: #{instruction}"
  end
end

#chat_file_context(path) ⇒ Object



325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/chocomint/edit_handlers.rb', line 325

def chat_file_context(path)
  return nil if path.empty?

  abs = @path_guard.resolve(path)
  return nil unless File.file?(abs)

  content = File.read(abs, mode: "rb").force_encoding("UTF-8")
  content = "#{content[0, CHAT_CONTEXT_LIMIT]}" if content.length > CHAT_CONTEXT_LIMIT
  "現在開いているファイル #{path} の内容:\n```\n#{content}\n```"
rescue Chocomint::Error
  nil
end

#chat_instruction?(instruction) ⇒ Boolean

chat_client があり、指示が「会話」と分類されたときだけ会話として扱う。 chat_client 未注入なら常に false (従来どおり全て Planner に流す)。

Returns:

  • (Boolean)


304
305
306
307
308
309
310
311
# File 'lib/chocomint/edit_handlers.rb', line 304

def chat_instruction?(instruction)
  return false unless @chat_client

  @chat_client.classify(instruction) == "chat"
rescue Chocomint::Error
  # 分類に失敗したら安全側でタスク扱い (Planner に流す)。
  false
end

#console_ws_configObject

WebSocket コンソールの接続先。ws_port 未設定ならコンソール無効。



686
687
688
689
690
# File 'lib/chocomint/edit_handlers.rb', line 686

def console_ws_config
  return nil unless @ws_port

  { "port" => @ws_port, "token" => @ws_token }
end

#deep_scrub(value) ⇒ Object

ハッシュ/配列/文字列を再帰的にたどり、文字列を妥当な UTF-8 に正規化する。 UTF-8 以外のエンコーディング (ASCII-8BIT な外部プロセス出力等) も UTF-8 とみなして scrub し、変換不能なバイトは置換文字にする。ハッシュのキーも同様に正規化する。



665
666
667
668
669
670
671
672
673
674
675
676
677
# File 'lib/chocomint/edit_handlers.rb', line 665

def deep_scrub(value)
  case value
  when String
    s = value.encoding == Encoding::UTF_8 ? value : value.dup.force_encoding("UTF-8")
    s.valid_encoding? ? s : s.scrub("")
  when Hash
    value.each_with_object({}) { |(k, v), h| h[deep_scrub(k)] = deep_scrub(v) }
  when Array
    value.map { |v| deep_scrub(v) }
  else
    value
  end
end

#edit_chat_reply(instruction, path) ⇒ Object

会話 (普通の質問) への直接応答を UI 向け JSON に整形する。 開いているファイルがあれば内容を文脈として渡す。



315
316
317
318
319
320
# File 'lib/chocomint/edit_handlers.rb', line 315

def edit_chat_reply(instruction, path)
  answer = @chat_client.answer(instruction, context: chat_file_context(path))
  { "status" => "PASS", "reply" => answer, "steps" => [] }
rescue Chocomint::Error => e
  { "status" => "FAIL", "error" => e.message, "steps" => [] }
end

#edit_chat_result(result, path, instruction = nil) ⇒ Object

Planner::Result を UI 向けの JSON に整形する。編集後内容も返して Monaco を更新する。 instruction: 要約生成のための元の要求文 (任意)。



349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'lib/chocomint/edit_handlers.rb', line 349

def edit_chat_result(result, path, instruction = nil)
  steps = result.steps.map do |s|
    r = s[:result]
    { "tool" => s[:tool],
      # UI で読みやすい日本語ラベル (例: "コマンド実行")。
      "label" => tool_label(s[:tool]),
      "arguments" => s[:arguments],
      # 引数の 1 行要約 (例: どのファイル / どのコマンドを対象にしたか)。
      "target" => tool_target_summary(s[:tool], s[:arguments]),
      "status" => s[:status],
      # ツールの終了コード (0 = 成功)。シンボル/文字列どちらのキーでも拾う。
      "exit_code" => result_field(r, :exit_code),
      # ツールの実行結果 (stdout / 生成物など) を人間可読テキストにして返す。
      "output" => tool_output_text(r) }
  end
  payload = {
    "status" => result.status,
    "trace_id" => result.trace_id,
    "attempts" => result.attempts,
    "steps" => steps
  }
  # 要約 (summary) と注記 (note) の振り分け。
  #  - ステップ無し + message: 聞き返し・会話的回答。message をそのまま要約枠に出す
  #    (実行内容が無いので summarize しない)。
  #  - ステップ有り + incomplete: 未完了の注記 (message) は黄色の注記枠へ。要約は別途生成する。
  #  - ステップ有り + 通常の finish message: それを要約として使う (summarize の代わり)。
  #  - それ以外のステップ有り: AI に実行内容を要約させる。
  if !result.message.to_s.empty? && steps.empty?
    payload["summary"] = result.message
  elsif result.incomplete
    payload["note"] = result.message unless result.message.to_s.empty?
    if @chat_client && !steps.empty? && instruction
      summary = @chat_client.summarize(instruction, steps, trace_id: result.trace_id)
      payload["summary"] = summary if summary
    end
  elsif !result.message.to_s.empty? && !steps.empty?
    payload["summary"] = result.message
  elsif @chat_client && result.status == "PASS" && !steps.empty? && instruction
    summary = @chat_client.summarize(instruction, steps, trace_id: result.trace_id)
    payload["summary"] = summary if summary
  end
  # 対象ファイルがまだ存在すれば最新内容を返す (AI が編集した結果を反映)。
  unless path.empty?
    begin
      abs = @path_guard.resolve(path)
      payload["content"] = File.read(abs) if File.file?(abs)
    rescue Chocomint::Error
      # 解決できない・消えた等は content 無しで返す。
    end
  end
  payload
end

#edit_htmlObject



692
693
694
695
696
# File 'lib/chocomint/edit_handlers.rb', line 692

def edit_html
  ws = console_ws_config
  ws_json = ws ? JSON.generate(ws) : "null"
  EDIT_HTML_TEMPLATE.sub("__WS_CONFIG__", ws_json)
end

#edit_json(res, status, hash) ⇒ Object



652
653
654
655
656
657
658
659
660
# File 'lib/chocomint/edit_handlers.rb', line 652

def edit_json(res, status, hash)
  res.status = status
  res["content-type"] = "application/json"
  # ツール出力 (外部プロセスの stdout など) は妥当な UTF-8 とは限らない。
  # 不正バイトが混じると JSON.generate が例外を投げ、WEBrick が HTML の 500 を
  # 返してしまう (フロントは JSON を期待して "Unexpected token '<'" になる)。
  # 生成前に全文字列を UTF-8 として scrub し、無効バイトを U+FFFD に置換して守る。
  res.body = JSON.generate(deep_scrub(hash))
end

#edit_method_guard(res) ⇒ Object



679
680
681
682
683
# File 'lib/chocomint/edit_handlers.rb', line 679

def edit_method_guard(res)
  res.status = 405
  res["content-type"] = "application/json"
  res.body = JSON.generate("error" => "method not allowed")
end

#edit_rootObject

workspace ルート (allowed_roots の先頭)。表示・列挙の起点にする。



22
23
24
# File 'lib/chocomint/edit_handlers.rb', line 22

def edit_root
  @path_guard.allowed_roots.first
end

#fs_body_path(req) ⇒ Object

JSON ボディから正規化済みの "path" を取り出す (空なら 400 相当の例外)。

Raises:



555
556
557
558
559
560
561
# File 'lib/chocomint/edit_handlers.rb', line 555

def fs_body_path(req)
  body = JSON.parse(req.body.to_s)
  path = normalize_fs_path(body["path"])
  raise Chocomint::Error, "path required" if path.empty?

  path
end

#handle_edit(req, res) ⇒ Object

---- エディタ画面 -------------------------------------------------------



634
635
636
637
638
639
640
641
642
643
# File 'lib/chocomint/edit_handlers.rb', line 634

def handle_edit(req, res)
  return edit_method_guard(res) unless req.request_method == "GET"

  res.status = 200
  res["content-type"] = "text/html; charset=utf-8"
  # HTML は常に最新を返す (ブラウザのヒューリスティックキャッシュで古い版が
  # 残ると CSS/JS の参照や DOM 構造がずれるため)。
  res["cache-control"] = "no-cache"
  res.body = edit_html
end

#handle_edit_asset(req, res) ⇒ Object



592
593
594
595
596
597
598
599
# File 'lib/chocomint/edit_handlers.rb', line 592

def handle_edit_asset(req, res)
  return edit_method_guard(res) unless req.request_method == "GET"

  # /edit/assets/<rel> の <rel> を取り出し、vendor 配下限定で配信する (Monaco / xterm.js)。
  # vendor は不変の大容量アセットなので長期キャッシュしてよい。
  serve_edit_static(req, res, prefix: "/edit/assets/", root: @vendor_dir,
                              cache: "public, max-age=86400")
end

#handle_edit_chat(req, res) ⇒ Object

---- AI 編集 (チャット) -------------------------------------------------



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/chocomint/edit_handlers.rb', line 170

def handle_edit_chat(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  body = JSON.parse(req.body.to_s)
  instruction = body["instruction"].to_s.strip
  path = body["path"].to_s
  return edit_json(res, 400, "error" => "instruction required") if instruction.empty?

  # stream=true なら SSE でツール実行の進捗を逐次配信する (UI のリアルタイム表示用)。
  return handle_edit_chat_stream(res, instruction, path) if body["stream"]

  # ツール不要の普通の質問なら会話として直接応答する。それ以外は Planner に流す。
  if chat_instruction?(instruction)
    return edit_json(res, 200, edit_chat_reply(instruction, path))
  end

  request = build_chat_request(path, instruction)
  result = @planner.run(request, expectations: instruction)
  edit_json(res, 200, edit_chat_result(result, path, instruction))
rescue JSON::ParserError => e
  edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
rescue Chocomint::InvalidProposalError, Chocomint::UnknownToolError => e
  edit_json(res, 422, "status" => "FAIL", "error" => humanize_chat_error(e))
rescue Chocomint::RetryLimitExceededError => e
  edit_json(res, 200, "status" => "FAIL", "error" => humanize_chat_error(e), "steps" => [])
rescue Chocomint::Error => e
  edit_json(res, 500, "error" => e.message)
end

#handle_edit_chat_stream(res, instruction, path) ⇒ Object

SSE 版のチャット処理。Planner のステップ進捗を逐次 event として流し、 最後に従来の JSON 結果を "result" event として送って締める。 イベント: tool_start / tool_done / result / error (data は JSON 1 行)。



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/chocomint/edit_handlers.rb', line 202

def handle_edit_chat_stream(res, instruction, path)
  res.status = 200
  res["content-type"] = "text/event-stream; charset=utf-8"
  res["cache-control"] = "no-cache"
  res.chunked = true
  res.body = lambda do |out|
    emit_sse = ->(event, data) { out.write("event: #{event}\ndata: #{JSON.generate(deep_scrub(data))}\n\n") }
    begin
      run_chat_stream(instruction, path, emit_sse)
    rescue Chocomint::InvalidProposalError, Chocomint::UnknownToolError,
           Chocomint::RetryLimitExceededError => e
      emit_sse.call("result", "status" => "FAIL", "error" => humanize_chat_error(e), "steps" => [])
    rescue Chocomint::Error => e
      emit_sse.call("error", "error" => e.message)
    end
  end
end

#handle_edit_compact(req, res) ⇒ Object



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/chocomint/edit_handlers.rb', line 281

def handle_edit_compact(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"
  return edit_json(res, 503, "error" => "chat client not configured") unless @chat_client

  body = JSON.parse(req.body.to_s)
  transcript = body["transcript"].to_s.strip
  return edit_json(res, 400, "error" => "transcript required") if transcript.empty?

  # 長すぎる場合は新しい方 (末尾) を優先して残す。
  if transcript.length > COMPACT_INPUT_LIMIT
    transcript = "(以前のやり取りは省略)\n#{transcript[-COMPACT_INPUT_LIMIT..]}"
  end

  summary = @chat_client.compact(transcript)
  edit_json(res, 200, "summary" => summary)
rescue JSON::ParserError => e
  edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
rescue Chocomint::Error => e
  edit_json(res, 500, "error" => e.message)
end

#handle_edit_dir(req, res) ⇒ Object

WORKING DIRECTORY を遅延展開するためのエンドポイント。指定ディレクトリの 「直下 1 階層だけ」を列挙して返す。ツリー全体を一括列挙すると巨大ホーム (例: C:Users<name>) で応答が返らず表示が壊れるため、階層ごとに取得する。

GET /edit/dir           → ルート (workspace 直下) を列挙
GET /edit/dir?path=src  → src/ の直下を列挙

path・返す entries.path はいずれも base_dir 基準の相対パス (relative_root を含む)。 /edit/file や /edit/fs/* と同じ表現なので、フロントは data-path をそのまま渡せる。



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/chocomint/edit_handlers.rb', line 45

def handle_edit_dir(req, res)
  return edit_method_guard(res) unless req.request_method == "GET"

  rel = req.query["path"].to_s.tr("\\", "/")
  rel = "" if rel == "." || rel == relative_root
  abs = rel.empty? ? edit_root : @path_guard.resolve(rel)
  return edit_json(res, 404, "error" => "no such directory") unless File.directory?(abs)

  entries, capped = list_dir_entries(abs, rel)
  edit_json(res, 200, "path" => rel, "entries" => entries, "capped" => capped,
                      "root" => relative_root, "abs_root" => edit_root.tr("\\", "/"))
rescue Chocomint::PathAccessError => e
  edit_json(res, 403, "error" => e.message)
rescue SystemCallError => e
  # 権限不足などで開けないディレクトリ。空扱いにしてツリーを壊さない。
  edit_json(res, 403, "error" => e.message)
rescue Chocomint::Error => e
  edit_json(res, 500, "error" => e.message)
end

#handle_edit_file(req, res) ⇒ Object

---- ファイル取得 -------------------------------------------------------



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/chocomint/edit_handlers.rb', line 105

def handle_edit_file(req, res)
  return edit_method_guard(res) unless req.request_method == "GET"

  path = req.query["path"].to_s
  return edit_json(res, 400, "error" => "path required") if path.empty?

  abs = @path_guard.resolve(path)
  return edit_json(res, 404, "error" => "no such file") unless File.file?(abs)
  if File.size(abs) > @max_file_bytes
    return edit_json(res, 413, "error" => "file too large")
  end

  raw = File.read(abs, mode: "rb")
  # テキストとして開けない (バイナリ / 不正な UTF-8) ファイルは中身を返さずフラグで知らせる。
  if binary_content?(raw)
    return edit_json(res, 200, "path" => path, "binary" => true)
  end

  edit_json(res, 200, "path" => path, "content" => raw.force_encoding("UTF-8"))
rescue Chocomint::PathAccessError => e
  edit_json(res, 403, "error" => e.message)
rescue Chocomint::Error => e
  edit_json(res, 500, "error" => e.message)
end

#handle_edit_fs_delete(req, res) ⇒ Object

ファイル or ディレクトリを削除する。



488
489
490
491
492
493
494
495
496
497
498
499
500
# File 'lib/chocomint/edit_handlers.rb', line 488

def handle_edit_fs_delete(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_fs(res) do
    path = fs_body_path(req)
    abs = @path_guard.resolve(path)
    raise Chocomint::Error, "workspace ルートは削除できません" if abs == edit_root
    raise Chocomint::Error, "no such path" unless File.exist?(abs)

    FileUtils.rm_rf(abs)
    edit_json(res, 200, "path" => path, "deleted" => true)
  end
end

#handle_edit_fs_mkdir(req, res) ⇒ Object

ディレクトリを新規作成する (親が無ければ一緒に作る)。



524
525
526
527
528
529
530
531
532
533
534
535
# File 'lib/chocomint/edit_handlers.rb', line 524

def handle_edit_fs_mkdir(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_fs(res) do
    path = fs_body_path(req)
    abs = @path_guard.resolve(path)
    raise Chocomint::Error, "既に存在します" if File.exist?(abs)

    FileUtils.mkdir_p(abs)
    edit_json(res, 200, "path" => path, "created" => true)
  end
end

#handle_edit_fs_rename(req, res) ⇒ Object

ファイル or ディレクトリの名前変更 (移動)。to は同一 workspace 内に限る。



503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
# File 'lib/chocomint/edit_handlers.rb', line 503

def handle_edit_fs_rename(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_fs(res) do
    body = JSON.parse(req.body.to_s)
    from = normalize_fs_path(body["from"])
    to = normalize_fs_path(body["to"])
    raise Chocomint::Error, "from/to required" if from.empty? || to.empty?

    src = @path_guard.resolve(from)
    dst = @path_guard.resolve(to)
    raise Chocomint::Error, "no such path" unless File.exist?(src)
    raise Chocomint::Error, "移動先が既に存在します" if File.exist?(dst)

    FileUtils.mkdir_p(File.dirname(dst))
    FileUtils.mv(src, dst)
    edit_json(res, 200, "from" => from, "to" => to)
  end
end

#handle_edit_fs_touch(req, res) ⇒ Object

空ファイルを新規作成する (親ディレクトリが無ければ作る)。



538
539
540
541
542
543
544
545
546
547
548
549
550
# File 'lib/chocomint/edit_handlers.rb', line 538

def handle_edit_fs_touch(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  with_fs(res) do
    path = fs_body_path(req)
    abs = @path_guard.resolve(path)
    raise Chocomint::Error, "既に存在します" if File.exist?(abs)

    FileUtils.mkdir_p(File.dirname(abs))
    File.write(abs, "")
    edit_json(res, 200, "path" => path, "created" => true)
  end
end

#handle_edit_save(req, res) ⇒ Object

---- 保存 (人手編集) ----------------------------------------------------



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/chocomint/edit_handlers.rb', line 145

def handle_edit_save(req, res)
  return edit_method_guard(res) unless req.request_method == "POST"

  body = JSON.parse(req.body.to_s)
  path = body["path"].to_s
  content = body["content"].to_s
  return edit_json(res, 400, "error" => "path required") if path.empty?
  if content.bytesize > @max_file_bytes
    return edit_json(res, 413, "error" => "content exceeds max_file_bytes")
  end

  abs = @path_guard.resolve(path)
  FileUtils.mkdir_p(File.dirname(abs))
  File.write(abs, content)
  edit_json(res, 200, "path" => path, "bytes" => content.bytesize)
rescue JSON::ParserError => e
  edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
rescue Chocomint::PathAccessError => e
  edit_json(res, 403, "error" => e.message)
rescue Chocomint::Error => e
  edit_json(res, 500, "error" => e.message)
end

#handle_edit_static(req, res) ⇒ Object



601
602
603
604
605
606
607
608
# File 'lib/chocomint/edit_handlers.rb', line 601

def handle_edit_static(req, res)
  return edit_method_guard(res) unless req.request_method == "GET"

  # /edit/static/<rel> の <rel> を取り出し、public/edit 配下限定で配信する (自前の CSS/JS)。
  # 自前の CSS/JS は開発中に頻繁に編集するため、キャッシュせず毎回最新を配信する。
  serve_edit_static(req, res, prefix: "/edit/static/", root: @edit_static_dir,
                              cache: "no-cache")
end

#humanize_chat_error(err) ⇒ Object

Planner の例外メッセージ (英語) を UI 向けの日本語に言い換える。 原文は握りつぶさず末尾に括弧書きで残す (デバッグの手がかりのため)。



259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/chocomint/edit_handlers.rb', line 259

def humanize_chat_error(err)
  case err
  when Chocomint::RetryLimitExceededError
    "指示を達成できませんでした。指示をより具体的にするか、手順を分けて試してください。"
  when Chocomint::InvalidProposalError
    "AI が実行するツールをうまく選べませんでした。指示を言い換えて試してください。"
  when Chocomint::UnknownToolError
    "AI が存在しないツールを呼び出そうとしました。指示を言い換えて試してください。"
  else
    "エラーが発生しました。"
  end + " (#{err.message})"
end

#list_dir_entries(abs_dir, rel_dir) ⇒ Object

abs_dir の直下 1 階層を列挙し、[entries, capped] を返す。 entries は { "name", "path", "dir" } の配列 (ディレクトリ→ファイルの順、各名前順)。 rel_dir は abs_dir の base_dir 基準の相対パス ("" ならルート = edit_root)。



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/chocomint/edit_handlers.rb', line 68

def list_dir_entries(abs_dir, rel_dir)
  dirs = []
  files = []
  capped = false

  # ルート (rel_dir 空) の直下は base_dir 基準にするため relative_root を前置する
  # (本番は "." のためプレフィックスなし、テスト等 edit_root != base_dir では "workspace" 等)。
  prefix = rel_dir.empty? ? (relative_root == "." ? "" : relative_root) : rel_dir

  Dir.each_child(abs_dir) do |name|
    next if name == "." || name == ".."

    rel = prefix.empty? ? name : "#{prefix}/#{name}"
    abs = File.join(abs_dir, name)

    if File.directory?(abs)
      # 除外ディレクトリは一覧に出さない (中身も見せない)。
      next if EXCLUDED_TREE_DIRS.include?(name)

      dirs << { "name" => name, "path" => rel, "dir" => true }
    elsif File.file?(abs)
      files << { "name" => name, "path" => rel, "dir" => false }
    end

    if dirs.size + files.size >= MAX_DIR_ENTRIES
      capped = true
      break
    end
  end

  dirs.sort_by! { |e| e["name"] }
  files.sort_by! { |e| e["name"] }
  [dirs + files, capped]
end

#normalize_fs_path(value) ⇒ Object

バックスラッシュを "/" に統一し前後空白を除く。



564
565
566
# File 'lib/chocomint/edit_handlers.rb', line 564

def normalize_fs_path(value)
  value.to_s.tr("\\", "/").strip
end

#relative_rootObject

---- 補助 ---------------------------------------------------------------



647
648
649
650
# File 'lib/chocomint/edit_handlers.rb', line 647

def relative_root
  rel = edit_root.sub(/\A#{Regexp.escape(@base_dir)}[\\\/]?/, "")
  rel.empty? ? "." : rel.tr("\\", "/")
end

#result_field(result, key) ⇒ Object

結果ハッシュからキー key の値を取り出す (シンボル/文字列どちらのキーでも拾う)。



433
434
435
436
437
# File 'lib/chocomint/edit_handlers.rb', line 433

def result_field(result, key)
  return nil unless result.is_a?(Hash)

  result.fetch(key) { result[key.to_s] }
end

#run_chat_stream(instruction, path, emit_sse) ⇒ Object

SSE 本体: 会話 or Planner 実行を行い、進捗と最終結果を emit_sse で送る。



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/chocomint/edit_handlers.rb', line 221

def run_chat_stream(instruction, path, emit_sse)
  # 会話 (ツール不要) は進捗が無いので、そのまま最終結果だけ送る。
  if chat_instruction?(instruction)
    emit_sse.call("result", edit_chat_reply(instruction, path))
    return
  end

  request = build_chat_request(path, instruction)
  on_event = lambda do |ev|
    case ev[:type]
    when "step_start"
      emit_sse.call("tool_start",
                    "step" => ev[:step],
                    "tool" => ev[:tool],
                    "label" => tool_label(ev[:tool]),
                    "target" => tool_target_summary(ev[:tool], ev[:arguments]))
    when "step_done"
      ok = ev[:status] == "PASS" && (result_field(ev[:result], :exit_code).nil? ||
                                     result_field(ev[:result], :exit_code) == 0)
      emit_sse.call("tool_done",
                    "step" => ev[:step],
                    "tool" => ev[:tool],
                    "label" => tool_label(ev[:tool]),
                    "target" => tool_target_summary(ev[:tool], ev[:arguments]),
                    "status" => ev[:status],
                    "ok" => ok,
                    "exit_code" => result_field(ev[:result], :exit_code),
                    # 実行結果 (stdout/stderr など) を人が読める形にして都度表示させる。
                    "output" => tool_output_text(ev[:result]))
    end
  end

  result = @planner.run(request, expectations: instruction, on_event: on_event)
  emit_sse.call("result", edit_chat_result(result, path, instruction))
end

#serve_edit_static(req, res, prefix:, root:, cache:) ⇒ Object

prefix を取り除いた相対パスを root 配下限定で解決して配信する共通ロジック。



611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
# File 'lib/chocomint/edit_handlers.rb', line 611

def serve_edit_static(req, res, prefix:, root:, cache:)
  rel = req.path.sub(%r{\A#{Regexp.escape(prefix)}}, "")
  abs = File.expand_path(rel, root)
  # traversal 防止: root の外を指したら拒否。
  unless abs.start_with?(root + File::SEPARATOR) || abs == root
    res.status = 403
    res.body = "forbidden"
    return
  end
  unless File.file?(abs)
    res.status = 404
    res.body = "not found"
    return
  end

  res.status = 200
  res["content-type"] = ASSET_CONTENT_TYPES.fetch(File.extname(abs), "application/octet-stream")
  res["cache-control"] = cache
  res.body = File.binread(abs)
end

#strip_ansi(text) ⇒ Object



428
429
430
# File 'lib/chocomint/edit_handlers.rb', line 428

def strip_ansi(text)
  text.gsub(ANSI_ESCAPE, "")
end

#tool_label(tool) ⇒ Object



457
458
459
# File 'lib/chocomint/edit_handlers.rb', line 457

def tool_label(tool)
  TOOL_LABELS.fetch(tool.to_s, "🔧 #{tool}")
end

#tool_output_text(result) ⇒ Object

ツール実行結果を UI 表示用のテキストに整形する。ハッシュなら stdout/stderr など 人が読みたいキーを優先する。結果ハッシュのキーはツール実装ではシンボル ({ exit_code:, stdout:, ... })、テスト等では文字列で来るため両対応にする。 該当キーが空なら nil を返し (生 JSON は出さない)、UI 側で「(出力なし)」を表示させる。 PowerShell 等の色付き出力に含まれる ANSI エスケープは、そのまま出すと文字化けに 見えるため除去する。



408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'lib/chocomint/edit_handlers.rb', line 408

def tool_output_text(result)
  text =
    case result
    when nil then nil
    when String then result
    when Hash
      joined = %i[stdout stderr message content]
               .map { |k| result_field(result, k) }
               .compact.map(&:to_s).reject(&:empty?).join("\n")
      joined.empty? ? nil : joined
    else
      result.to_s
    end
  text.nil? ? nil : strip_ansi(text)
end

#tool_target_summary(tool, args) ⇒ Object



464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# File 'lib/chocomint/edit_handlers.rb', line 464

def tool_target_summary(tool, args)
  return nil unless args.is_a?(Hash)

  value =
    case tool.to_s
    when "run_command", "bash", "shell"
      args["command"] || args["cmd"] || args["script"]
    when "grep", "glob"
      args["pattern"] || args["path"]
    else
      args["path"] || args["file"] || args["dir"]
    end
  return nil if value.nil?

  s = value.to_s.gsub(/\s+/, " ").strip
  s.length > TARGET_SUMMARY_LIMIT ? "#{s[0, TARGET_SUMMARY_LIMIT]}" : s
end

#with_fs(res) ⇒ Object

ファイル操作系ハンドラ共通の例外→HTTP 変換。



569
570
571
572
573
574
575
576
577
578
579
# File 'lib/chocomint/edit_handlers.rb', line 569

def with_fs(res)
  yield
rescue JSON::ParserError => e
  edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
rescue Chocomint::PathAccessError => e
  edit_json(res, 403, "error" => e.message)
rescue Chocomint::Error => e
  edit_json(res, 400, "error" => e.message)
rescue SystemCallError => e
  edit_json(res, 500, "error" => e.message)
end