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.
@@ -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
- # 役割は 2 つ:
11
- # 1. classifyユーザー指示が「ファイル操作/コマンド実行を要するタスク」か、
12
- # 「ツール不要の普通の質問・会話」かを 1 語で判定する。
13
- # 2. answer会話と判定された指示に、ツールを使わず自然文で直接答える。
10
+ # 現在の役割:
11
+ # - summarizePlanner が実行したツール列とその結果を自然文にまとめる。
12
+ # - extract — fetch_url が取得したページ本文から目的の情報を抽出する。
13
+ # - compact会話履歴を短い要約に畳み込む (/compact 用)。
14
14
  #
15
- # タスクと判定された指示は呼び出し側 (EditHandlers) Planner に渡す。
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:, arguments:, status:, output: }, ...] 形式。
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
- content = <<~MSG
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: [{ "role" => "user", "content" => content }],
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
- def format_steps(steps)
185
- Array(steps).each_with_index.map do |s, i|
186
- tool = s[:tool] || s["tool"]
187
- status = s[:status] || s["status"]
188
- out = (s[:output] || s["output"]).to_s.strip
189
- out = "#{out[0, SUMMARY_OUTPUT_LIMIT]}…" if out.length > SUMMARY_OUTPUT_LIMIT
190
- line = "#{i + 1}. #{tool} (#{status})"
191
- out.empty? ? line : "#{line}\n 結果: #{out}"
192
- end.join("\n")
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
- def propose_step(request, done_steps = [], trace_id: nil)
102
+ # history: これまでの会話ターン [{ "role" => "user"/"assistant", "content" => String }, ...]
103
+ # (request より前の文脈として messages の先頭に user/assistant のまま積む)。
104
+ def propose_step(request, done_steps = [], trace_id: nil, history: nil)
103
105
  response = @client.create_message(
104
106
  system: self.class.step_system_prompt,
105
107
  tools: @tool_definitions + [FINISH_TOOL_DEFINITION],
106
- messages: build_step_messages(request, done_steps),
108
+ messages: Array(history) + build_step_messages(request, done_steps),
107
109
  trace_id: trace_id
108
110
  )
109
111
 
@@ -120,11 +122,12 @@ module Chocomint
120
122
  # 戻り値: { "tool" => name, "arguments" => Hash }
121
123
  # feedback: 初回〜前回までの全試行履歴 (再提案時に引数を変えさせるため / DESIGN §6)。
122
124
  # [{ attempt:, proposal:, error: }, ...] 形式。旧来の単一 Hash も許容する。
123
- def propose(request, feedback: nil, trace_id: nil)
125
+ # history: これまでの会話ターン (propose_step と同様。messages の先頭に積む)
126
+ def propose(request, feedback: nil, trace_id: nil, history: nil)
124
127
  response = @client.create_message(
125
128
  system: self.class.system_prompt,
126
129
  tools: @tool_definitions,
127
- messages: build_messages(request, feedback),
130
+ messages: Array(history) + build_messages(request, feedback),
128
131
  trace_id: trace_id
129
132
  )
130
133
 
@@ -42,14 +42,16 @@ module Chocomint
42
42
  # { type: "step_start", step: n, tool:, label:, target: } … ツール実行の直前
43
43
  # { type: "step_done", step: n, tool:, label:, target:, status:, exit_code:, output: } … 実行後
44
44
  # コールバック内の例外は握りつぶす (通知失敗で実行本体を止めないため)。
45
- def run(request, expectations: nil, on_event: nil)
45
+ # history: これまでの会話ターン [{ "role" => "user"/"assistant", "content" => String }, ...]
46
+ # (request より前の文脈として主 LLM への messages 先頭に user/assistant のまま積む)。
47
+ def run(request, expectations: nil, on_event: nil, history: nil)
46
48
  trace_id = SecureRandom.uuid
47
49
  done_steps = []
48
50
  total_attempts = 0
49
51
 
50
52
  @max_steps.times do |i|
51
53
  step_no = i + 1
