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.("../../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- RAW_CONTENT_TYPES =
拡張子から Content-Type を推定する。未知の拡張子は octet-stream。
{ # 画像 (ラスター) ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".webp" => "image/webp", ".bmp" => "image/bmp", ".ico" => "image/x-icon", ".avif" => "image/avif", ".apng" => "image/apng", ".tif" => "image/tiff", ".tiff" => "image/tiff", # 画像 (ベクター) ".svg" => "image/svg+xml", # 文書 ".pdf" => "application/pdf", # 動画 ".mp4" => "video/mp4", ".m4v" => "video/mp4", ".webm" => "video/webm", ".ogv" => "video/ogg", ".mkv" => "video/x-matroska", ".mov" => "video/quicktime", ".avi" => "video/x-msvideo", # 音声 ".mp3" => "audio/mpeg", ".wav" => "audio/wav", ".ogg" => "audio/ogg", ".oga" => "audio/ogg", ".m4a" => "audio/mp4", ".aac" => "audio/aac", ".flac" => "audio/flac", ".opus" => "audio/opus", ".weba" => "audio/webm" }.freeze
- MEDIA_KINDS =
拡張子 → メディア種別 ("image" / "pdf" / "video" / "audio")。フロントはこの値で 表示ウィジェット (
/
{ "image" => %w[.png .jpg .jpeg .gif .webp .bmp .ico .avif .apng .tif .tiff .svg], "pdf" => %w[.pdf], "video" => %w[.mp4 .m4v .webm .ogv .mkv .mov .avi], "audio" => %w[.mp3 .wav .ogg .oga .m4a .aac .flac .opus .weba] }.freeze
- MEDIA_KIND_BY_EXT =
拡張子 (小文字) → メディア種別の逆引き表。
MEDIA_KINDS.each_with_object({}) do |(kind, exts), h| exts.each { |ext| h[ext] = kind } end.freeze
- BINARY_SNIFF_BYTES =
テキストとして扱えないか判定する。NUL バイトを含む、または先頭ブロックが 妥当な UTF-8 でないものはバイナリ (エディタで開けない) とみなす。
8192- COMPACT_INPUT_LIMIT =
要約に渡すログの上限 (これを超える古い部分は末尾を優先して切り詰める)。
12_000- CHAT_CONTEXT_LIMIT =
会話の文脈として渡すファイル内容 (先頭のみ)。開いていない/読めない場合は nil。
4000- CHAT_HISTORY_LIMIT =
HTTP リクエストの history (JSON 配列) を chat_client / Planner へ渡せる [{ "role" => "user"/"assistant", "content" => String }, ...] に正規化する。 不正な要素は無視する (壊れた履歴で応答全体を失敗させないため)。
20- ANSI_ESCAPE =
ANSI エスケープシーケンス (色・装飾。CSI \e[...m 等) を取り除く。 端末を経由しない chocomint の出力表示では色コードは無意味で、文字化けに見えるだけ。
/\e\[[0-9;?]*[ -\/]*[@-~]/- 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
- #binary_content?(raw) ⇒ Boolean
-
#build_chat_request(path, instruction) ⇒ Object
対象ファイルがあれば文脈として明示し、無ければ指示だけを渡す。 過去のやり取りは history として別途 messages の先頭に user/assistant のまま積むため (chat_client#answer / Planner#run 経由)、ここでは今回の指示だけを組み立てる。.
- #chat_file_context(path) ⇒ Object
-
#chat_instruction?(instruction) ⇒ Boolean
chat_client があり、指示が「会話」と分類されたときだけ会話として扱う。 chat_client 未注入なら常に false (従来どおり全て Planner に流す)。.
-
#console_ws_config ⇒ Object
WebSocket コンソールの接続先。ws_port 未設定ならコンソール無効。.
-
#deep_scrub(value) ⇒ Object
ハッシュ/配列/文字列を再帰的にたどり、文字列を妥当な UTF-8 に正規化する。 UTF-8 以外のエンコーディング (ASCII-8BIT な外部プロセス出力等) も UTF-8 とみなして scrub し、変換不能なバイトは置換文字にする。ハッシュのキーも同様に正規化する。.
-
#edit_chat_reply(instruction, path, history = []) ⇒ Object
会話 (普通の質問) への直接応答を UI 向け JSON に整形する。 開いているファイルがあれば内容を文脈として渡す。.
-
#edit_chat_result(result, path, instruction = nil) ⇒ Object
Planner::Result を UI 向けの JSON に整形する。編集後内容も返して Monaco を更新する。 instruction: 要約生成のための元の要求文 (任意)。.
- #edit_html ⇒ Object
- #edit_json(res, status, hash) ⇒ Object
- #edit_method_guard(res) ⇒ Object
-
#edit_root ⇒ Object
workspace ルート (allowed_roots の先頭)。表示・列挙の起点にする。.
-
#fs_body_path(req) ⇒ Object
JSON ボディから正規化済みの "path" を取り出す (空なら 400 相当の例外)。.
-
#handle_edit(req, res) ⇒ Object
---- エディタ画面 -------------------------------------------------------.
- #handle_edit_asset(req, res) ⇒ Object
-
#handle_edit_chat(req, res) ⇒ Object
---- AI 編集 (チャット) -------------------------------------------------.
-
#handle_edit_chat_stream(res, instruction, path, history) ⇒ Object
SSE 版のチャット処理。Planner のステップ進捗を逐次 event として流し、 最後に従来の JSON 結果を "result" event として送って締める。 イベント: tool_start / tool_done / result / error (data は JSON 1 行)。.
- #handle_edit_compact(req, res) ⇒ Object
-
#handle_edit_dir(req, res) ⇒ Object
WORKING DIRECTORY を遅延展開するためのエンドポイント。指定ディレクトリの 「直下 1 階層だけ」を列挙して返す。ツリー全体を一括列挙すると巨大ホーム (例: C:Users<name>) で応答が返らず表示が壊れるため、階層ごとに取得する。.
-
#handle_edit_file(req, res) ⇒ Object
---- ファイル取得 -------------------------------------------------------.
-
#handle_edit_fs_delete(req, res) ⇒ Object
ファイル or ディレクトリを削除する。.
-
#handle_edit_fs_mkdir(req, res) ⇒ Object
ディレクトリを新規作成する (親が無ければ一緒に作る)。.
-
#handle_edit_fs_rename(req, res) ⇒ Object
ファイル or ディレクトリの名前変更 (移動)。to は同一 workspace 内に限る。.
-
#handle_edit_fs_touch(req, res) ⇒ Object
空ファイルを新規作成する (親ディレクトリが無ければ作る)。.
-
#handle_edit_model(req, res) ⇒ Object
provider / model を切り替え、Planner・chat_client を再構築して config.yml に保存する。 POST /edit/model { "provider": "ollama"|"openrouter", "model": "..." }.
-
#handle_edit_models(req, res) ⇒ Object
現在の provider / model と、選択肢 (Ollama のインストール済みモデル一覧、 OpenRouter が利用可能かどうか) を返す。 GET /edit/models.
-
#handle_edit_raw(req, res) ⇒ Object
---- 生ファイル配信 (画像 / PDF / 動画 / 音声のプレビュー用) ---------------.
-
#handle_edit_save(req, res) ⇒ Object
---- 保存 (人手編集) ----------------------------------------------------.
- #handle_edit_static(req, res) ⇒ Object
-
#humanize_chat_error(err) ⇒ Object
Planner の例外メッセージ (英語) を UI 向けの日本語に言い換える。 原文は握りつぶさず末尾に括弧書きで残す (デバッグの手がかりのため)。.
-
#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)。.
- #media_content_type(abs) ⇒ Object
- #media_kind(abs) ⇒ Object
-
#normalize_fs_path(value) ⇒ Object
バックスラッシュを "/" に統一し前後空白を除く。.
-
#ollama_model_names ⇒ Object
Ollama にインストール済みのモデル名一覧を取得する。Ollama ネイティブ API の /api/tags を叩く。現在の provider が OpenRouter でも Ollama タブの候補は Ollama から取るため、config.ollama_api_base (provider 非依存) を使う。 取得失敗時は空配列を返す (UI では「Ollama に接続できません」等の空表示になる)。.
-
#parse_byte_range(header, size) ⇒ Object
"bytes=first-last" 形式の Range を [first, last] に解釈する (単一範囲のみ対応)。 範囲を持たない/不正/範囲外なら nil を返す。"bytes=500-" や "bytes=-500" にも対応。.
- #parse_chat_history(raw) ⇒ Object
-
#rebuild_llm! ⇒ Object
変更後の @config で Planner と chat_client を作り直し、以後のリクエストに反映する。.
-
#relative_root ⇒ Object
---- 補助 ---------------------------------------------------------------.
-
#result_field(result, key) ⇒ Object
結果ハッシュからキー key の値を取り出す (シンボル/文字列どちらのキーでも拾う)。.
-
#run_chat_stream(instruction, path, history, emit_sse) ⇒ Object
SSE 本体: 会話 or Planner 実行を行い、進捗と最終結果を emit_sse で送る。.
-
#serve_edit_static(req, res, prefix:, root:, cache:) ⇒ Object
prefix を取り除いた相対パスを root 配下限定で解決して配信する共通ロジック。.
-
#serve_raw_file(req, res, abs, content_type) ⇒ Object
ファイルを配信する。Range ヘッダがあれば 206 Partial Content で部分配信する (動画のシーク・一部ブラウザの音声/動画再生に必須)。範囲不正なら 416。.
- #strip_ansi(text) ⇒ Object
-
#tool_label(tool) ⇒ Object
内部ツール名を UI 向けの表示ラベルに変換する。 絵文字は付けず、"_" を空白に、小文字を大文字にする (例: run_command → RUN COMMAND)。.
-
#tool_output_text(result) ⇒ Object
ツール実行結果を UI 表示用のテキストに整形する。ハッシュなら stdout/stderr など 人が読みたいキーを優先する。結果ハッシュのキーはツール実装ではシンボル ({ exit_code:, stdout:, ... })、テスト等では文字列で来るため両対応にする。 該当キーが空なら nil を返し (生 JSON は出さない)、UI 側で「(出力なし)」を表示させる。 PowerShell 等の色付き出力に含まれる ANSI エスケープは、そのまま出すと文字化けに 見えるため除去する。.
- #tool_target_summary(tool, args) ⇒ Object
-
#with_fs(res) ⇒ Object
ファイル操作系ハンドラ共通の例外→HTTP 変換。.
Instance Method Details
#binary_content?(raw) ⇒ Boolean
267 268 269 270 271 272 273 274 |
# File 'lib/chocomint/edit_handlers.rb', line 267 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
対象ファイルがあれば文脈として明示し、無ければ指示だけを渡す。 過去のやり取りは history として別途 messages の先頭に user/assistant のまま積むため (chat_client#answer / Planner#run 経由)、ここでは今回の指示だけを組み立てる。
553 554 555 556 557 558 559 |
# File 'lib/chocomint/edit_handlers.rb', line 553 def build_chat_request(path, instruction) if path.empty? instruction else "ファイル #{path} に対して次の指示を反映してください: #{instruction}" end end |
#chat_file_context(path) ⇒ Object
537 538 539 540 541 542 543 544 545 546 547 548 |
# File 'lib/chocomint/edit_handlers.rb', line 537 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 に流す)。
516 517 518 519 520 521 522 523 |
# File 'lib/chocomint/edit_handlers.rb', line 516 def chat_instruction?(instruction) return false unless @chat_client @chat_client.classify(instruction) == "chat" rescue Chocomint::Error # 分類に失敗したら安全側でタスク扱い (Planner に流す)。 false end |
#console_ws_config ⇒ Object
WebSocket コンソールの接続先。ws_port 未設定ならコンソール無効。
903 904 905 906 907 |
# File 'lib/chocomint/edit_handlers.rb', line 903 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 し、変換不能なバイトは置換文字にする。ハッシュのキーも同様に正規化する。
882 883 884 885 886 887 888 889 890 891 892 893 894 |
# File 'lib/chocomint/edit_handlers.rb', line 882 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, history = []) ⇒ Object
会話 (普通の質問) への直接応答を UI 向け JSON に整形する。 開いているファイルがあれば内容を文脈として渡す。
527 528 529 530 531 532 |
# File 'lib/chocomint/edit_handlers.rb', line 527 def edit_chat_reply(instruction, path, history = []) answer = @chat_client.answer(instruction, context: chat_file_context(path), history: history) { "status" => "PASS", "reply" => answer, "steps" => [] } rescue Chocomint::Error => e { "status" => "FAIL", "error" => e., "steps" => [] } end |
#edit_chat_result(result, path, instruction = nil) ⇒ Object
Planner::Result を UI 向けの JSON に整形する。編集後内容も返して Monaco を更新する。 instruction: 要約生成のための元の要求文 (任意)。
582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 |
# File 'lib/chocomint/edit_handlers.rb', line 582 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..to_s.empty? && steps.empty? payload["summary"] = result. elsif result.incomplete payload["note"] = result. unless result..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..to_s.empty? && !steps.empty? payload["summary"] = result. 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_html ⇒ Object
909 910 911 912 913 |
# File 'lib/chocomint/edit_handlers.rb', line 909 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
869 870 871 872 873 874 875 876 877 |
# File 'lib/chocomint/edit_handlers.rb', line 869 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
896 897 898 899 900 |
# File 'lib/chocomint/edit_handlers.rb', line 896 def edit_method_guard(res) res.status = 405 res["content-type"] = "application/json" res.body = JSON.generate("error" => "method not allowed") end |
#edit_root ⇒ Object
workspace ルート (allowed_roots の先頭)。表示・列挙の起点にする。
23 24 25 |
# File 'lib/chocomint/edit_handlers.rb', line 23 def edit_root @path_guard.allowed_roots.first end |
#fs_body_path(req) ⇒ Object
JSON ボディから正規化済みの "path" を取り出す (空なら 400 相当の例外)。
772 773 774 775 776 777 778 |
# File 'lib/chocomint/edit_handlers.rb', line 772 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
---- エディタ画面 -------------------------------------------------------
851 852 853 854 855 856 857 858 859 860 |
# File 'lib/chocomint/edit_handlers.rb', line 851 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
809 810 811 812 813 814 815 816 |
# File 'lib/chocomint/edit_handlers.rb', line 809 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 編集 (チャット) -------------------------------------------------
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 |
# File 'lib/chocomint/edit_handlers.rb', line 305 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 history = parse_chat_history(body["history"]) return edit_json(res, 400, "error" => "instruction required") if instruction.empty? # stream=true なら SSE でツール実行の進捗を逐次配信する (UI のリアルタイム表示用)。 return handle_edit_chat_stream(res, instruction, path, history) if body["stream"] # ツール不要の普通の質問なら会話として直接応答する。それ以外は Planner に流す。 if chat_instruction?(instruction) return edit_json(res, 200, edit_chat_reply(instruction, path, history)) end request = build_chat_request(path, instruction) result = @planner.run(request, expectations: instruction, history: history) edit_json(res, 200, edit_chat_result(result, path, instruction)) rescue JSON::ParserError => e edit_json(res, 400, "error" => "invalid JSON: #{e.}") 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.) end |
#handle_edit_chat_stream(res, instruction, path, history) ⇒ Object
SSE 版のチャット処理。Planner のステップ進捗を逐次 event として流し、 最後に従来の JSON 結果を "result" event として送って締める。 イベント: tool_start / tool_done / result / error (data は JSON 1 行)。
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 |
# File 'lib/chocomint/edit_handlers.rb', line 338 def handle_edit_chat_stream(res, instruction, path, history) 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, history, 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.) end end end |
#handle_edit_compact(req, res) ⇒ Object
418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 |
# File 'lib/chocomint/edit_handlers.rb', line 418 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.}") rescue Chocomint::Error => e edit_json(res, 500, "error" => e.) 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 をそのまま渡せる。
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
# File 'lib/chocomint/edit_handlers.rb', line 46 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.) rescue SystemCallError => e # 権限不足などで開けないディレクトリ。空扱いにしてツリーを壊さない。 edit_json(res, 403, "error" => e.) rescue Chocomint::Error => e edit_json(res, 500, "error" => e.) end |
#handle_edit_file(req, res) ⇒ Object
---- ファイル取得 -------------------------------------------------------
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/chocomint/edit_handlers.rb', line 106 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) # 画像 / PDF / 動画 / 音声は拡張子で先に判定し、中身を読まずにメディア種別だけ返す # (フロントは /edit/raw で取得する)。SVG のようにテキストでもある形式もここで拾う。 # メディアは巨大化しやすい (動画等) ため @max_file_bytes の上限は掛けない。 media = media_kind(abs) return edit_json(res, 200, "path" => path, "media" => media) if media # zip / tar / tar.gz / bz2 等のアーカイブは第一階層一覧を表示する専用ビューに回す # (フロントは /edit/archive で取得する)。中身の展開はここでは行わない。 return edit_json(res, 200, "path" => path, "archive" => true) if archive?(abs) # テキストとして開くものだけサイズ上限を掛ける (Monaco に丸ごと載せるため)。 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.) rescue Chocomint::Error => e edit_json(res, 500, "error" => e.) end |
#handle_edit_fs_delete(req, res) ⇒ Object
ファイル or ディレクトリを削除する。
705 706 707 708 709 710 711 712 713 714 715 716 717 |
# File 'lib/chocomint/edit_handlers.rb', line 705 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
ディレクトリを新規作成する (親が無ければ一緒に作る)。
741 742 743 744 745 746 747 748 749 750 751 752 |
# File 'lib/chocomint/edit_handlers.rb', line 741 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 内に限る。
720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 |
# File 'lib/chocomint/edit_handlers.rb', line 720 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
空ファイルを新規作成する (親ディレクトリが無ければ作る)。
755 756 757 758 759 760 761 762 763 764 765 766 767 |
# File 'lib/chocomint/edit_handlers.rb', line 755 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_model(req, res) ⇒ Object
provider / model を切り替え、Planner・chat_client を再構築して config.yml に保存する。 POST /edit/model { "provider": "ollama"|"openrouter", "model": "..." }
464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 |
# File 'lib/chocomint/edit_handlers.rb', line 464 def handle_edit_model(req, res) return edit_method_guard(res) unless req.request_method == "POST" return edit_json(res, 503, "error" => "config not available") unless @config body = JSON.parse(req.body.to_s) provider = body["provider"].to_s model = body["model"].to_s.strip return edit_json(res, 400, "error" => "provider required") unless %w[ollama openrouter].include?(provider) return edit_json(res, 400, "error" => "model required") if model.empty? # OpenRouter は API キーが環境変数に無いと呼べないので、切り替え前に確認する。 if provider == "openrouter" && ENV["OPENROUTER_API_KEY"].to_s.empty? return edit_json(res, 400, "error" => "環境変数 OPENROUTER_API_KEY が設定されていません。" \ "設定してからサーバーを再起動してください。") end @config.set_llm!(provider: provider, model: model) rebuild_llm! @config.persist_llm! edit_json(res, 200, "provider" => @config.llm_provider, "model" => @config.llm_model) rescue JSON::ParserError => e edit_json(res, 400, "error" => "invalid JSON: #{e.}") rescue Chocomint::Error => e edit_json(res, 500, "error" => e.) end |
#handle_edit_models(req, res) ⇒ Object
現在の provider / model と、選択肢 (Ollama のインストール済みモデル一覧、 OpenRouter が利用可能かどうか) を返す。 GET /edit/models
448 449 450 451 452 453 454 455 456 457 458 459 460 |
# File 'lib/chocomint/edit_handlers.rb', line 448 def handle_edit_models(req, res) return edit_method_guard(res) unless req.request_method == "GET" return edit_json(res, 503, "error" => "config not available") unless @config openrouter_key = ENV["OPENROUTER_API_KEY"].to_s edit_json(res, 200, "provider" => @config.llm_provider, "model" => @config.llm_model, "ollama_models" => ollama_model_names, "openrouter_available" => !openrouter_key.empty?) rescue Chocomint::Error => e edit_json(res, 500, "error" => e.) end |
#handle_edit_raw(req, res) ⇒ Object
---- 生ファイル配信 (画像 / PDF / 動画 / 音声のプレビュー用) ---------------
GET /edit/raw?path=foo.png
workspace 内のファイルを、拡張子から推定した Content-Type でそのまま返す。
/
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/chocomint/edit_handlers.rb', line 151 def handle_edit_raw(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) content_type = media_content_type(abs) res["cache-control"] = "no-cache" res["accept-ranges"] = "bytes" serve_raw_file(req, res, abs, content_type) rescue Chocomint::PathAccessError => e edit_json(res, 403, "error" => e.) rescue Chocomint::Error => e edit_json(res, 500, "error" => e.) end |
#handle_edit_save(req, res) ⇒ Object
---- 保存 (人手編集) ----------------------------------------------------
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
# File 'lib/chocomint/edit_handlers.rb', line 278 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)) # バイナリモードで書き込む。Windows の既定 (テキストモード) だと \n が \r\n に # 変換され、クライアントが選んだ改行コード (CRLF/LF) を無視して常に CRLF になってしまう。 File.binwrite(abs, content) edit_json(res, 200, "path" => path, "bytes" => content.bytesize) rescue JSON::ParserError => e edit_json(res, 400, "error" => "invalid JSON: #{e.}") rescue Chocomint::PathAccessError => e edit_json(res, 403, "error" => e.) rescue Chocomint::Error => e edit_json(res, 500, "error" => e.) end |
#handle_edit_static(req, res) ⇒ Object
818 819 820 821 822 823 824 825 |
# File 'lib/chocomint/edit_handlers.rb', line 818 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 向けの日本語に言い換える。 原文は握りつぶさず末尾に括弧書きで残す (デバッグの手がかりのため)。
395 396 397 398 399 400 401 402 403 404 405 406 |
# File 'lib/chocomint/edit_handlers.rb', line 395 def humanize_chat_error(err) case err when Chocomint::RetryLimitExceededError "指示を達成できませんでした。指示をより具体的にするか、手順を分けて試してください。" when Chocomint::InvalidProposalError "AI が実行するツールをうまく選べませんでした。指示を言い換えて試してください。" when Chocomint::UnknownToolError "AI が存在しないツールを呼び出そうとしました。指示を言い換えて試してください。" else "エラーが発生しました。" end + " (#{err.})" 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)。
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 102 |
# File 'lib/chocomint/edit_handlers.rb', line 69 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 |
#media_content_type(abs) ⇒ Object
191 192 193 |
# File 'lib/chocomint/edit_handlers.rb', line 191 def media_content_type(abs) RAW_CONTENT_TYPES.fetch(File.extname(abs).downcase, "application/octet-stream") end |
#media_kind(abs) ⇒ Object
209 210 211 |
# File 'lib/chocomint/edit_handlers.rb', line 209 def media_kind(abs) MEDIA_KIND_BY_EXT[File.extname(abs).downcase] end |
#normalize_fs_path(value) ⇒ Object
バックスラッシュを "/" に統一し前後空白を除く。
781 782 783 |
# File 'lib/chocomint/edit_handlers.rb', line 781 def normalize_fs_path(value) value.to_s.tr("\\", "/").strip end |
#ollama_model_names ⇒ Object
Ollama にインストール済みのモデル名一覧を取得する。Ollama ネイティブ API の /api/tags を叩く。現在の provider が OpenRouter でも Ollama タブの候補は Ollama から取るため、config.ollama_api_base (provider 非依存) を使う。 取得失敗時は空配列を返す (UI では「Ollama に接続できません」等の空表示になる)。
502 503 504 505 506 507 508 509 510 511 512 |
# File 'lib/chocomint/edit_handlers.rb', line 502 def ollama_model_names base = @config.ollama_api_base conn = Faraday.new { |f| f..timeout = 5; f..open_timeout = 5 } response = conn.get("#{base}/api/tags") return [] unless response.success? data = JSON.parse(response.body) Array(data["models"]).filter_map { |m| m["name"] if m.is_a?(Hash) }.sort rescue Faraday::Error, JSON::ParserError [] end |
#parse_byte_range(header, size) ⇒ Object
"bytes=first-last" 形式の Range を [first, last] に解釈する (単一範囲のみ対応)。 範囲を持たない/不正/範囲外なら nil を返す。"bytes=500-" や "bytes=-500" にも対応。
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/chocomint/edit_handlers.rb', line 243 def parse_byte_range(header, size) return nil if header.nil? || size.zero? m = /\Abytes=(\d*)-(\d*)\z/.match(header.strip) return nil unless m first_s, last_s = m[1], m[2] if first_s.empty? && last_s.empty? nil elsif first_s.empty? # 末尾 N バイト。 n = last_s.to_i n.zero? ? nil : [[size - n, 0].max, size - 1] else first = first_s.to_i last = last_s.empty? ? size - 1 : [last_s.to_i, size - 1].min (first <= last && first < size) ? [first, last] : nil end end |
#parse_chat_history(raw) ⇒ Object
566 567 568 569 570 571 572 573 574 575 576 577 578 |
# File 'lib/chocomint/edit_handlers.rb', line 566 def parse_chat_history(raw) return [] unless raw.is_a?(Array) raw.filter_map do |entry| next unless entry.is_a?(Hash) role = entry["role"].to_s content = entry["content"].to_s next if content.empty? || !%w[user assistant].include?(role) { "role" => role, "content" => content } end.last(CHAT_HISTORY_LIMIT) end |
#rebuild_llm! ⇒ Object
変更後の @config で Planner と chat_client を作り直し、以後のリクエストに反映する。
493 494 495 496 |
# File 'lib/chocomint/edit_handlers.rb', line 493 def rebuild_llm! @planner = Chocomint::Factory.build(@config, base_dir: @base_dir) @chat_client = Chocomint::Factory.build_chat_client(@config, base_dir: @base_dir) end |
#relative_root ⇒ Object
---- 補助 ---------------------------------------------------------------
864 865 866 867 |
# File 'lib/chocomint/edit_handlers.rb', line 864 def relative_root rel = edit_root.sub(/\A#{Regexp.escape(@base_dir)}[\\\/]?/, "") rel.empty? ? "." : rel.tr("\\", "/") end |
#result_field(result, key) ⇒ Object
結果ハッシュからキー key の値を取り出す (シンボル/文字列どちらのキーでも拾う)。
666 667 668 669 670 |
# File 'lib/chocomint/edit_handlers.rb', line 666 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, history, emit_sse) ⇒ Object
SSE 本体: 会話 or Planner 実行を行い、進捗と最終結果を emit_sse で送る。
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 |
# File 'lib/chocomint/edit_handlers.rb', line 357 def run_chat_stream(instruction, path, history, emit_sse) # 会話 (ツール不要) は進捗が無いので、そのまま最終結果だけ送る。 if chat_instruction?(instruction) emit_sse.call("result", edit_chat_reply(instruction, path, history)) 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, history: history) emit_sse.call("result", edit_chat_result(result, path, instruction)) end |
#serve_edit_static(req, res, prefix:, root:, cache:) ⇒ Object
prefix を取り除いた相対パスを root 配下限定で解決して配信する共通ロジック。
828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 |
# File 'lib/chocomint/edit_handlers.rb', line 828 def serve_edit_static(req, res, prefix:, root:, cache:) rel = req.path.sub(%r{\A#{Regexp.escape(prefix)}}, "") abs = File.(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 |
#serve_raw_file(req, res, abs, content_type) ⇒ Object
ファイルを配信する。Range ヘッダがあれば 206 Partial Content で部分配信する (動画のシーク・一部ブラウザの音声/動画再生に必須)。範囲不正なら 416。
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 |
# File 'lib/chocomint/edit_handlers.rb', line 215 def serve_raw_file(req, res, abs, content_type) size = File.size(abs) range = parse_byte_range(req["range"], size) if range.nil? && req["range"].to_s.strip != "" # Range 指定はあるが解釈できない → 416 で全長を知らせる。 res.status = 416 res["content-range"] = "bytes */#{size}" res.body = "" return end res["content-type"] = content_type if range first, last = range res.status = 206 res["content-range"] = "bytes #{first}-#{last}/#{size}" res["content-length"] = (last - first + 1).to_s res.body = File.open(abs, "rb") { |f| f.seek(first); f.read(last - first + 1) } else res.status = 200 res["content-length"] = size.to_s res.body = File.binread(abs) end end |
#strip_ansi(text) ⇒ Object
661 662 663 |
# File 'lib/chocomint/edit_handlers.rb', line 661 def strip_ansi(text) text.gsub(ANSI_ESCAPE, "") end |
#tool_label(tool) ⇒ Object
内部ツール名を UI 向けの表示ラベルに変換する。 絵文字は付けず、"_" を空白に、小文字を大文字にする (例: run_command → RUN COMMAND)。
674 675 676 |
# File 'lib/chocomint/edit_handlers.rb', line 674 def tool_label(tool) tool.to_s.tr("_", " ").upcase end |
#tool_output_text(result) ⇒ Object
ツール実行結果を UI 表示用のテキストに整形する。ハッシュなら stdout/stderr など 人が読みたいキーを優先する。結果ハッシュのキーはツール実装ではシンボル ({ exit_code:, stdout:, ... })、テスト等では文字列で来るため両対応にする。 該当キーが空なら nil を返し (生 JSON は出さない)、UI 側で「(出力なし)」を表示させる。 PowerShell 等の色付き出力に含まれる ANSI エスケープは、そのまま出すと文字化けに 見えるため除去する。
641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 |
# File 'lib/chocomint/edit_handlers.rb', line 641 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
681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 |
# File 'lib/chocomint/edit_handlers.rb', line 681 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 変換。
786 787 788 789 790 791 792 793 794 795 796 |
# File 'lib/chocomint/edit_handlers.rb', line 786 def with_fs(res) yield rescue JSON::ParserError => e edit_json(res, 400, "error" => "invalid JSON: #{e.}") rescue Chocomint::PathAccessError => e edit_json(res, 403, "error" => e.) rescue Chocomint::Error => e edit_json(res, 400, "error" => e.) rescue SystemCallError => e edit_json(res, 500, "error" => e.) end |