chocomint 1.1.0 → 1.1.2

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: e8b684c264c0c8d2449165ef471d439f3816b59732464d1a80d773d65327838b
4
+ data.tar.gz: c9a98fd3654496f6db1c634771e81a05093ffd21af91e90e7e95c12bc3d8fb4f
5
5
  SHA512:
6
- metadata.gz: 75558e5f77a42aaaddaa2ffdfc5a509975cf7660ae5eaf346d9e5e24d45ce3873783f23e924b5516a6341db2f405e3ac1bd3688bf7677cc9fe0ec0074a5a352c
7
- data.tar.gz: 0b43f17a5b4f916798db9107c3cef61d21f07da1299723a3feb1e31de43231f521bfc8e4bdfc560ff6dd1b65f9546819b61fb79e9263c90ef89796984cac48c9
6
+ metadata.gz: a9909618b16c7063b1d98b72a61bf666db38081bdd712a46595c20a97434c2087f30f265658a4b99ef614f9d38d391a9e2889866bbd406a41efb03f83a4ac526
7
+ data.tar.gz: 2771ba70535d038cc64987496ea410c4da908af05fd61147f05af15c0492a51ee7b717564aa2b748d75edd8af96d2c4b5ea77091af82d115880655cf5e45efd9
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:
@@ -118,6 +125,38 @@ Invoke-RestMethod -Uri http://127.0.0.1:9210/v1/messages -Method Post `
118
125
  bundle exec rspec