52
- proposal, error = propose_and_execute(request, done_steps, trace_id, step_no, on_event)
54
+ proposal, error = propose_and_execute(request, done_steps, trace_id, step_no, on_event, history)
53
55
  total_attempts += 1
54
56
 
55
57
  if proposal.nil?
@@ -96,10 +98,10 @@ module Chocomint
96
98
  # 次ステップを提案させ、machine 検証を通るまで最大 step_retry 回まで引数を変えて再提案する。
97
99
  # 成功時は proposal に実行結果を :__result で埋めて返す。
98
100
  # 戻り値: [proposal_or_nil, error_or_nil]。
99
- def propose_and_execute(request, done_steps, trace_id, step_no, on_event = nil)
101
+ def propose_and_execute(request, done_steps, trace_id, step_no, on_event = nil, history = nil)
100
102
  last_error = nil
101
103
  @step_retry.times do
102
- proposal = safe_propose(request, done_steps, trace_id)
104
+ proposal = safe_propose(request, done_steps, trace_id, history)
103
105
  return [nil, "proposal failed: #{@propose_error}"] if proposal.nil?
104
106
 
105
107
  # finish はそのまま返す (実行不要)。
@@ -130,9 +132,9 @@ module Chocomint
130
132
  end
131
133
 
132
134
  # LLM 通信エラーは一時的な障害として nil を返し、呼び出し元でリトライさせる。
133
- def safe_propose(request, done_steps, trace_id)
135
+ def safe_propose(request, done_steps, trace_id, history = nil)
134
136
  @propose_error = nil
135
- @primary.propose_step(request, done_steps, trace_id: trace_id)
137
+ @primary.propose_step(request, done_steps, trace_id: trace_id, history: history)
136
138
  rescue InvalidProposalError, UnknownToolError
137
139
  raise
138
140
  rescue Chocomint::Error => e
@@ -27,12 +27,15 @@ module Chocomint
27
27
  # edit UI 用に PathGuard / base_dir / vendor / console 情報も受け取る (任意)。
28
28
  # chat_client: 会話 (ツール不要の質問) を判定・応答する ChatClient (任意)。
29
29
  # 未指定なら /edit チャットは従来どおり全指示を Planner に流す。
30
+ # config: Config インスタンス (任意)。渡すと /edit のモデル切り替え
31
+ # (POST /edit/model) で Planner / chat_client を再構築できるようになる。
30
32
  def initialize(planner, host: "127.0.0.1", port: 9210, logger: $stderr,
31
33
  sqlite_path: nil, path_guard: nil, base_dir: Dir.pwd,
32
34
  max_file_bytes: 10_485_760, ws_port: nil, ws_token: nil,
33
- chat_client: nil)
35
+ chat_client: nil, config: nil)
34
36
  @planner = planner
35
37
  @chat_client = chat_client
38
+ @config = config
36
39
  @host = host
37
40
  @port = port
38
41
  @logger = logger
@@ -70,6 +73,9 @@ module Chocomint
70
73
  server.mount_proc("/edit/save") { |req, res| handle_edit_save(req, res) }
71
74
  server.mount_proc("/edit/chat") { |req, res| handle_edit_chat(req, res) }
72
75
  server.mount_proc("/edit/compact") { |req, res| handle_edit_compact(req, res) }
76
+ # モデル選択 (ステータスバー): 現在値/候補の取得と切り替え。
77
+ server.mount_proc("/edit/models") { |req, res| handle_edit_models(req, res) }
78
+ server.mount_proc("/edit/model") { |req, res| handle_edit_model(req, res) }
73
79
  # ファイル操作 (右クリックメニュー): 削除 / 名前変更 / 追加。
74
80
  server.mount_proc("/edit/fs/delete") { |req, res| handle_edit_fs_delete(req, res) }
75
81
  server.mount_proc("/edit/fs/rename") { |req, res| handle_edit_fs_rename(req, res) }
@@ -270,7 +276,13 @@ module Chocomint
270
276
 
