chocomint 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 691a0248c4e919afa5640c7398b3f1b06221c397ac2c1e4c73fb56c044174246
4
- data.tar.gz: 5371ad706155faff73120e8064f6629b7a776099e7c66fc5f07a89bfec932799
3
+ metadata.gz: cf5b5e18513023f06550376aa901ada01d10efd621f53fc01c4f1671b94e3f1d
4
+ data.tar.gz: 8fa2badc83ba90c08fd8bcbc5a2a2632a4342993befafb3721457e10c39ee168
5
5
  SHA512:
6
- metadata.gz: 75558e5f77a42aaaddaa2ffdfc5a509975cf7660ae5eaf346d9e5e24d45ce3873783f23e924b5516a6341db2f405e3ac1bd3688bf7677cc9fe0ec0074a5a352c
7
- data.tar.gz: 0b43f17a5b4f916798db9107c3cef61d21f07da1299723a3feb1e31de43231f521bfc8e4bdfc560ff6dd1b65f9546819b61fb79e9263c90ef89796984cac48c9
6
+ metadata.gz: 6be492f0a271ab2d6978da7c22c6740522983f933f167b96fcdc7800482b341aadc48156d4cb062612abadda090dcc34220a4f0e21745e2b835c51e0a658f8fb
7
+ data.tar.gz: b5408738a6734a31b1d311fa455c4f2374f176c375cd6fddb7136011353c8fe7dddd54461851baf2c7ed625c6091f7a3d36e90e30ea71a6d918eda31a66ebd0d
data/README.md CHANGED
@@ -27,7 +27,14 @@ bundle install
27
27
 
28
28
  ## 設定
29
29
 
30
- `config/config.yml` を編集する(DESIGN §11)。実機確認済みの構成:
30
+ 設定ファイルは XDG Base Directory 準拠のユーザーパスに置かれる:
31
+
32
+ - `~/.config/chocomint/config.yaml`(`$XDG_CONFIG_HOME` があればそちらの `chocomint/config.yaml`)
33
+
34
+ 初回起動時、このファイルが無ければ gem 同梱の既定 `config/config.yml` が上記へ
35
+ コピーされる。以後はユーザーパス側を編集する(`/edit` のモデル切り替えもここへ保存される)。
36
+
37
+ 実機確認済みの構成:
31
38
 
