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
|
@@ -11,8 +11,11 @@ module Chocomint
|
|
|
11
11
|
class BaseClient
|
|
12
12
|
# payload_logger: LLM 送信内容を記録するロガー (record_llm_payload を持つ / 任意)。
|
|
13
13
|
# role: ペイロードの識別子 ("primary" / "verifier")。ログ表示で区別するため。
|
|
14
|
+
# provider: "openrouter" / "ollama" 等。OpenRouter のときだけ prompt caching
|
|
15
|
+
# (cache_control) を明示的に付ける (Ollama はローカルでキャッシュ課金メリットが無く、
|
|
16
|
+
# cache_control 非対応の可能性もあるため付けない)。
|
|
14
17
|
def initialize(base_url:, model:, api_key: nil, max_tokens: 1024, timeout: 60,
|
|
15
|
-
payload_logger: nil, role: nil)
|
|
18
|
+
payload_logger: nil, role: nil, provider: nil)
|
|
16
19
|
@base_url = base_url.to_s.chomp("/")
|
|
17
20
|
@model = model
|
|
18
21
|
@api_key = api_key
|
|
@@ -20,6 +23,7 @@ module Chocomint
|
|
|
20
23
|
@timeout = timeout
|
|
21
24
|
@payload_logger = payload_logger
|
|
22
25
|
@role = role
|
|
26
|
+
@provider = provider
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
# messages: Anthropic 形式の配列。tools / system は任意。
|
|
@@ -31,8 +35,8 @@ module Chocomint
|
|
|
31
35
|
"max_tokens" => @max_tokens,
|
|
32
36
|
"messages" => messages
|
|
33
37
|
}
|
|
34
|
-
body["tools"] = tools if tools
|
|
35
|
-
body["system"] = system if system
|
|
38
|
+
body["tools"] = with_cached_tools(tools) if tools
|
|
39
|
+
body["system"] = with_cached_system(system) if system
|
|
36
40
|
|
|
37
41
|
headers = {
|
|
38
42
|
"content-type" => "application/json",
|
|
@@ -102,6 +106,40 @@ module Chocomint
|
|
|
102
106
|
warn "llm response logging failed: #{e.message}"
|
|
103
107
|
end
|
|
104
108
|
|
|
109
|
+
# OpenRouter のときだけ prompt caching を有効にする。
|
|
110
|
+
# レンダリング順は tools → system → messages。安定した接頭辞 (tools と system) を
|
|
111
|
+
# キャッシュすれば、毎回同じ system/tools を送るこのアプリでは大きな節約になる。
|
|
112
|
+
def caching_enabled? = @provider.to_s == "openrouter"
|
|
113
|
+
|
|
114
|
+
EPHEMERAL = { "type" => "ephemeral" }.freeze
|
|
115
|
+
|
|
116
|
+
# tools 配列の最後のツール定義に cache_control を付ける。
|
|
117
|
+
# ブレークポイントはそのツールブロックまで (= tools 全体) をキャッシュ対象にする。
|
|
118
|
+
def with_cached_tools(tools)
|
|
119
|
+
return tools unless caching_enabled? && tools.is_a?(Array) && !tools.empty?
|
|
120
|
+
|
|
121
|
+
cached_last = tools.last.merge("cache_control" => EPHEMERAL)
|
|
122
|
+
tools[0...-1] + [cached_last]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# system は文字列で渡ってくる。Anthropic 形式では text ブロックの配列にでき、
|
|
126
|
+
# 最後のブロックに cache_control を付けると system (と直前の tools) がキャッシュされる。
|
|
127
|
+
# 既に配列で渡された場合はその最後のブロックに付ける。
|
|
128
|
+
def with_cached_system(system)
|
|
129
|
+
return system unless caching_enabled?
|
|
130
|
+
|
|
131
|
+
case system
|
|
132
|
+
when String
|
|
133
|
+
[{ "type" => "text", "text" => system, "cache_control" => EPHEMERAL }]
|
|
134
|
+
when Array
|
|
135
|
+
return system if system.empty?
|
|
136
|
+
|
|
137
|
+
system[0...-1] + [system.last.merge("cache_control" => EPHEMERAL)]
|
|
138
|
+
else
|
|
139
|
+
system
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
105
143
|
def connection
|
|
106
144
|
@connection ||= Faraday.new do |f|
|
|
107
145
|
f.options.timeout = @timeout
|
|
@@ -5,46 +5,16 @@ require_relative "../errors"
|
|
|
5
5
|
|
|
6
6
|
module Chocomint
|
|
7
7
|
module LLM
|
|
8
|
-
# /edit
|
|
8
|
+
# /edit チャットの補助 LLM 処理 (要約・抽出・圧縮) を担う軽量クライアント。
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
10
|
+
# 現在の役割:
|
|
11
|
+
# - summarize — Planner が実行したツール列とその結果を自然文にまとめる。
|
|
12
|
+
# - extract — fetch_url が取得したページ本文から目的の情報を抽出する。
|
|
13
|
+
# - compact — 会話履歴を短い要約に畳み込む (/compact 用)。
|
|
14
14
|
#
|
|
15
|
-
#
|
|
15
|
+
# かつて指示を task/chat に振り分けていた classify / answer は、全指示を Planner に
|
|
16
|
+
# 流す方針 (ツールを自律実行して解決) に変更したため削除済み。
|
|
16
17
|
class ChatClient
|
|
17
|
-
# 分類プロンプト。ローカル小型モデルでも安定するよう、出力を 1 語に強制する。
|
|
18
|
-
CLASSIFY_SYSTEM_PROMPT = <<~PROMPT
|
|
19
|
-
あなたはユーザーの指示を 2 種類に分類する分類器です。
|
|
20
|
-
- "task": 実際の操作が必要なもの。ファイルの作成・編集・削除、
|
|
21
|
-
コマンド・プログラムの実行、外部ツールでの調査 (ping・実行・ビルド・
|
|
22
|
-
テスト・検索・一覧取得など) を伴うもの。「〜して」「〜を実行して」
|
|
23
|
-
「〜した結果をまとめて/教えて」のように、動かして結果を得る依頼はすべて task。
|
|
24
|
-
具体的な URL が示されていて、その内容を参照・要約する依頼も task。
|
|
25
|
-
- "chat": 操作を一切伴わず、言葉の説明・質問・相談・雑談だけで済むもの。
|
|
26
|
-
知識の説明や意見を求めるものはこちら。
|
|
27
|
-
また、今日の天気・最新ニュース・現在の株価のように「その場では調べる対象
|
|
28
|
-
(場所・URL など) が特定できない、リアルタイム情報を尋ねる質問」も chat。
|
|
29
|
-
これらは会話として聞き返し、必要な情報を確認するのが適切なため。
|
|
30
|
-
迷ったら task を選ぶこと (操作が必要なのに chat と誤ると何も実行されないため)。
|
|
31
|
-
出力は task または chat のいずれか 1 語のみ。他の文字は一切出力しないこと。
|
|
32
|
-
PROMPT
|
|
33
|
-
|
|
34
|
-
# 会話応答プロンプト。エディタ上のアシスタントとして簡潔に日本語で答えさせる。
|
|
35
|
-
# 現在日時・OS・シェルの情報は answer 実行時に environment_prompt を付加する。
|
|
36
|
-
ANSWER_SYSTEM_PROMPT = <<~PROMPT
|
|
37
|
-
あなたはコードエディタに組み込まれた親切なアシスタントです。
|
|
38
|
-
ユーザーの質問や相談に、簡潔で正確な日本語で答えてください。
|
|
39
|
-
実際のファイル操作やコマンド実行は行いません (この応答では行えません)。
|
|
40
|
-
操作が必要な場合は、その旨と具体的な手順を説明してください。
|
|
41
|
-
今日の天気・最新ニュースなどリアルタイム情報は、あなた自身は把握していません。
|
|
42
|
-
推測で答えず、参照したいウェブページの URL を教えてもらえれば内容を調べられる旨を
|
|
43
|
-
案内し、必要なら場所や対象を聞き返してください。
|
|
44
|
-
コマンド例を示すときは PowerShell (pwsh) の構文を優先してください。
|
|
45
|
-
絵文字は一切使わないでください。
|
|
46
|
-
PROMPT
|
|
47
|
-
|
|
48
18
|
# 実行結果の要約プロンプト。実行済みのツール列とその結果をもとに、
|
|
49
19
|
# ユーザー要求に対して何を行い・どうなったかを自然文でまとめさせる。
|
|
50
20
|
SUMMARIZE_SYSTEM_PROMPT = <<~PROMPT
|
|
@@ -82,48 +52,20 @@ module Chocomint
|
|
|
82
52
|
@client = base_client
|
|
83
53
|
end
|
|
84
54
|
|
|
85
|
-
# 指示を "task" / "chat" に分類する。判定不能時は安全側の "task" を返す
|
|
86
|
-
# (タスクを会話扱いして取りこぼすより、会話をタスク扱いして Planner に流す方が無難)。
|
|
87
|
-
def classify(instruction, trace_id: nil)
|
|
88
|
-
response = @client.create_message(
|
|
89
|
-
system: CLASSIFY_SYSTEM_PROMPT,
|
|
90
|
-
messages: [{ "role" => "user", "content" => instruction.to_s }],
|
|
91
|
-
trace_id: trace_id
|
|
92
|
-
)
|
|
93
|
-
text = extract_text(response).to_s.downcase
|
|
94
|
-
text.include?("chat") ? "chat" : "task"
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# 会話としての自然文回答を返す。現在日時・OS・シェルの情報を毎回付加する
|
|
98
|
-
# (ローカルモデルが日付や実行環境を誤解しないようにするため)。
|
|
99
|
-
def answer(instruction, context: nil, trace_id: nil)
|
|
100
|
-
content = context.to_s.empty? ? instruction.to_s : "#{context}\n\n#{instruction}"
|
|
101
|
-
system_prompt = "#{ANSWER_SYSTEM_PROMPT}\n#{PrimaryClient.environment_prompt}"
|
|
102
|
-
response = @client.create_message(
|
|
103
|
-
system: system_prompt,
|
|
104
|
-
messages: [{ "role" => "user", "content" => content }],
|
|
105
|
-
trace_id: trace_id
|
|
106
|
-
)
|
|
107
|
-
text = strip_emoji(extract_text(response))
|
|
108
|
-
raise Chocomint::Error, "chat response is empty" if text.nil? || text.empty?
|
|
109
|
-
|
|
110
|
-
text
|
|
111
|
-
end
|
|
112
|
-
|
|
113
55
|
# 実行済みステップ列を自然文でまとめる。失敗しても要約なしで続行できるよう
|
|
114
56
|
# 例外時は nil を返す (呼び出し側は生の結果表示にフォールバックする)。
|
|
115
|
-
# steps: [{ tool:,
|
|
57
|
+
# steps: [{ tool:, label:, target:, status:, exit_code:, output: }, ...] 形式。
|
|
58
|
+
#
|
|
59
|
+
# かつては全ステップの生出力を 1 本の巨大な user メッセージに連結し、その後は
|
|
60
|
+
# 要求と各ステップを別々の user メッセージに分割していた。だが Messages API は
|
|
61
|
+
# user/assistant が交互になる形を想定しており、user メッセージの連投は素直でない。
|
|
62
|
+
# そこで「ユーザー要求」と「実行トレース (ステップの構造化 JSON 配列)」を、
|
|
63
|
+
# 1 本の user メッセージにまとめて渡す (要求とトレースは見出しで切り分ける)。
|
|
116
64
|
def summarize(request, steps, trace_id: nil)
|
|
117
|
-
|
|
118
|
-
ユーザー要求:
|
|
119
|
-
#{request}
|
|
120
|
-
|
|
121
|
-
実行したステップ:
|
|
122
|
-
#{format_steps(steps)}
|
|
123
|
-
MSG
|
|
65
|
+
messages = build_summarize_messages(request, steps)
|
|
124
66
|
response = @client.create_message(
|
|
125
67
|
system: SUMMARIZE_SYSTEM_PROMPT,
|
|
126
|
-
messages:
|
|
68
|
+
messages: messages,
|
|
127
69
|
trace_id: trace_id
|
|
128
70
|
)
|
|
129
71
|
text = strip_emoji(extract_text(response))
|
|
@@ -181,15 +123,41 @@ module Chocomint
|
|
|
181
123
|
|
|
182
124
|
SUMMARY_OUTPUT_LIMIT = 500
|
|
183
125
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
126
|
+
# 「ユーザー要求」と「実行トレース」を 1 本の user メッセージにまとめて積む。
|
|
127
|
+
# トレースは各ステップ (番号・ツール・対象・状態・出力要点) を要素とする JSON
|
|
128
|
+
# 配列にして、LLM が構造を読み取りやすいようにする。ステップが無ければ要求だけを渡す。
|
|
129
|
+
def build_summarize_messages(request, steps)
|
|
130
|
+
content = +"ユーザー要求:\n#{request}"
|
|
131
|
+
list = Array(steps)
|
|
132
|
+
unless list.empty?
|
|
133
|
+
trace = list.each_with_index.map { |s, i| format_step(s, i) }
|
|
134
|
+
content << "\n\n実行したステップ:\n#{JSON.pretty_generate(trace)}"
|
|
135
|
+
end
|
|
136
|
+
[{ "role" => "user", "content" => content }]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# 1 ステップを要約向けの構造化オブジェクトに整える。呼び出し側 (edit_handlers) が
|
|
140
|
+
# 用意した label / target / exit_code を活かし、生出力は要点だけに圧縮する。
|
|
141
|
+
def format_step(step, index)
|
|
142
|
+
obj = {
|
|
143
|
+
"step" => index + 1,
|
|
144
|
+
"tool" => step[:tool] || step["tool"],
|
|
145
|
+
"label" => step[:label] || step["label"],
|
|
146
|
+
"target" => step[:target] || step["target"],
|
|
147
|
+
"status" => step[:status] || step["status"],
|
|
148
|
+
"exit_code" => step[:exit_code] || step["exit_code"]
|
|
149
|
+
}.reject { |_, v| v.nil? || v == "" }
|
|
150
|
+
out = clean_output(step[:output] || step["output"])
|
|
151
|
+
obj["output"] = out unless out.empty?
|
|
152
|
+
obj
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# ツール出力の生ノイズを取り除いて要点化する。改行コード (\r\n) を正規化し、
|
|
156
|
+
# 空行を畳んでから上限で切り詰める (生 JSON や README 全文をそのまま流さない)。
|
|
157
|
+
def clean_output(output)
|
|
158
|
+
text = output.to_s.gsub("\r\n", "\n").gsub("\r", "\n")
|
|
159
|
+
text = text.gsub(/\n{3,}/, "\n\n").strip
|
|
160
|
+
text.length > SUMMARY_OUTPUT_LIMIT ? "#{text[0, SUMMARY_OUTPUT_LIMIT]}…" : text
|
|
193
161
|
end
|
|
194
162
|
|
|
195
163
|
def 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; }
|
|
@@ -353,7 +360,7 @@ html, body { margin: 0; height: 100%;
|
|
|
353
360
|
.markdown h4, .markdown h5, .markdown h6 { margin: 0.5rem 0 0.3rem; line-height: 1.3; }
|
|
354
361
|
.markdown h1 { font-size: 1.15rem; } .markdown h2 { font-size: 1.05rem; }
|
|
355
362
|
.markdown h3 { font-size: 0.95rem; }
|
|
356
|
-
.markdown ul { margin: 0.3rem 0; padding-left: 1.
|
|
363
|
+
.markdown ul, .markdown ol { margin: 0.3rem 0; padding-left: 1.5rem; }
|
|
357
364
|
.markdown li { margin: 0.15rem 0; }
|
|
358
365
|
.markdown code { font-family: "Cascadia Code", "Noto Sans JP", monospace;
|
|
359
366
|
font-size: 0.85em; background: #f0f0f3; padding: 0.05rem 0.3rem;
|
|
@@ -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; }
|