271
277
  # 一覧の表列。id は詳細リンク、trace_id はフィルタリンクにする。
272
278
  # content はレスポンスの返答要約 (text 冒頭 / tool_use:名前)。
273
- LLM_COLUMNS = %w[no trace_id role model response_status content created_at].freeze
279
+ # in/cache/out はトークン数 (入力=キャッシュ除く / 入力キャッシュ / 出力)。
280
+ LLM_COLUMNS = %w[no trace_id role model response_status
281
+ in cache out content created_at].freeze
282
+ # 一覧のトークン列は短い見出しだと意味が伝わりにくいので日本語ラベルを添える。
283
+ LLM_COLUMN_LABELS = {
284
+ "in" => "入力", "cache" => "入力キャッシュ", "out" => "出力"
285
+ }.freeze
274
286
 
275
287
  def llm_logs_html(rows, total:, page:, trace_id:)
276
288
  base = trace_id ? "/logs/llm?trace_id=#{CGI.escape(trace_id)}&" : "/logs/llm?"
@@ -303,6 +315,7 @@ module Chocomint
303
315
  th { background: #f5f5f5; position: sticky; top: 0; }
304
316
  td.no a, td.trace a { color: #1155cc; text-decoration: none; }
305
317
  td.no a:hover, td.trace a:hover { text-decoration: underline; }
318
+ td.num { text-align: right; font-variant-numeric: tabular-nums; color: #444; }
306
319
  td.content { white-space: normal; max-width: 28rem; color: #444; }
307
320
  tr:nth-child(even) { background: #fafafa; }
308
321
  .role-primary { color: #1155cc; font-weight: 600; }
@@ -319,7 +332,7 @@ module Chocomint
319
332
  <div class="table-wrap">
320
333
  <table>
321
334
  <thead>
322
- <tr>#{LLM_COLUMNS.map { |c| "<th>#{CGI.escapeHTML(c)}</th>" }.join}</tr>
335
+ <tr>#{LLM_COLUMNS.map { |c| "<th>#{CGI.escapeHTML(LLM_COLUMN_LABELS[c] || c)}</th>" }.join}</tr>
323
336
  </thead>
324
337
  <tbody>
325
338
  #{rows.empty? ? %(<tr><td colspan="#{LLM_COLUMNS.size}" class="empty">ペイロードはありません</td></tr>) : body_rows}
@@ -340,6 +353,7 @@ module Chocomint
340
353
  trace_cell = tid.empty? ? "(なし)" : %(<a href="/logs/llm?trace_id=#{CGI.escape(tid)}">#{CGI.escapeHTML(tid)}</a>)
341
354
  status = row["response_status"].nil? ? "-" : row["response_status"].to_s
342
355
  content = content_summary(row["response_body"])
356
+ usage = usage_tokens(row["response_body"])
343
357
  <<~ROW
344
358
  <tr>
345
359
  <td class="no">#{no_link}</td>
@@ -347,18 +361,49 @@ module Chocomint
347
361
  <td class="role-#{CGI.escapeHTML(role)}">#{CGI.escapeHTML(role)}</td>
348
362
  <td>#{CGI.escapeHTML(row['model'].to_s)}</td>
349
363
  <td>#{CGI.escapeHTML(status)}</td>
364
+ <td class="num">#{CGI.escapeHTML(token_cell(usage[:input]))}</td>
365
+ <td class="num">#{CGI.escapeHTML(token_cell(usage[:cache]))}</td>
366
+ <td class="num">#{CGI.escapeHTML(token_cell(usage[:output]))}</td>
350
367
  <td class="content">#{CGI.escapeHTML(content)}</td>
351
368
  <td>#{CGI.escapeHTML(row['created_at'].to_s)}</td>
352
369
  </tr>
353
370
  ROW
354
371
  end
355
372
 
373
+ # response_body(JSON) の usage からトークン数を取り出す。
374
+ # :input … 入力トークン (入力キャッシュを除く。Anthropic 系の input_tokens)
375
+ # :cache … 入力キャッシュ (cache_read + cache_creation)
376
+ # :output … 出力トークン
377
+ # usage が無い/パース不能なら各値 nil (表示は "-")。
378
+ def usage_tokens(response_body)
379
+ return { input: nil, cache: nil, output: nil } if response_body.nil?
380
+
381
+ parsed = begin
382
+ JSON.parse(response_body)
383
+ rescue JSON::ParserError
384
+ nil
385
+ end
386
+ usage = parsed.is_a?(Hash) ? parsed["usage"] : nil
387
+ return { input: nil, cache: nil, output: nil } unless usage.is_a?(Hash)
388
+
389
+ cache = usage["cache_read_input_tokens"].to_i + usage["cache_creation_input_tokens"].to_i
390
+ { input: usage["input_tokens"], cache: cache, output: usage["output_tokens"] }
391
+ end
392
+
393
+ # トークン数セルの表示。nil は "-"、数値は 3 桁区切りにする。
394
+ def token_cell(value)
395
+ return "-" if value.nil?
396
+
397
+ value.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
398
+ end
399
+
356
400
  # ペイロード詳細ページ (リクエスト + レスポンス + ツール正常性判定)。
357
401
  def llm_detail_html(row, outcomes = [])
358
402
  role = row["role"].to_s
359
403
  tid = row["trace_id"].to_s
360
404
  trace_link = tid.empty? ? "(なし)" : %(<a href="/logs/llm?trace_id=#{CGI.escape(tid)}">#{CGI.escapeHTML(tid)}</a>)
361
405
  status = row["response_status"].nil? ? "(未記録)" : row["response_status"].to_s
406
+ usage = usage_tokens(row["response_body"])
362
407
  <<~HTML
363
408
  <!DOCTYPE html>
364
409
  <html lang="ja">
@@ -407,6 +452,7 @@ module Chocomint
407
452
  <span>model: #{CGI.escapeHTML(row['model'].to_s)}</span>
408
453
  <span>trace: #{trace_link}</span>
409
454
  <span>response_status: #{CGI.escapeHTML(status)}</span>
455
+ <span>tokens: 入力 #{CGI.escapeHTML(token_cell(usage[:input]))} / 入力キャッシュ #{CGI.escapeHTML(token_cell(usage[:cache]))} / 出力 #{CGI.escapeHTML(token_cell(usage[:output]))}</span>
410
456
  <span>#{CGI.escapeHTML(row['created_at'].to_s)}</span>
411
457
  </div>
412
458
  <div class="lbl">request messages(LLM への送信メッセージ)</div>
data/lib/chocomint.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Chocomint
4
- VERSION = "1.1.0"
4
+ VERSION = "1.1.2"
5
5
  end
6
6
 
7
7
  require_relative "chocomint/errors"
data/public/edit/edit.css CHANGED
@@ -182,6 +182,13 @@ html, body { margin: 0; height: 100%;
182
182
  padding: 0.2rem 0.6rem; cursor: pointer; border-radius: 4px; }
183
183
  #statusbar-eol[hidden] { display: none; }
184
184
  #statusbar-eol:hover { background: rgba(255,255,255,0.15); }
185
+ /* モデル選択ボタン (改行コードの右)。現在の LLM モデル名を表示する。 */
186
+ #statusbar-model { display: flex; align-items: center; gap: 0.3rem; background: transparent;
187
+ border: none; color: inherit; font-size: inherit; padding: 0.2rem 0.6rem;
188
+ cursor: pointer; border-radius: 4px; max-width: 16rem; }
189
+ #statusbar-model:hover { background: rgba(255,255,255,0.15); }
190
+ .statusbar-model-icon { font-size: 0.8rem; }
191
+ #statusbar-model-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
185
192
  /* ブランチ切り替えダイアログの一覧。 */
186
193
  .branch-list { list-style: none; margin: 0 0 0.7rem; padding: 0; max-height: 12rem;
187
194
  overflow: auto; border: 1px solid #eee; border-radius: 6px; }
@@ -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.3rem; }
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; }