32
39
  ```yaml
33
40
  llm:
data/bin/chocomint-server CHANGED
@@ -39,5 +39,6 @@ Chocomint::Server.new(
39
39
  max_file_bytes: config.max_file_bytes,
40
40
  ws_port: ws_port,
41
41
  ws_token: ws_token,
42
- chat_client: chat_client
42
+ chat_client: chat_client,
43
+ config: config
43
44
  ).start
data/config/config.yml CHANGED
@@ -1,37 +1,39 @@
1
- # chocomint 設定 (DESIGN.md §11)
2
- llm:
3
- base_url: http://localhost:11434/v1
4
- model: gemma4:e4b
5
- api_key_env: OLLAMA_API_KEY # 参照する環境変数名 (無ければ空)
6
- max_tokens: 4096 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
7
-
8
- verifier:
9
- enabled: true
10
- # モデルは指定しない。VRAM 節約のため llm.model を共有する (factory.rb 参照)。
11
- max_tokens: 1024 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
12
-
13
- execution:
14
- max_retry: 10
15
- max_steps: 20 # マルチステップ要求で実行を許すステップ数の上限 (暴走防止)
16
- timeout_sec: 30
17
- max_output_bytes: 1048576 # stdout/stderr サイズ制限 (1 MiB)
18
-
19
- security:
20
- allowed_dirs:
21
- - . # サーバーを起動した場所 (Dir.pwd) を作業ディレクトリとして許可
22
- max_file_bytes: 10485760 # ファイルサイズ制限 (10 MiB)
23
- allowed_commands: # run_command で実行を許可するコマンド (whitelist)
24
- - git
25
- - ruby
26
- - node
27
- - uv # Python 環境構築 (uv init / uv add / venv)
28
- - ping # ネットワーク疎通確認
29
- allow_all_commands: true # true にすると run_command allowed_commands を無視し任意コマンドを実行する
30
- # (⚠️ whitelist を無効化する。信頼できるローカル環境でのみ true にすること)
31
-
32
- logging:
33
- sqlite_path: ./logs/execution.db
34
-
35
- server:
36
- host: 127.0.0.1
37
- port: 9210
1
+ # chocomint 設定 (DESIGN.md §11)
2
+ llm:
3
+ base_url: http://localhost:11434/v1
4
+ model: gemma4:e4b
5
+ api_key_env: OLLAMA_API_KEY # 参照する環境変数名 (無ければ空)
6
+ max_tokens: 4096 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
7
+ timeout_sec: 300 # LLM 応答待ちのタイムアウト (秒)。ローカル LLM は遅いため長めに
8
+ # (ツール実行の execution.timeout_sec とは別物)
9
+
10
+ verifier:
11
+ enabled: true
12
+ # モデルは指定しない。VRAM 節約のため llm.model を共有する (factory.rb 参照)。
13
+ max_tokens: 1024 # thinking モデルは思考だけで使い切ることがあるため余裕を持たせる
14
+
15
+ execution:
16
+ max_retry: 10
17
+ max_steps: 20 # マルチステップ要求で実行を許すステップ数の上限 (暴走防止)
18
+ timeout_sec: 30
19
+ max_output_bytes: 1048576 # stdout/stderr サイズ制限 (1 MiB)
20
+
21
+ security:
22
+ allowed_dirs:
23
+ - . # サーバーを起動した場所 (Dir.pwd) を作業ディレクトリとして許可
24
+ max_file_bytes: 10485760 # ファイルサイズ制限 (10 MiB)
25
+ allowed_commands: # run_command で実行を許可するコマンド (whitelist)
26
+ - git
27
+ - ruby
28
+ - node
29
+ - uv # Python 環境構築 (uv init / uv add / venv)
30
+ - ping # ネットワーク疎通確認
31
+ allow_all_commands: true # true にすると run_command が allowed_commands を無視し任意コマンドを実行する
32
+ # (⚠️ whitelist を無効化する。信頼できるローカル環境でのみ true にすること)
33
+
34
+ logging:
35
+ sqlite_path: ./logs/execution.db
36
+
37
+ server:
38
+ host: 127.0.0.1
39
+ port: 9210
@@ -1,21 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "yaml"
4
+ require "fileutils"
5
+ require "uri"
4
6
 
5
7
  module Chocomint
6
- # config/config.yml を読み込み、環境変数オーバーライドを適用する。
8
+ # 設定ファイルを読み込み、環境変数オーバーライドを適用する。
9
+ #
10
+ # 設定は XDG Base Directory 準拠のユーザーパス
11
+ # (~/.config/chocomint/config.yaml。$XDG_CONFIG_HOME があればそちらを優先) に置く。
12
+ # ユーザー設定がまだ無い場合は、gem 同梱の既定 config をそこへコピーして雛形にする
13
+ # (初回起動時)。読み込み・保存 (モデル切り替えの persist) はいずれもこのユーザーパスを使う。
7
14
  class Config
8
- DEFAULT_PATH = File.expand_path("../../config/config.yml", __dir__)
15
+ # gem 同梱の既定 config (初回のコピー元。読み取り専用として扱う)
16
+ BUNDLED_PATH = File.expand_path("../../config/config.yml", __dir__)
9
17
 
10
18
  attr_reader :data
11
19
 
12
- def self.load(path = DEFAULT_PATH)
20
+ # XDG 準拠のユーザー設定ファイルパス (~/.config/chocomint/config.yaml)
21
+ def self.user_config_path
22
+ base = ENV["XDG_CONFIG_HOME"]
23
+ base = File.join(Dir.home, ".config") if base.nil? || base.empty?
24
+ File.join(base, "chocomint", "config.yaml")
25
+ end
26
+
27
+ # 設定を読み込む。明示的な path が無ければ user_config_path を使い、そこに
28
+ # まだファイルが無ければ gem 同梱の既定 config をコピーして作成する。
29
+ # コピーできない (書き込み不可など) 場合は同梱 config をそのまま読み、
30
+ # 保存先だけユーザーパスに向ける (persist 時に改めて作成を試みる)。
31
+ def self.load(path = nil)
32
+ path ||= ensure_user_config
13
33
  raw = File.exist?(path) ? YAML.safe_load(File.read(path)) : {}
14
- new(raw || {})
34
+ new(raw || {}, path)
35
+ end
36
+
37
+ # user_config_path を用意する。無ければ同梱 config をコピーして作る。
38
+ # 戻り値: 実際に読み込むべきパス (コピー成功時はユーザーパス、失敗時は同梱パス)。
39
+ def self.ensure_user_config
40
+ dest = user_config_path
41
+ return dest if File.exist?(dest)
42
+
43
+ begin
44
+ FileUtils.mkdir_p(File.dirname(dest))
45
+ FileUtils.cp(BUNDLED_PATH, dest) if File.exist?(BUNDLED_PATH)
46
+ File.exist?(dest) ? dest : BUNDLED_PATH
47
+ rescue SystemCallError
48
+ # 書き込み不可などでコピーできないときは同梱 config を読む。
49
+ BUNDLED_PATH
50
+ end
15
51
  end
16
52
 
17
- def initialize(data)
53
+ # 読み込み元パス。llm モデル設定を書き戻す (persist_llm!) のにも使う。
54
+ # path は第 2 引数 (キーワードにすると Config.new("k" => v) が keyword 誤認で壊れるため)。
55
+ attr_reader :path
56
+
57
+ def initialize(data, path = self.class.user_config_path)
18
58
  @data = deep_stringify(data)
59
+ @path = path
19
60
  apply_env_overrides!
20
61
  end
21
62
 
@@ -37,6 +78,10 @@ module Chocomint
37
78
  # マルチステップ実行のステップ数上限。未設定なら max_retry を流用する (暴走防止)。
38
79
  def max_steps = execution.fetch("max_steps", max_retry)
39
80
  def timeout_sec = execution.fetch("timeout_sec", 30)
81
+ # LLM への HTTP リクエストのタイムアウト (秒)。ツール実行の timeout_sec とは分離する:
82
+ # ローカル LLM は 1 応答に数分かかることがあり、暴走コマンド用の短い timeout_sec を
83
+ # 流用すると Net::ReadTimeout で失敗するため。未設定なら余裕を持たせた既定にする。
84
+ def llm_timeout_sec = llm.fetch("timeout_sec", 300)
40
85
  def max_output_bytes = execution.fetch("max_output_bytes", 1_048_576)
41
86
 
42
87
  def allowed_dirs = security.fetch("allowed_dirs", ["."])
@@ -49,8 +94,95 @@ module Chocomint
49
94
 
50
95
  def verifier_enabled? = verifier.fetch("enabled", false)
51
96
 
97
+ # ---- LLM プロバイダ / モデル設定 (AI エディタのモデル切り替え用) ----------
98
+ #
99
+ # provider は base_url から推定する: openrouter.ai を含めば "openrouter"、
100
+ # それ以外 (localhost:11434 等) は "ollama" とみなす。UI での表示・選択に使う。
101
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
102
+ OLLAMA_BASE_URL = "http://localhost:11434/v1"
103
+
104
+ def llm_base_url = llm.fetch("base_url", OLLAMA_BASE_URL)
105
+ def llm_model = llm.fetch("model", nil)
106
+
107
+ def llm_provider
108
+ llm_base_url.to_s.include?("openrouter.ai") ? "openrouter" : "ollama"
109
+ end
110
+
111
+ # Ollama の API ベース URL (末尾に /v1 を含まない)。インストール済みモデル一覧
112
+ # (/api/tags) の取得に使う。provider が OpenRouter でも Ollama タブのモデルは
113
+ # ここから取るため、現在の provider に依存させない。
114
+ #
115
+ # 優先順位: (1) OLLAMA_HOST 環境変数 (Ollama 標準の設定) →
116
+ # (2) 現在の base_url が Ollama を指していればその親 →
117
+ # (3) 既定 (http://localhost:11434)。
118
+ def ollama_api_base
119
+ if (host = ENV["OLLAMA_HOST"]) && !host.to_s.empty?
120
+ return normalize_ollama_host(host)
121
+ end
122
+ base = llm_provider == "ollama" ? llm_base_url : OLLAMA_BASE_URL
123
+ base.to_s.sub(%r{/v1/?\z}, "").chomp("/")
124
+ end
125
+
126
+ # 実行時にモデル (と provider) を差し替える。Factory がここを参照して
127
+ # Planner / chat_client を再構築する。api_key_env は provider ごとに固定する
128
+ # (Ollama: OLLAMA_API_KEY / OpenRouter: OPENROUTER_API_KEY)。
129
+ def set_llm!(provider:, model:)
130
+ llm_cfg = (@data["llm"] ||= {})
131
+ llm_cfg["model"] = model
132
+ case provider.to_s
133
+ when "openrouter"
134
+ llm_cfg["base_url"] = OPENROUTER_BASE_URL
135
+ llm_cfg["api_key_env"] = "OPENROUTER_API_KEY"
136
+ else
137
+ llm_cfg["base_url"] = OLLAMA_BASE_URL
138
+ llm_cfg["api_key_env"] = "OLLAMA_API_KEY"
139
+ end
140
+ self
141
+ end
142
+
143
+ # 現在の llm 設定 (provider / model / base_url / api_key_env) をユーザー設定へ
144
+ # 書き戻す。llm セクションだけを更新し、他セクションは元のファイル内容を保つ。
145
+ #
146
+ # 保存先は常にユーザーパス (~/.config/chocomint/config.yaml)。読み込み元が
147
+ # gem 同梱 config だった場合 (初回コピーに失敗したケース) でも、gem ディレクトリ
148
+ # には書かず、ベースは @path (同梱) の内容を引き継いでユーザーパスへ書き出す。
149
+ def persist_llm!
150
+ dest = self.class.user_config_path
151
+ raw = begin
152
+ (File.exist?(@path) && YAML.safe_load(File.read(@path))) || {}
153
+ rescue Psych::SyntaxError
154
+ {}
155
+ end
156
+ raw = deep_stringify(raw)
157
+ raw["llm"] = @data["llm"]
158
+ FileUtils.mkdir_p(File.dirname(dest))
159
+ File.write(dest, YAML.dump(raw))
160
+ @path = dest
161
+ end
162
+
52
163
  private
53
164
 
165
+ # Ollama の既定ポート。OLLAMA_HOST にポートが無いとき補う。
166
+ OLLAMA_DEFAULT_PORT = 11434
167
+
168
+ # OLLAMA_HOST の値をクライアントが接続できる URL に正規化する。
169
+ # OLLAMA_HOST は Ollama サーバーの「待ち受け設定」なので、そのまま接続先には使えない:
170
+ # - scheme が無ければ http:// を補う
171
+ # - ポートが無ければ既定の 11434 を補う
172
+ # - 0.0.0.0 / :: (全 IF で listen する指定) は接続不可なので 127.0.0.1 に読み替える
173
+ def normalize_ollama_host(host)
174
+ host = host.to_s.strip
175
+ host = "http://#{host}" unless host.match?(%r{\Ahttps?://}i)
176
+ uri = URI.parse(host)
177
+ uri.host = "127.0.0.1" if ["0.0.0.0", "::", "[::]"].include?(uri.host)
178
+ # URI::HTTP は scheme 既定ポート (80) を .port に持つため、元文字列に ":ポート" が
179
+ # 明示されていたかで判定する。無ければ Ollama の既定ポートを補う。
180
+ uri.port = OLLAMA_DEFAULT_PORT unless host.match?(/:\d+(?:\/|\z)/)
181
+ uri.to_s.chomp("/")
182
+ rescue URI::InvalidURIError
183
+ OLLAMA_BASE_URL.sub(%r{/v1/?\z}, "")
184
+ end
185
+
54
186
  def apply_env_overrides!
55
187
  if (url = ENV["CHOCOMINT_LLM_BASE_URL"])
56
188
  (@data["llm"] ||= {})["base_url"] = url
@@ -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 して使う)。
@@ -307,18 +308,19 @@ module Chocomint
307
308
  body = JSON.parse(req.body.to_s)
308
309
  instruction = body["instruction"].to_s.strip
309
310
  path = body["path"].to_s
311
+ history = parse_chat_history(body["history"])
310
312
  return edit_json(res, 400, "error" => "instruction required") if instruction.empty?
311
313
 
312
314
  # stream=true なら SSE でツール実行の進捗を逐次配信する (UI のリアルタイム表示用)。
313
- return handle_edit_chat_stream(res, instruction, path) if body["stream"]
315
+ return handle_edit_chat_stream(res, instruction, path, history) if body["stream"]
314
316
 
315
317
  # ツール不要の普通の質問なら会話として直接応答する。それ以外は Planner に流す。
316
318
  if chat_instruction?(instruction)
317
- return edit_json(res, 200, edit_chat_reply(instruction, path))
319
+ return edit_json(res, 200, edit_chat_reply(instruction, path, history))
318
320
  end
319
321
 
320
322
  request = build_chat_request(path, instruction)
321
- result = @planner.run(request, expectations: instruction)
323
+ result = @planner.run(request, expectations: instruction, history: history)
322
324
  edit_json(res, 200, edit_chat_result(result, path, instruction))
323
325
  rescue JSON::ParserError => e
324
326
  edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
@@ -333,7 +335,7 @@ module Chocomint
333
335
  # SSE 版のチャット処理。Planner のステップ進捗を逐次 event として流し、
334
336
  # 最後に従来の JSON 結果を "result" event として送って締める。
335
337
  # イベント: tool_start / tool_done / result / error (data は JSON 1 行)。
336
- def handle_edit_chat_stream(res, instruction, path)
338
+ def handle_edit_chat_stream(res, instruction, path, history)
337
339
  res.status = 200
338
340
  res["content-type"] = "text/event-stream; charset=utf-8"
339
341
  res["cache-control"] = "no-cache"
@@ -341,7 +343,7 @@ module Chocomint
341
343
  res.body = lambda do |out|
342
344
  emit_sse = ->(event, data) { out.write("event: #{event}\ndata: #{JSON.generate(deep_scrub(data))}\n\n") }
343
345
  begin
344
- run_chat_stream(instruction, path, emit_sse)
346
+ run_chat_stream(instruction, path, history, emit_sse)
345
347
  rescue Chocomint::InvalidProposalError, Chocomint::UnknownToolError,
346
348
  Chocomint::RetryLimitExceededError => e
347
349
  emit_sse.call("result", "status" => "FAIL", "error" => humanize_chat_error(e), "steps" => [])
@@ -352,10 +354,10 @@ module Chocomint
352
354
  end
353
355
 
354
356
  # SSE 本体: 会話 or Planner 実行を行い、進捗と最終結果を emit_sse で送る。
355
- def run_chat_stream(instruction, path, emit_sse)
357
+ def run_chat_stream(instruction, path, history, emit_sse)
356
358
  # 会話 (ツール不要) は進捗が無いので、そのまま最終結果だけ送る。
357
359
  if chat_instruction?(instruction)
358
- emit_sse.call("result", edit_chat_reply(instruction, path))
360
+ emit_sse.call("result", edit_chat_reply(instruction, path, history))
359
361
  return
360
362
  end
361
363
 
@@ -384,7 +386,7 @@ module Chocomint
384
386
  end
385
387
  end
386
388
 
387
- result = @planner.run(request, expectations: instruction, on_event: on_event)
389
+ result = @planner.run(request, expectations: instruction, on_event: on_event, history: history)
388
390
  emit_sse.call("result", edit_chat_result(result, path, instruction))
389
391
  end
390
392
 
@@ -406,8 +408,9 @@ module Chocomint
406
408
  # ---- 会話ログの圧縮 (/compact) -----------------------------------------
407
409
  #
408
410
  # ブラウザから現在のチャット表示テキストを受け取り、AI に要点を要約させて返す。
409
- # サーバー側は会話履歴を保持しないため、これは「表示ログを短い要約 1 件に畳む」
410
- # ための機能 (AI の文脈には元々影響しない)。chat_client 未注入なら 503。
411
+ # 会話履歴はブラウザ側 (表示ログ) にのみ保持され、/edit/chat 送信時に毎回
412
+ # history として渡される。この要約 1 件でログを畳めば、以後の history にも
413
+ # 圧縮済みの文脈として引き継がれる。chat_client 未注入なら 503。
411
414
 
412
415
  # 要約に渡すログの上限 (これを超える古い部分は末尾を優先して切り詰める)。
413
416
  COMPACT_INPUT_LIMIT = 12_000
@@ -433,6 +436,81 @@ module Chocomint
433
436
  edit_json(res, 500, "error" => e.message)
434
437
  end
435
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
+
436
514
  # chat_client があり、指示が「会話」と分類されたときだけ会話として扱う。
437
515
  # chat_client 未注入なら常に false (従来どおり全て Planner に流す)。
438
516
  def chat_instruction?(instruction)
@@ -446,8 +524,8 @@ module Chocomint
446
524
 
447
525
  # 会話 (普通の質問) への直接応答を UI 向け JSON に整形する。
448
526
  # 開いているファイルがあれば内容を文脈として渡す。
449
- def edit_chat_reply(instruction, path)
450
- 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)
451
529
  { "status" => "PASS", "reply" => answer, "steps" => [] }
452
530
  rescue Chocomint::Error => e
453
531
  { "status" => "FAIL", "error" => e.message, "steps" => [] }
@@ -470,6 +548,8 @@ module Chocomint
470
548
  end
471
549
 
472
550
  # 対象ファイルがあれば文脈として明示し、無ければ指示だけを渡す。
551
+ # 過去のやり取りは history として別途 messages の先頭に user/assistant のまま積むため
552
+ # (chat_client#answer / Planner#run 経由)、ここでは今回の指示だけを組み立てる。
473
553
  def build_chat_request(path, instruction)
474
554
  if path.empty?
475
555
  instruction
@@ -478,6 +558,25 @@ module Chocomint
478
558
  end
479
559
  end
480
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
+
481
580
  # Planner::Result を UI 向けの JSON に整形する。編集後内容も返して Monaco を更新する。
482
581
  # instruction: 要約生成のための元の要求文 (任意)。
483
582
  def edit_chat_result(result, path, instruction = nil)
@@ -570,26 +669,10 @@ module Chocomint
570
669
  result.fetch(key) { result[key.to_s] }
571
670
  end
572
671
 
573
- # 内部ツール名を UI 向けの日本語ラベル (アイコン付き) に変換する。未知の名前は原文。
574
- TOOL_LABELS = {
575
- "write_file" => "📝 ファイル書き込み",
576
- "read_file" => "📖 ファイル読み取り",
577
- "append_file" => "➕ ファイル追記",
578
- "delete_file" => "🗑 ファイル削除",
579
- "edit" => "✏️ ファイル編集",
580
- "make_dir" => "📁 ディレクトリ作成",
581
- "file_info" => "ℹ️ ファイル情報",
582
- "list_dir" => "📂 ディレクトリ一覧",
583
- "ls" => "📂 一覧表示",
584
- "glob" => "🔍 ファイル検索",
585
- "grep" => "🔍 内容検索",
586
- "run_command" => "▶️ コマンド実行",
587
- "bash" => "▶️ コマンド実行",
588
- "shell" => "▶️ コマンド実行"
589
- }.freeze
590
-
672
+ # 内部ツール名を UI 向けの表示ラベルに変換する。
673
+ # 絵文字は付けず、"_" を空白に、小文字を大文字にする (例: run_command → RUN COMMAND)。
591
674
  def tool_label(tool)
592
- TOOL_LABELS.fetch(tool.to_s, "🔧 #{tool}")
675
+ tool.to_s.tr("_", " ").upcase
593
676
  end
594
677
 
595
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
@@ -27,12 +27,15 @@ module Chocomint
27
27
  # edit UI 用に PathGuard / base_dir / vendor / console 情報も受け取る (任意)。
28
28
  # chat_client: 会話 (ツール不要の質問) を判定・応答する ChatClient (任意)。
29
29
  # 未指定なら /edit チャットは従来どおり全指示を Planner に流す。
30
+ # config: Config インスタンス (任意)。渡すと /edit のモデル切り替え
31
+ # (POST /edit/model) で Planner / chat_client を再構築できるようになる。
30
32
  def initialize(planner, host: "127.0.0.1", port: 9210, logger: $stderr,
31
33
  sqlite_path: nil, path_guard: nil, base_dir: Dir.pwd,
32
34
  max_file_bytes: 10_485_760, ws_port: nil, ws_token: nil,
33
- chat_client: nil)
35
+ chat_client: nil, config: nil)
34
36
  @planner = planner
35
37
  @chat_client = chat_client
38
+ @config = config
36
39
  @host = host
37
40
  @port = port
38
41
  @logger = logger
@@ -70,6 +73,9 @@ module Chocomint
70
73
  server.mount_proc("/edit/save") { |req, res| handle_edit_save(req, res) }
71
74
  server.mount_proc("/edit/chat") { |req, res| handle_edit_chat(req, res) }
72
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) }
73
79
  # ファイル操作 (右クリックメニュー): 削除 / 名前変更 / 追加。
74
80
  server.mount_proc("/edit/fs/delete") { |req, res| handle_edit_fs_delete(req, res) }
75
81
  server.mount_proc("/edit/fs/rename") { |req, res| handle_edit_fs_rename(req, res) }
@@ -270,7 +276,13 @@ module Chocomint
270
276
 
271
277
  # 一覧の表列。id は詳細リンク、trace_id はフィルタリンクにする。
272
278
  # content はレスポンスの返答要約 (text 冒頭 / tool_use:名前)。
273
- 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
274
286
 
275
287
  def llm_logs_html(rows, total:, page:, trace_id:)
276
288
  base = trace_id ? "/logs/llm?trace_id=#{CGI.escape(trace_id)}&" : "/logs/llm?"
@@ -303,6 +315,7 @@ module Chocomint
303
315
  th { background: #f5f5f5; position: sticky; top: 0; }
304
316
  td.no a, td.trace a { color: #1155cc; text-decoration: none; }
305
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; }
306
319
  td.content { white-space: normal; max-width: 28rem; color: #444; }
307
320
  tr:nth-child(even) { background: #fafafa; }
308
321
  .role-primary { color: #1155cc; font-weight: 600; }
@@ -319,7 +332,7 @@ module Chocomint
319
332
  <div class="table-wrap">
320
333
  <table>
321
334
  <thead>
322
- <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>
323
336
  </thead>
324
337
  <tbody>
325
338
  #{rows.empty? ? %(<tr><td colspan="#{LLM_COLUMNS.size}" class="empty">ペイロードはありません</td></tr>) : body_rows}
@@ -340,6 +353,7 @@ module Chocomint
340
353
  trace_cell = tid.empty? ? "(なし)" : %(<a href="/logs/llm?trace_id=#{CGI.escape(tid)}">#{CGI.escapeHTML(tid)}</a>)
341
354
  status = row["response_status"].nil? ? "-" : row["response_status"].to_s
342
355
  content = content_summary(row["response_body"])
356
+ usage = usage_tokens(row["response_body"])
343
357
  <<~ROW
344
358
  <tr>
345
359
  <td class="no">#{no_link}</td>
@@ -347,18 +361,49 @@ module Chocomint
347
361
  <td class="role-#{CGI.escapeHTML(role)}">#{CGI.escapeHTML(role)}</td>
348
362
  <td>#{CGI.escapeHTML(row['model'].to_s)}</td>
349
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>
350
367
  <td class="content">#{CGI.escapeHTML(content)}</td>
351
368
  <td>#{CGI.escapeHTML(row['created_at'].to_s)}</td>
352
369
  </tr>
353
370
  ROW
354
371
  end
355
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
+
356
400
  # ペイロード詳細ページ (リクエスト + レスポンス + ツール正常性判定)。
357
401
  def llm_detail_html(row, outcomes = [])
358
402
  role = row["role"].to_s
359
403
  tid = row["trace_id"].to_s
360
404
  trace_link = tid.empty? ? "(なし)" : %(<a href="/logs/llm?trace_id=#{CGI.escape(tid)}">#{CGI.escapeHTML(tid)}</a>)
361
405
  status = row["response_status"].nil? ? "(未記録)" : row["response_status"].to_s
406
+ usage = usage_tokens(row["response_body"])
362
407
  <<~HTML
363
408
  <!DOCTYPE html>
364
409
  <html lang="ja">
@@ -407,6 +452,7 @@ module Chocomint
407
452
  <span>model: #{CGI.escapeHTML(row['model'].to_s)}</span>
408
453
  <span>trace: #{trace_link}</span>
409
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>
410
456
  <span>#{CGI.escapeHTML(row['created_at'].to_s)}</span>
411
457
  </div>
412
458
  <div class="lbl">request messages(LLM への送信メッセージ)</div>
data/lib/chocomint.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chocomint
4
- VERSION = "1.1.0"
4
+ VERSION = "1.1.1"
5
5
  end
6
6
 
7
7
  require_relative "chocomint/errors"
data/public/edit/edit.css CHANGED
@@ -182,6 +182,13 @@ html, body { margin: 0; height: 100%;
182
182
  padding: 0.2rem 0.6rem; cursor: pointer; border-radius: 4px; }
183
183
  #statusbar-eol[hidden] { display: none; }
184
184
  #statusbar-eol:hover { background: rgba(255,255,255,0.15); }
185
+ /* モデル選択ボタン (改行コードの右)。現在の LLM モデル名を表示する。 */
186
+ #statusbar-model { display: flex; align-items: center; gap: 0.3rem; background: transparent;
187
+ border: none; color: inherit; font-size: inherit; padding: 0.2rem 0.6rem;
188
+ cursor: pointer; border-radius: 4px; max-width: 16rem; }
189
+ #statusbar-model:hover { background: rgba(255,255,255,0.15); }
190
+ .statusbar-model-icon { font-size: 0.8rem; }
191
+ #statusbar-model-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
185
192
  /* ブランチ切り替えダイアログの一覧。 */
186
193
  .branch-list { list-style: none; margin: 0 0 0.7rem; padding: 0; max-height: 12rem;
187
194
  overflow: auto; border: 1px solid #eee; border-radius: 6px; }
@@ -416,6 +423,12 @@ html, body { margin: 0; height: 100%;
416
423
  width: 1em; text-align: center; }
417
424
  /* AI 実行中のツール進捗行 (SSE)。実行中は点字スピナー、完了で ✓ / ✕ に変わる。 */
418
425
  .pending-head { color: #555; }
426
+ /* 実行中の経過時間 (例: 30s)。1 秒ごとに更新される。 */
427
+ .pending-elapsed { margin-left: 0.4rem; font-size: 0.78rem; color: #888;
428
+ font-variant-numeric: tabular-nums; }
429
+ /* 会話応答 (ツール実行なし) の下に添える実行時間。 */
430
+ .msg-elapsed { margin-top: 0.3rem; font-size: 0.72rem; color: #999;
431
+ font-variant-numeric: tabular-nums; }
419
432
  .pending-tools { display: flex; flex-direction: column; gap: 0.3rem;
420
433
  margin-top: 0.4rem; }
421
434
  .ptool { display: flex; align-items: center; gap: 0.4rem; font-size: 0.78rem;
@@ -424,6 +437,9 @@ html, body { margin: 0; height: 100%;
424
437
  .ptool-mark.ok { color: #137333; }
425
438
  .ptool-mark.ng { color: #c5221f; }
426
439
  .ptool-label { font-weight: 600; color: #333; }
440
+ /* 完了マーク / ツール名を監査ログへのリンクにしたとき (クリック可能を示す)。 */
441
+ .log-link { cursor: pointer; }
442
+ .log-link:hover { text-decoration: underline; }
427
443
  .ptool-target { font-family: "Cascadia Code", "Noto Sans JP", monospace;
428
444
  font-size: 0.72rem; background: #eef; color: #33407a;
429
445
  padding: 0.05rem 0.35rem; border-radius: 4px;
@@ -481,3 +497,19 @@ html, body { margin: 0; height: 100%;
481
497
  .vwin-btn.primary:hover { background: #82c3cb; }
482
498
  .vwin-btn.danger { background: #fce8e6; border-color: #f3c9c6; color: #c5221f; font-weight: 600; }
483
499
  .vwin-btn.danger:hover { background: #f8d7d4; }
500
+
501
+ /* モデル設定ダイアログ: provider タブ + 各 provider のペイン。 */
502
+ #model-dialog .vwin-body select {
503
+ width: 100%; box-sizing: border-box; padding: 0.4rem 0.5rem;
504
+ border: 1px solid #ccc; border-radius: 6px; font-size: 0.85rem;
505
+ font-family: "Cascadia Code", "Noto Sans JP", monospace; }
506
+ .model-tabs { display: flex; gap: 0.3rem; margin-bottom: 0.8rem; }
507
+ .model-tab { flex: 1; padding: 0.4rem 0.6rem; border: 1px solid #ccc; border-radius: 6px;
508
+ background: #f5f5f5; color: #555; font-size: 0.85rem; cursor: pointer; }
509
+ .model-tab:hover { background: #ececec; }
510
+ .model-tab.active { background: #a2d7dd; border-color: #82c3cb; color: #16393d; font-weight: 600; }
511
+ .model-pane[hidden] { display: none; }
512
+ .model-hint { font-size: 0.78rem; color: #666; margin: 0.5rem 0 0; line-height: 1.5; }
513
+ .model-hint a { color: #1155cc; }
514
+ .model-hint code { background: #eef; padding: 0.05rem 0.3rem; border-radius: 3px; }
515
+ .model-warn { color: #b06000; }
data/public/edit/edit.js CHANGED
@@ -1460,7 +1460,23 @@ function addPendingMsg() {
1460
1460
  sp.textContent = SPINNER_FRAMES[0];
1461
1461
  head.appendChild(sp);
1462
1462
  head.appendChild(document.createTextNode(' AI 考え中...'));
1463
+ // 経過時間をリアルタイム表示 (例: 30s)。1 秒ごとに更新する。
1464
+ const elapsed = document.createElement('span');
1465
+ elapsed.className = 'pending-elapsed';
1466
+ head.appendChild(elapsed);
1463
1467
  b.appendChild(head);
1468
+ const startedAt = Date.now();
1469
+ const updateElapsed = () => {
1470
+ elapsed.textContent = '(' + Math.floor((Date.now() - startedAt) / 1000) + 's)';
1471
+ };
1472
+ updateElapsed();
1473
+ const elapsedTimer = setInterval(updateElapsed, 1000);
1474
+ div._startedAt = startedAt;
1475
+ // 経過秒 (実行時間) を確定して返す。タイマーも止める (二重停止は無害)。
1476
+ div._finishElapsed = () => {
1477
+ clearInterval(elapsedTimer);
1478
+ return Math.round((Date.now() - startedAt) / 1000);
1479
+ };
1464
1480
  // ツール実行の進捗行を並べるコンテナ (SSE の tool_start / tool_done で更新)。
1465
1481
  const tools = document.createElement('div');
1466
1482
  tools.className = 'pending-tools';
@@ -1472,6 +1488,7 @@ function addPendingMsg() {
1472
1488
  // remove 時に回転対象からも外す (ツール行のスピナーも含めて)。
1473
1489
  const origRemove = div.remove.bind(div);
1474
1490
  div.remove = () => {
1491
+ clearInterval(elapsedTimer);
1475
1492
  spinnerTargets.delete(sp);
1476
1493
  tools.querySelectorAll('.pending-spinner').forEach(s => spinnerTargets.delete(s));
1477
1494
  origRemove();
@@ -1539,6 +1556,24 @@ function pendingToolDone(pending, ev) {
1539
1556
  chatLog.scrollTop = chatLog.scrollHeight;
1540
1557
  }
1541
1558
 
1559
+ // trace_id に対応する監査ログ (実行の詳細) の URL。エディタと同一オリジンで開ける。
1560
+ function traceLogHref(traceId) {
1561
+ return '/logs?trace_id=' + encodeURIComponent(traceId);
1562
+ }
1563
+
1564
+ // 完了マーク (✓/✕) とツール名を、その実行の監査ログへのリンクにする。
1565
+ // trace_id が判明した時点 (result 受信後) に呼ぶ。既にリンク済みなら href だけ更新する。
1566
+ function linkToolRow(row, traceId) {
1567
+ if (!traceId) return;
1568
+ const href = traceLogHref(traceId);
1569
+ const title = 'この実行の監査ログを開く';
1570
+ row.querySelectorAll('.ptool-mark, .ptool-label, .step-mark, .step-label').forEach(el => {
1571
+ el.classList.add('log-link');
1572
+ el.onclick = () => window.open(href, '_blank', 'noopener');
1573
+ el.title = title;
1574
+ });
1575
+ }
1576
+
1542
1577
  // 軽量 Markdown → HTML 変換 (外部ライブラリ非依存 / オフライン用)。
1543
1578
  // XSS 防止のため、まず全体を HTML エスケープしてから Markdown 記法だけを HTML 化する。
1544
1579
  function escapeHtml(s) {
@@ -1629,12 +1664,19 @@ function addUserMsg(text) {
1629
1664
  }
1630
1665
 
1631
1666
  // AI 発言: 枠なしの地の文 (ラベルなし)。回答は Markdown として描画する。
1632
- function addAiMsg(text) {
1667
+ // elapsedSec: 実行時間 ()。渡されれば発言の下に控えめに「実行時間 Ns」を添える。
1668
+ function addAiMsg(text, elapsedSec) {
1633
1669
  const div = document.createElement('div');
1634
1670
  div.className = 'msg ai';
1635
1671
  const b = document.createElement('div');
1636
1672
  b.className = 'bubble markdown';
1637
1673
  b.innerHTML = renderMarkdown(text);
1674
+ if (elapsedSec != null) {
1675
+ const meta = document.createElement('div');
1676
+ meta.className = 'msg-elapsed';
1677
+ meta.textContent = '実行時間 ' + elapsedSec + 's';
1678
+ b.appendChild(meta);
1679
+ }
1638
1680
  div.appendChild(b);
1639
1681
  chatLog.appendChild(div);
1640
1682
  chatLog.scrollTop = chatLog.scrollHeight;
@@ -1643,7 +1685,7 @@ function addAiMsg(text) {
1643
1685
 
1644
1686
  // 実行ステップ 1 件を「番号・ツールラベル・対象・成否・出力」の構造で描画する。
1645
1687
  // 生 JSON をそのまま出さず、人間が追える形にするのが目的。
1646
- function renderStep(s, no) {
1688
+ function renderStep(s, no, traceId) {
1647
1689
  const st = document.createElement('div');
1648
1690
  st.className = 'step' + (s.status === 'PASS' ? '' : ' failed');
1649
1691
 
@@ -1677,6 +1719,9 @@ function renderStep(s, no) {
1677
1719
 
1678
1720
  st.appendChild(head);
1679
1721
 
1722
+ // trace_id があれば、完了マークとツール名をこの実行の監査ログへのリンクにする。
1723
+ if (traceId) linkToolRow(st, traceId);
1724
+
1680
1725
  // 出力は改行を保った等幅ブロックで表示。空なら控えめに「(出力なし)」。
1681
1726
  const out = document.createElement('code');
1682
1727
  out.className = 'out';
@@ -1691,9 +1736,10 @@ function renderStep(s, no) {
1691
1736
  }
1692
1737
 
1693
1738
  // AI の実行結果 (ステータスバッジ + 返信 + 各ツールの出力) を描画する。
1694
- function renderAiResult(data) {
1739
+ // elapsedSec: 実行時間 ()。渡されればバッジに「実行時間 Ns」を添える。
1740
+ function renderAiResult(data, elapsedSec) {
1695
1741
  // 会話応答 (ツール実行なし) は自然文だけを地の文として表示する。
1696
- if (data.reply) return addAiMsg(data.reply);
1742
+ if (data.reply) return addAiMsg(data.reply, elapsedSec);
1697
1743
 
1698
1744
  const status = data.status || 'FAIL';
1699
1745
  const div = document.createElement('div');
@@ -1705,7 +1751,9 @@ function renderAiResult(data) {
1705
1751
  badge.className = 'badge';
1706
1752
  const statusText = status === 'PASS' ? '✓ 完了' : '✕ 失敗';
1707
1753
  // attempts は「LLM への試行回数」。1 回なら冗長なので添えない。
1708
- badge.textContent = statusText + (data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '');
1754
+ badge.textContent = statusText +
1755
+ (data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '') +
1756
+ (elapsedSec != null ? ' · 実行時間 ' + elapsedSec + 's' : '');
1709
1757
  b.appendChild(badge);
1710
1758
 
1711
1759
  // AI による実行結果の要約 (Markdown)。生のツール出力より上に目立たせて表示。
@@ -1734,7 +1782,7 @@ function renderAiResult(data) {
1734
1782
  if (steps.length) {
1735
1783
  const wrap = document.createElement('div');
1736
1784
  wrap.className = 'steps';
1737
- steps.forEach((s, i) => wrap.appendChild(renderStep(s, i + 1)));
1785
+ steps.forEach((s, i) => wrap.appendChild(renderStep(s, i + 1, data.trace_id)));
1738
1786
  // 要約があるときは実行の詳細を折りたたむ (まとめを主役にする)。
1739
1787
  if (data.summary) {
1740
1788
  const det = document.createElement('details');
@@ -1770,7 +1818,8 @@ function clearChat() {
1770
1818
  }
1771
1819
 
1772
1820
  // 現在のチャット表示テキストを AI に要約させ、ログを短い要約 1 件に畳み込む。
1773
- // サーバー側は会話履歴を保持しないため、これは表示の圧縮のみ (AI の文脈には影響しない)。
1821
+ // 履歴は表示ログから毎回組み立てて送るため、これでログを畳めば次回送信の
1822
+ // history も要約後の短い文脈に置き換わる (肥大化を防ぐ)。
1774
1823
  async function compactChat() {
1775
1824
  const transcript = collectTranscript();
1776
1825
  if (!transcript.trim()) { clearChat(); return; }
@@ -1812,6 +1861,20 @@ function collectTranscript() {
1812
1861
  return lines.join('\n');
1813
1862
  }
1814
1863
 
1864
+ // これまでのチャットログを /edit/chat に渡す履歴配列に書き出す。
1865
+ // サーバーは会話履歴を保持しないため、送信のたびに表示済みログから組み立てて送る。
1866
+ function collectChatHistory() {
1867
+ const history = [];
1868
+ chatLog.querySelectorAll('.msg').forEach(m => {
1869
+ // pending (AI 考え中) 行はまだ確定した発言ではないので含めない。
1870
+ if (m.querySelector('.pending-head')) return;
1871
+ const role = m.classList.contains('user') ? 'user' : 'assistant';
1872
+ const text = (m.textContent || '').replace(/\s+/g, ' ').trim();
1873
+ if (text) history.push({ role, content: text });
1874
+ });
1875
+ return history;
1876
+ }
1877
+
1815
1878
  // /compact の結果 (これまでの会話のまとめ) を専用の見出し付きで表示する。
1816
1879
  function addCompactSummary(text) {
1817
1880
  const div = document.createElement('div');
@@ -1855,11 +1918,19 @@ function finalizePending(pending, data) {
1855
1918
  const badge = document.createElement('span');
1856
1919
  badge.className = 'badge';
1857
1920
  const statusText = status === 'PASS' ? '✓ 完了' : '✕ 失敗';
1858
- badge.textContent = statusText + (data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '');
1921
+ const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
1922
+ badge.textContent = statusText +
1923
+ (data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '') +
1924
+ (elapsedSec != null ? ' · 実行時間 ' + elapsedSec + 's' : '');
1859
1925
  if (head) head.replaceWith(badge); else b.insertBefore(badge, b.firstChild);
1860
1926
 
1861
- // 要約 (Markdown) はツール一覧の上に目立たせて置く。
1927
+ // trace_id が判明したので、各ツール行 (完了マーク + ツール名) を監査ログへのリンクにする。
1862
1928
  const tools = pending._toolsEl;
1929
+ if (data.trace_id && tools) {
1930
+ tools.querySelectorAll('.ptool').forEach(row => linkToolRow(row, data.trace_id));
1931
+ }
1932
+
1933
+ // 要約 (Markdown) はツール一覧の上に目立たせて置く。
1863
1934
  if (data.summary) {
1864
1935
  const sum = document.createElement('div');
1865
1936
  sum.className = 'summary markdown';
@@ -1895,8 +1966,10 @@ function handleChatEvent(pending, evName, dataStr) {
1895
1966
  // 会話応答 (reply) はツール実行が無いので、pending を消して地の文で表示。
1896
1967
  // ツール実行があった場合は、進捗表示を消さずにその場で仕上げる。
1897
1968
  if (data.reply || !pending._hadTool) {
1969
+ // pending 破棄前に実行時間を確定して結果表示へ渡す。
1970
+ const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
1898
1971
  pending.remove();
1899
- renderAiResult(data);
1972
+ renderAiResult(data, elapsedSec);
1900
1973
  } else {
1901
1974
  finalizePending(pending, data);
1902
1975
  }
@@ -1906,8 +1979,9 @@ function handleChatEvent(pending, evName, dataStr) {
1906
1979
  if (pending._hadTool) {
1907
1980
  finalizePending(pending, { status: 'FAIL', error: data.error || 'エラー' });
1908
1981
  } else {
1982
+ const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
1909
1983
  pending.remove();
1910
- renderAiResult({ status: 'FAIL', error: data.error || 'エラー' });
1984
+ renderAiResult({ status: 'FAIL', error: data.error || 'エラー' }, elapsedSec);
1911
1985
  }
1912
1986
  }
1913
1987
  }
@@ -1917,6 +1991,8 @@ async function sendChat() {
1917
1991
  if (!instruction) return;
1918
1992
  // /clear /compact はローカルコマンド (AI へは送らない)。
1919
1993
  if (handleSlashCommand(instruction)) return;
1994
+ // 今回の発言を DOM に足す前に、これまでのログを履歴として集めておく。
1995
+ const history = collectChatHistory();
1920
1996
  chatInput.value = '';
1921
1997
  addUserMsg(instruction);
1922
1998
  chatForm.classList.add('busy');
@@ -1926,13 +2002,14 @@ async function sendChat() {
1926
2002
  try {
1927
2003
  const r = await fetch('/edit/chat', {
1928
2004
  method: 'POST', headers: { 'content-type': 'application/json' },
1929
- body: JSON.stringify({ path: currentPath || '', instruction, stream: true })
2005
+ body: JSON.stringify({ path: currentPath || '', instruction, history, stream: true })
1930
2006
  });
1931
2007
  if (!r.ok || !r.body) {
1932
2008
  // SSE を開始できなかった場合は本文を JSON として読んでフォールバック。
1933
2009
  const data = await r.json().catch(() => ({ status: 'FAIL', error: r.status + ' error' }));
2010
+ const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
1934
2011
  pending.remove();
1935
- renderAiResult(data);
2012
+ renderAiResult(data, elapsedSec);
1936
2013
  applyResultSideEffects(data);
1937
2014
  return;
1938
2015
  }
@@ -1957,8 +2034,9 @@ async function sendChat() {
1957
2034
  }
1958
2035
  }
1959
2036
  } catch (err) {
2037
+ const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
1960
2038
  pending.remove();
1961
- renderAiResult({ status: 'FAIL', error: 'エラー: ' + err.message });
2039
+ renderAiResult({ status: 'FAIL', error: 'エラー: ' + err.message }, elapsedSec);
1962
2040
  } finally {
1963
2041
  stopSpinner();
1964
2042
  chatForm.classList.remove('busy');
@@ -2518,6 +2596,119 @@ remoteUrlInput.addEventListener('keydown', (e) => {
2518
2596
  });
2519
2597
  initDraggableWindow(remoteDialog);
2520
2598
 
2599
+ // ---- モデル選択 (ステータスバー) ----
2600
+ const modelDialog = document.getElementById('model-dialog');
2601
+ const modelNameEl = document.getElementById('statusbar-model-name');
2602
+ const modelOllamaSelect = document.getElementById('model-ollama-select');
2603
+ const modelOllamaEmpty = document.getElementById('model-ollama-empty');
2604
+ const modelOpenrouterInput = document.getElementById('model-openrouter-input');
2605
+ const modelOpenrouterReady = document.getElementById('model-openrouter-ready');
2606
+ const modelOpenrouterNokey = document.getElementById('model-openrouter-nokey');
2607
+ const modelError = document.getElementById('model-error');
2608
+ // ダイアログ内で選択中の provider ("ollama" | "openrouter")。
2609
+ let modelSelectedProvider = 'ollama';
2610
+ // /edit/models の直近の応答 (現在値と選択肢) を保持する。
2611
+ let modelInfo = null;
2612
+
2613
+ // ステータスバーのモデル名を更新する (起動時と切り替え成功時に呼ぶ)。
2614
+ function updateModelStatus(provider, model) {
2615
+ if (!modelNameEl) return;
2616
+ modelNameEl.textContent = model || 'モデル未設定';
2617
+ const btn = document.getElementById('statusbar-model');
2618
+ if (btn) btn.title = `AI モデルを選択 (現在: ${provider} / ${model || '未設定'})`;
2619
+ }
2620
+
2621
+ // 起動時に現在のモデルを取得してステータスバーに表示する。
2622
+ async function initModelStatus() {
2623
+ try {
2624
+ modelInfo = await gitFetch('/edit/models');
2625
+ updateModelStatus(modelInfo.provider, modelInfo.model);
2626
+ } catch {
2627
+ // 取得に失敗しても致命的ではない。"—" 表示のままにする。
2628
+ }
2629
+ }
2630
+
2631
+ // provider タブを切り替えて対応ペインを表示する。
2632
+ function selectModelProvider(provider) {
2633
+ modelSelectedProvider = provider;
2634
+ modelError.textContent = '';
2635
+ document.getElementById('model-tab-ollama').classList.toggle('active', provider === 'ollama');
2636
+ document.getElementById('model-tab-openrouter').classList.toggle('active', provider === 'openrouter');
2637
+ document.getElementById('model-pane-ollama').hidden = provider !== 'ollama';
2638
+ document.getElementById('model-pane-openrouter').hidden = provider !== 'openrouter';
2639
+ }
2640
+
2641
+ async function openModelDialog() {
2642
+ modelError.textContent = '';
2643
+ // 開くたびに最新の情報 (インストール済みモデル・OpenRouter 可否) を取り直す。
2644
+ try {
2645
+ modelInfo = await gitFetch('/edit/models');
2646
+ } catch (err) {
2647
+ modelInfo = modelInfo || { provider: 'ollama', model: '', ollama_models: [], openrouter_available: false };
2648
+ }
2649
+ updateModelStatus(modelInfo.provider, modelInfo.model);
2650
+
2651
+ // Ollama: インストール済みモデルを select に流し込み、現在値を選択する。
2652
+ modelOllamaSelect.innerHTML = '';
2653
+ const models = modelInfo.ollama_models || [];
2654
+ models.forEach(name => {
2655
+ const opt = document.createElement('option');
2656
+ opt.value = name; opt.textContent = name;
2657
+ modelOllamaSelect.appendChild(opt);
2658
+ });
2659
+ modelOllamaEmpty.hidden = models.length > 0;
2660
+ modelOllamaSelect.hidden = models.length === 0;
2661
+ if (modelInfo.provider === 'ollama' && modelInfo.model && models.includes(modelInfo.model)) {
2662
+ modelOllamaSelect.value = modelInfo.model;
2663
+ }
2664
+
2665
+ // OpenRouter: キーがあれば入力欄、無ければ案内を表示する。
2666
+ const orAvailable = !!modelInfo.openrouter_available;
2667
+ modelOpenrouterReady.hidden = !orAvailable;
2668
+ modelOpenrouterNokey.hidden = orAvailable;
2669
+ modelOpenrouterInput.value = modelInfo.provider === 'openrouter' ? (modelInfo.model || '') : '';
2670
+
2671
+ selectModelProvider(modelInfo.provider || 'ollama');
2672
+ centerWindow(modelDialog);
2673
+ }
2674
+
2675
+ function closeModelDialog() { modelDialog.hidden = true; }
2676
+
2677
+ async function applyModel() {
2678
+ modelError.textContent = '';
2679
+ let model = '';
2680
+ if (modelSelectedProvider === 'ollama') {
2681
+ model = modelOllamaSelect.value;
2682
+ if (!model) { modelError.textContent = 'モデルを選択してください'; return; }
2683
+ } else {
2684
+ model = modelOpenrouterInput.value.trim();
2685
+ if (!model) { modelError.textContent = 'モデル ID を入力してください'; return; }
2686
+ }
2687
+ const okBtn = document.getElementById('model-ok');
2688
+ okBtn.disabled = true;
2689
+ try {
2690
+ const data = await gitPost('/edit/model', { provider: modelSelectedProvider, model });
2691
+ updateModelStatus(data.provider, data.model);
2692
+ closeModelDialog();
2693
+ } catch (err) {
2694
+ modelError.textContent = err.message;
2695
+ } finally {
2696
+ okBtn.disabled = false;
2697
+ }
2698
+ }
2699
+
2700
+ document.getElementById('statusbar-model').onclick = () => openModelDialog();
2701
+ document.getElementById('model-tab-ollama').onclick = () => selectModelProvider('ollama');
2702
+ document.getElementById('model-tab-openrouter').onclick = () => selectModelProvider('openrouter');
2703
+ document.getElementById('model-ok').onclick = () => applyModel();
2704
+ document.getElementById('model-cancel').onclick = closeModelDialog;
2705
+ document.getElementById('model-close').onclick = closeModelDialog;
2706
+ modelOpenrouterInput.addEventListener('keydown', (e) => {
2707
+ if (e.key === 'Enter') { e.preventDefault(); applyModel(); }
2708
+ else if (e.key === 'Escape') { e.preventDefault(); closeModelDialog(); }
2709
+ });
2710
+ initDraggableWindow(modelDialog);
2711
+
2521
2712
  // ---- 起動 ----
2522
2713
  refreshTree();
2523
2714
  initConsole();
@@ -2527,6 +2718,7 @@ initPanelView();
2527
2718
  initFilesTab();
2528
2719
  setAutoSave(autoSaveEnabled); // メニューのチェック表示を保存済み設定に合わせる
2529
2720
  refreshGitPanel();
2721
+ initModelStatus();
2530
2722
 
2531
2723
  // 自動保存 (debounce) が確定する前にタブを閉じようとした場合に警告する。
2532
2724
  window.addEventListener('beforeunload', (e) => {
@@ -146,6 +146,10 @@
146
146
  <button id="statusbar-eol" type="button" title="改行コードを切り替え" hidden>
147
147
  <span id="statusbar-eol-name">LF</span>
148
148
  </button>
149
+ <!-- モデル選択。現在の LLM モデル名を表示し、クリックでモデル設定ダイアログを開く。 -->
150
+ <button id="statusbar-model" type="button" title="AI モデルを選択">
151
+ <span class="statusbar-model-icon">◇</span><span id="statusbar-model-name">—</span>
152
+ </button>
149
153
  </div>
150
154
  </div>
151
155
  <div id="ctx-menu"></div>
@@ -250,6 +254,53 @@
250
254
  </div>
251
255
  </div>
252
256
 
257
+ <!-- モデル設定ダイアログ。ステータスバーのモデル名クリックで開く。
258
+ Ollama はインストール済みモデルから選択、OpenRouter はモデル ID を手入力する。-->
259
+ <div id="model-dialog" class="vwin" hidden>
260
+ <div class="vwin-titlebar">
261
+ <span class="vwin-title">AI モデル設定</span>
262
+ <button type="button" class="vwin-close" id="model-close" title="キャンセル" aria-label="キャンセル">×</button>
263
+ </div>
264
+ <div class="vwin-body">
265
+ <div class="model-tabs" role="tablist">
266
+ <button type="button" class="model-tab" id="model-tab-ollama" data-provider="ollama" role="tab">Ollama</button>
267
+ <button type="button" class="model-tab" id="model-tab-openrouter" data-provider="openrouter" role="tab">OpenRouter</button>
268
+ </div>
269
+
270
+ <!-- Ollama: インストール済みモデルから選ぶ。-->
271
+ <div class="model-pane" id="model-pane-ollama">
272
+ <label for="model-ollama-select">インストール済みモデル</label>
273
+ <select id="model-ollama-select"></select>
274
+ <p class="model-hint" id="model-ollama-empty" hidden>
275
+ Ollama に接続できないか、モデルが 1 つもインストールされていません。
276
+ </p>
277
+ </div>
278
+
279
+ <!-- OpenRouter: API キーがあればモデル ID を手入力。無ければ案内のみ表示。-->
280
+ <div class="model-pane" id="model-pane-openrouter" hidden>
281
+ <div id="model-openrouter-ready" hidden>
282
+ <label for="model-openrouter-input">モデル ID</label>
283
+ <input type="text" id="model-openrouter-input" autocomplete="off"
284
+ placeholder="例: anthropic/claude-3.5-sonnet">
285
+ <p class="model-hint">
286
+ OpenRouter のモデル ID を入力してください
287
+ (<a href="https://openrouter.ai/models" target="_blank" rel="noopener">モデル一覧</a>)。
288
+ </p>
289
+ </div>
290
+ <p class="model-hint model-warn" id="model-openrouter-nokey" hidden>
291
+ 環境変数 <code>OPENROUTER_API_KEY</code> が設定されていません。
292
+ 設定してからサーバーを再起動すると OpenRouter が使えるようになります。
293
+ </p>
294
+ </div>
295
+
296
+ <div class="vwin-error" id="model-error"></div>
297
+ </div>
298
+ <div class="vwin-actions">
299
+ <button type="button" class="vwin-btn" id="model-cancel">キャンセル</button>
300
+ <button type="button" class="vwin-btn primary" id="model-ok">このモデルを使う</button>
301
+ </div>
302
+ </div>
303
+
253
304
  <script src="/edit/assets/xterm/xterm.js"></script>
254
305
  <script src="/edit/assets/xterm/addon-fit.js"></script>
255
306
  <script>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chocomint
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ひかり (Himeyama)