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 +4 -4
- data/README.md +8 -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 +114 -31
- data/lib/chocomint/factory.rb +3 -3
- data/lib/chocomint/llm/chat_client.rb +5 -2
- 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 +32 -0
- data/public/edit/edit.js +206 -14
- 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: cf5b5e18513023f06550376aa901ada01d10efd621f53fc01c4f1671b94e3f1d
|
|
4
|
+
data.tar.gz: 8fa2badc83ba90c08fd8bcbc5a2a2632a4342993befafb3721457e10c39ee168
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
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 して使う)。
|
|
@@ -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
|
-
#
|
|
410
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
675
|
+
tool.to_s.tr("_", " ").upcase
|
|
593
676
|
end
|
|
594
677
|
|
|
595
678
|
# 引数から「何を対象にしたか」を 1 行で要約する (例: 対象ファイル名 / 実行コマンド)。
|
data/lib/chocomint/factory.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/chocomint/planner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/chocomint/server.rb
CHANGED
|
@@ -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
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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) => {
|
data/public/edit/index.html
CHANGED
|
@@ -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>
|