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 +4 -4
- data/README.md +40 -1
- data/bin/chocomint-server +2 -1
- data/config/config.yml +39 -37
- data/lib/chocomint/config.rb +137 -5
- data/lib/chocomint/edit_handlers.rb +112 -66
- data/lib/chocomint/factory.rb +9 -6
- data/lib/chocomint/llm/base_client.rb +41 -3
- data/lib/chocomint/llm/chat_client.rb +51 -83
- data/lib/chocomint/llm/primary_client.rb +7 -4
- data/lib/chocomint/planner.rb +8 -6
- data/lib/chocomint/server.rb +49 -3
- data/lib/chocomint.rb +1 -1
- data/public/edit/edit.css +33 -1
- data/public/edit/edit.js +232 -18
- data/public/edit/index.html +51 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8b684c264c0c8d2449165ef471d439f3816b59732464d1a80d773d65327838b
|
|
4
|
+
data.tar.gz: c9a98fd3654496f6db1c634771e81a05093ffd21af91e90e7e95c12bc3d8fb4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
data/lib/chocomint/config.rb
CHANGED
|
@@ -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
|
-
#
|
|
8
|
+
# 設定ファイルを読み込み、環境変数オーバーライドを適用する。
|
|
9
|
+
#
|
|
10
|
+
# 設定は XDG Base Directory 準拠のユーザーパス
|
|
11
|
+
# (~/.config/chocomint/config.yaml。$XDG_CONFIG_HOME があればそちらを優先) に置く。
|
|
12
|
+
# ユーザー設定がまだ無い場合は、gem 同梱の既定 config をそこへコピーして雛形にする
|
|
13
|
+
# (初回起動時)。読み込み・保存 (モデル切り替えの persist) はいずれもこのユーザーパスを使う。
|
|
7
14
|
class Config
|
|
8
|
-
|
|
15
|
+
# gem 同梱の既定 config (初回のコピー元。読み取り専用として扱う)。
|
|
16
|
+
BUNDLED_PATH = File.expand_path("../../config/config.yml", __dir__)
|
|
9
17
|
|
|
10
18
|
attr_reader :data
|
|
11
19
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
410
|
-
#
|
|
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
|
-
#
|
|
437
|
-
#
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
#
|
|
448
|
-
#
|
|
449
|
-
def
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
488
|
+
edit_json(res, 500, "error" => e.message)
|
|
454
489
|
end
|
|
455
490
|
|
|
456
|
-
#
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
638
|
+
tool.to_s.tr("_", " ").upcase
|
|
593
639
|
end
|
|
594
640
|
|
|
595
641
|
# 引数から「何を対象にしたか」を 1 行で要約する (例: 対象ファイル名 / 実行コマンド)。
|
data/lib/chocomint/factory.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|