119
126
  ```
120
127
 
128
+ ## リリース(gem 公開)
129
+
130
+ RubyGems に公開済み。バージョンは `lib/chocomint.rb` の `Chocomint::VERSION`
131
+ 1 か所で定義し、gemspec がそれを参照する。
132
+
133
+ ```powershell
134
+ # 1. バージョンを更新(lib/chocomint.rb の VERSION を書き換える)
135
+ # 例: "1.1.1" -> "1.1.2"
136
+
137
+ # 2. 更新をコミット
138
+ git add lib/chocomint.rb
139
+ git commit -m 'release: vX.Y.Z'
140
+
141
+ # 3. gem をビルド
142
+ gem build chocomint.gemspec # -> chocomint-X.Y.Z.gem が生成される
143
+
144
+ # 4. RubyGems へ push(要認証 + OTP。下記の注意を参照)
145
+ gem push chocomint-X.Y.Z.gem
146
+ ```
147
+
148
+ > **push には認証と OTP が必要**: gemspec で `rubygems_mfa_required = true` を
149
+ > 設定しているため、`gem push` には RubyGems の認証(`gem signin` か環境変数
150
+ > `GEM_HOST_API_KEY`)と、多要素認証の OTP コード入力が必要。この操作は対話が
151
+ > 発生するため手元で実行すること。
152
+
153
+ > ⚠️ **公開前のエンドツーエンド検証**: 実行ファイル(`bin/*`)では
154
+ > `$PROGRAM_NAME == __FILE__` ガードを**使わない**。RubyGems の実行ラッパーは
155
+ > 本体を `load` 経由で呼ぶため、このガードがあると gem インストール経由では
156
+ > `main` が発動せず「何もせず終了」する不具合になる。`ruby bin/xxx` の直接実行
157
+ > だけでは見逃すため、公開前後はソースツリー外で `gem install` した実体
158
+ > (`.bat` ラッパー経由)で必ず動作確認する。
159
+
121
160
  ## 監査ログ
122
161
 
123
162
  全試行が `logs/execution.db` (SQLite) の `executions` テーブルに記録される(DESIGN §12)。
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 して使う)。
@@ -268,8 +269,14 @@ module Chocomint
268
269
 
269
270
  head = raw.byteslice(0, BINARY_SNIFF_BYTES)
270
271
  return true if head.include?("\x00")
272
+ return false if head.dup.force_encoding("UTF-8").valid_encoding?
271
273
 
272
- !head.dup.force_encoding("UTF-8").valid_encoding?
274
+ # 先頭ブロックが有効な UTF-8 でないとき、それが「末尾のマルチバイト文字が
275
+ # スニフ境界 (8192B) で途中まで切れた」だけなら、テキストを誤ってバイナリと
276
+ # みなさない。scrub で不正バイトを除いた結果が末尾数バイトの欠落だけであれば
277
+ # (=不正箇所が末尾に限られる) テキストとして扱う。UTF-8 の 1 文字は最大 4 バイト。
278
+ scrubbed = head.dup.force_encoding("UTF-8").scrub("")
279
+ head.bytesize - scrubbed.bytesize > 3
273
280
  end
274
281
 
275
282
  # ---- 保存 (人手編集) ----------------------------------------------------
@@ -307,18 +314,16 @@ module Chocomint
307
314
  body = JSON.parse(req.body.to_s)
308
315
  instruction = body["instruction"].to_s.strip
309
316
  path = body["path"].to_s
317
+ history = parse_chat_history(body["history"])
310
318
  return edit_json(res, 400, "error" => "instruction required") if instruction.empty?
311
319
 
312
320
  # stream=true なら SSE でツール実行の進捗を逐次配信する (UI のリアルタイム表示用)。
313
- return handle_edit_chat_stream(res, instruction, path) if body["stream"]
314
-
315
- # ツール不要の普通の質問なら会話として直接応答する。それ以外は Planner に流す。
316
- if chat_instruction?(instruction)
317
- return edit_json(res, 200, edit_chat_reply(instruction, path))
318
- end
321
+ return handle_edit_chat_stream(res, instruction, path, history) if body["stream"]
319
322
 
323
+ # 全指示を Planner に流し、必要なツールを自律的に実行させて解決する。
324
+ # 純粋な会話・情報不足の聞き返しは、Planner がツールを使わず finish で応答する。
320
325
  request = build_chat_request(path, instruction)
321
- result = @planner.run(request, expectations: instruction)
326
+ result = @planner.run(request, expectations: instruction, history: history)
322
327
  edit_json(res, 200, edit_chat_result(result, path, instruction))
323
328
  rescue JSON::ParserError => e
324
329
  edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
@@ -333,7 +338,7 @@ module Chocomint
333
338
  # SSE 版のチャット処理。Planner のステップ進捗を逐次 event として流し、
334
339
  # 最後に従来の JSON 結果を "result" event として送って締める。
335
340
  # イベント: tool_start / tool_done / result / error (data は JSON 1 行)。
336
- def handle_edit_chat_stream(res, instruction, path)
341
+ def handle_edit_chat_stream(res, instruction, path, history)
337
342
  res.status = 200
338
343
  res["content-type"] = "text/event-stream; charset=utf-8"
339
344
  res["cache-control"] = "no-cache"
@@ -341,7 +346,7 @@ module Chocomint
341
346
  res.body = lambda do |out|
342
347
  emit_sse = ->(event, data) { out.write("event: #{event}\ndata: #{JSON.generate(deep_scrub(data))}\n\n") }
343
348
  begin
344
- run_chat_stream(instruction, path, emit_sse)
349
+ run_chat_stream(instruction, path, history, emit_sse)
345
350
  rescue Chocomint::InvalidProposalError, Chocomint::UnknownToolError,
346
351
  Chocomint::RetryLimitExceededError => e
347
352
  emit_sse.call("result", "status" => "FAIL", "error" => humanize_chat_error(e), "steps" => [])
@@ -352,13 +357,9 @@ module Chocomint
352
357
  end
353
358
 
354
359
  # SSE 本体: 会話 or Planner 実行を行い、進捗と最終結果を emit_sse で送る。
355
- def run_chat_stream(instruction, path, emit_sse)
356
- # 会話 (ツール不要) は進捗が無いので、そのまま最終結果だけ送る。
357
- if chat_instruction?(instruction)
358
- emit_sse.call("result", edit_chat_reply(instruction, path))
359
- return
360
- end
361
-
360
+ def run_chat_stream(instruction, path, history, emit_sse)
361
+ # 全指示を Planner に流す。ツールを使わない会話・聞き返しは、Planner が
362
+ # ツール実行なし (進捗イベントなし) で finish し、最終 result だけが届く。
362
363
  request = build_chat_request(path, instruction)
363
364
  on_event = lambda do |ev|
364
365
  case ev[:type]
@@ -384,7 +385,7 @@ module Chocomint
384
385
  end
385
386
  end
386
387
 
387
- result = @planner.run(request, expectations: instruction, on_event: on_event)
388
+ result = @planner.run(request, expectations: instruction, on_event: on_event, history: history)
388
389
  emit_sse.call("result", edit_chat_result(result, path, instruction))
389
390
  end
390
391
 
@@ -406,8 +407,9 @@ module Chocomint
406
407
  # ---- 会話ログの圧縮 (/compact) -----------------------------------------
407
408
  #
408
409
  # ブラウザから現在のチャット表示テキストを受け取り、AI に要点を要約させて返す。
409
- # サーバー側は会話履歴を保持しないため、これは「表示ログを短い要約 1 件に畳む」
410
- # ための機能 (AI の文脈には元々影響しない)。chat_client 未注入なら 503。
410
+ # 会話履歴はブラウザ側 (表示ログ) にのみ保持され、/edit/chat 送信時に毎回
411
+ # history として渡される。この要約 1 件でログを畳めば、以後の history にも
412
+ # 圧縮済みの文脈として引き継がれる。chat_client 未注入なら 503。
411
413
 
412
414
  # 要約に渡すログの上限 (これを超える古い部分は末尾を優先して切り詰める)。
413
415
  COMPACT_INPUT_LIMIT = 12_000
@@ -433,43 +435,84 @@ module Chocomint
433
435
  edit_json(res, 500, "error" => e.message)
434
436
  end
435
437
 
436
- # chat_client があり、指示が「会話」と分類されたときだけ会話として扱う。
437
- # chat_client 未注入なら常に false (従来どおり全て Planner に流す)。
438
- def chat_instruction?(instruction)
439
- return false unless @chat_client
440
-
441
- @chat_client.classify(instruction) == "chat"
442
- rescue Chocomint::Error
443
- # 分類に失敗したら安全側でタスク扱い (Planner に流す)。
444
- false
438
+ # ---- モデル選択 (ステータスバー) ---------------------------------------
439
+ #
440
+ # ステータスバーのモデルボタンから、現在使っているモデルと選択肢を取得し、
441
+ # 別のモデル (Ollama のローカルモデル / OpenRouter のモデル) に切り替える。
442
+ # config 未注入 (テスト等) なら 503 を返す。
443
+
444
+ # 現在の provider / model と、選択肢 (Ollama のインストール済みモデル一覧、
445
+ # OpenRouter が利用可能かどうか) を返す。
446
+ # GET /edit/models
447
+ def handle_edit_models(req, res)
448
+ return edit_method_guard(res) unless req.request_method == "GET"
449
+ return edit_json(res, 503, "error" => "config not available") unless @config
450
+
451
+ openrouter_key = ENV["OPENROUTER_API_KEY"].to_s
452
+ edit_json(res, 200,
453
+ "provider" => @config.llm_provider,
454
+ "model" => @config.llm_model,
455
+ "ollama_models" => ollama_model_names,
456
+ "openrouter_available" => !openrouter_key.empty?)
457
+ rescue Chocomint::Error => e
458
+ edit_json(res, 500, "error" => e.message)
445
459
  end
446
460
 
447
- # 会話 (普通の質問) への直接応答を UI 向け JSON に整形する。
448
- # 開いているファイルがあれば内容を文脈として渡す。
449
- def edit_chat_reply(instruction, path)
450
- answer = @chat_client.answer(instruction, context: chat_file_context(path))
451
- { "status" => "PASS", "reply" => answer, "steps" => [] }
461
+ # provider / model を切り替え、Planner・chat_client を再構築して config.yml に保存する。
462
+ # POST /edit/model { "provider": "ollama"|"openrouter", "model": "..." }
463
+ def handle_edit_model(req, res)
464
+ return edit_method_guard(res) unless req.request_method == "POST"
465
+ return edit_json(res, 503, "error" => "config not available") unless @config
466
+
467
+ body = JSON.parse(req.body.to_s)
468
+ provider = body["provider"].to_s
469
+ model = body["model"].to_s.strip
470
+ return edit_json(res, 400, "error" => "provider required") unless %w[ollama openrouter].include?(provider)
471
+ return edit_json(res, 400, "error" => "model required") if model.empty?
472
+
473
+ # OpenRouter は API キーが環境変数に無いと呼べないので、切り替え前に確認する。
474
+ if provider == "openrouter" && ENV["OPENROUTER_API_KEY"].to_s.empty?
475
+ return edit_json(res, 400,
476
+ "error" => "環境変数 OPENROUTER_API_KEY が設定されていません。" \
477
+ "設定してからサーバーを再起動してください。")
478
+ end
479
+
480
+ @config.set_llm!(provider: provider, model: model)
481
+ rebuild_llm!
482
+ @config.persist_llm!
483
+
484
+ edit_json(res, 200, "provider" => @config.llm_provider, "model" => @config.llm_model)
485
+ rescue JSON::ParserError => e
486
+ edit_json(res, 400, "error" => "invalid JSON: #{e.message}")
452
487
  rescue Chocomint::Error => e
453
- { "status" => "FAIL", "error" => e.message, "steps" => [] }
488
+ edit_json(res, 500, "error" => e.message)
454
489
  end
455
490
 
456
- # 会話の文脈として渡すファイル内容 (先頭のみ)。開いていない/読めない場合は nil。
457
- CHAT_CONTEXT_LIMIT = 4000
458
-
459
- def chat_file_context(path)
460
- return nil if path.empty?
491
+ # 変更後の @config で Planner と chat_client を作り直し、以後のリクエストに反映する。
492
+ def rebuild_llm!
493
+ @planner = Chocomint::Factory.build(@config, base_dir: @base_dir)
494
+ @chat_client = Chocomint::Factory.build_chat_client(@config, base_dir: @base_dir)
495
+ end
461
496
 
462
- abs = @path_guard.resolve(path)
463
- return nil unless File.file?(abs)
497
+ # Ollama にインストール済みのモデル名一覧を取得する。Ollama ネイティブ API の
498
+ # /api/tags を叩く。現在の provider が OpenRouter でも Ollama タブの候補は
499
+ # Ollama から取るため、config.ollama_api_base (provider 非依存) を使う。
500
+ # 取得失敗時は空配列を返す (UI では「Ollama に接続できません」等の空表示になる)。
501
+ def ollama_model_names
502
+ base = @config.ollama_api_base
503
+ conn = Faraday.new { |f| f.options.timeout = 5; f.options.open_timeout = 5 }
504
+ response = conn.get("#{base}/api/tags")
505
+ return [] unless response.success?
464
506
 
465
- content = File.read(abs, mode: "rb").force_encoding("UTF-8")
466
- content = "#{content[0, CHAT_CONTEXT_LIMIT]}…" if content.length > CHAT_CONTEXT_LIMIT
467
- "現在開いているファイル #{path} の内容:\n```\n#{content}\n```"
468
- rescue Chocomint::Error
469
- nil
507
+ data = JSON.parse(response.body)
508
+ Array(data["models"]).filter_map { |m| m["name"] if m.is_a?(Hash) }.sort
509
+ rescue Faraday::Error, JSON::ParserError
510
+ []
470
511
  end
471
512
 
472
513
  # 対象ファイルがあれば文脈として明示し、無ければ指示だけを渡す。
514
+ # 過去のやり取りは history として別途 messages の先頭に user/assistant のまま積むため
515
+ # (chat_client#answer / Planner#run 経由)、ここでは今回の指示だけを組み立てる。
473
516
  def build_chat_request(path, instruction)
474
517
  if path.empty?
475
518
  instruction
@@ -478,6 +521,25 @@ module Chocomint
478
521
  end
479
522
  end
480
523
 
524
+ # HTTP リクエストの history (JSON 配列) を chat_client / Planner へ渡せる
525
+ # [{ "role" => "user"/"assistant", "content" => String }, ...] に正規化する。
526
+ # 不正な要素は無視する (壊れた履歴で応答全体を失敗させないため)。
527
+ CHAT_HISTORY_LIMIT = 20
528
+
529
+ def parse_chat_history(raw)
530
+ return [] unless raw.is_a?(Array)
531
+
532
+ raw.filter_map do |entry|
533
+ next unless entry.is_a?(Hash)
534
+
535
+ role = entry["role"].to_s
536
+ content = entry["content"].to_s
537
+ next if content.empty? || !%w[user assistant].include?(role)
538
+
539
+ { "role" => role, "content" => content }
540
+ end.last(CHAT_HISTORY_LIMIT)
541
+ end
542
+
481
543
  # Planner::Result を UI 向けの JSON に整形する。編集後内容も返して Monaco を更新する。
482
544
  # instruction: 要約生成のための元の要求文 (任意)。
483
545
  def edit_chat_result(result, path, instruction = nil)
@@ -570,26 +632,10 @@ module Chocomint
570
632
  result.fetch(key) { result[key.to_s] }
571
633
  end
572
634
 
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
-
635
+ # 内部ツール名を UI 向けの表示ラベルに変換する。
636
+ # 絵文字は付けず、"_" を空白に、小文字を大文字にする (例: run_command → RUN COMMAND)。
591
637
  def tool_label(tool)
592
- TOOL_LABELS.fetch(tool.to_s, "🔧 #{tool}")
638
+ tool.to_s.tr("_", " ").upcase
593
639
  end
594
640
 
595
641
  # 引数から「何を対象にしたか」を 1 行で要約する (例: 対象ファイル名 / 実行コマンド)。
@@ -99,9 +99,10 @@ 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
- role: "chat"
104
+ role: "chat",
105
+ provider: config.llm_provider
105
106
  )
106
107
  LLM::ChatClient.new(base_client: client)
107
108
  end
@@ -112,9 +113,10 @@ module Chocomint
112
113
  model: config.llm.fetch("model"),
113
114
  api_key: api_key(config.llm),
114
115
  max_tokens: config.llm.fetch("max_tokens", 1024),
115
- timeout: config.timeout_sec,
116
+ timeout: config.llm_timeout_sec,
116
117
  payload_logger: logger,
117
- role: "primary"
118
+ role: "primary",
119
+ provider: config.llm_provider
118
120
  )
119
121
  LLM::PrimaryClient.new(base_client: client,
120
122
  tool_definitions: registry.tool_definitions)
@@ -130,9 +132,10 @@ module Chocomint
130
132
  model: config.llm.fetch("model"),
131
133
  api_key: api_key(config.llm),
132
134
  max_tokens: config.verifier.fetch("max_tokens", 1024),
133
- timeout: config.timeout_sec,
135
+ timeout: config.llm_timeout_sec,
134
136
  payload_logger: logger,
135
- role: "verifier"
137
+ role: "verifier",
138
+ provider: config.llm_provider
136
139
  )
137
140
  LLM::VerifierClient.new(base_client: client)
138
141
  end