chocomint 1.1.1 → 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 +32 -0
- data/lib/chocomint/edit_handlers.rb +11 -48
- data/lib/chocomint/factory.rb +6 -3
- data/lib/chocomint/llm/base_client.rb +41 -3
- data/lib/chocomint/llm/chat_client.rb +51 -86
- data/lib/chocomint.rb +1 -1
- data/public/edit/edit.css +1 -1
- data/public/edit/edit.js +27 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8b684c264c0c8d2449165ef471d439f3816b59732464d1a80d773d65327838b
|
|
4
|
+
data.tar.gz: c9a98fd3654496f6db1c634771e81a05093ffd21af91e90e7e95c12bc3d8fb4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a9909618b16c7063b1d98b72a61bf666db38081bdd712a46595c20a97434c2087f30f265658a4b99ef614f9d38d391a9e2889866bbd406a41efb03f83a4ac526
|
|
7
|
+
data.tar.gz: 2771ba70535d038cc64987496ea410c4da908af05fd61147f05af15c0492a51ee7b717564aa2b748d75edd8af96d2c4b5ea77091af82d115880655cf5e45efd9
|
data/README.md
CHANGED
|
@@ -125,6 +125,38 @@ Invoke-RestMethod -Uri http://127.0.0.1:9210/v1/messages -Method Post `
|
|
|
125
125
|
bundle exec rspec
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
## リリース(gem 公開)
|
|
129
|
+
|
|
130
|
+
RubyGems に公開済み。バージョンは `lib/chocomint.rb` の `Chocomint::VERSION`
|
|
131
|
+
1 か所で定義し、gemspec がそれを参照する。
|
|
132
|
+
|
|
133
|
+
```powershell
|
|
134
|
+
# 1. バージョンを更新(lib/chocomint.rb の VERSION を書き換える)
|
|
135
|
+
# 例: "1.1.1" -> "1.1.2"
|
|
136
|
+
|
|
137
|
+
# 2. 更新をコミット
|
|
138
|
+
git add lib/chocomint.rb
|
|
139
|
+
git commit -m 'release: vX.Y.Z'
|
|
140
|
+
|
|
141
|
+
# 3. gem をビルド
|
|
142
|
+
gem build chocomint.gemspec # -> chocomint-X.Y.Z.gem が生成される
|
|
143
|
+
|
|
144
|
+
# 4. RubyGems へ push(要認証 + OTP。下記の注意を参照)
|
|
145
|
+
gem push chocomint-X.Y.Z.gem
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
> **push には認証と OTP が必要**: gemspec で `rubygems_mfa_required = true` を
|
|
149
|
+
> 設定しているため、`gem push` には RubyGems の認証(`gem signin` か環境変数
|
|
150
|
+
> `GEM_HOST_API_KEY`)と、多要素認証の OTP コード入力が必要。この操作は対話が
|
|
151
|
+
> 発生するため手元で実行すること。
|
|
152
|
+
|
|
153
|
+
> ⚠️ **公開前のエンドツーエンド検証**: 実行ファイル(`bin/*`)では
|
|
154
|
+
> `$PROGRAM_NAME == __FILE__` ガードを**使わない**。RubyGems の実行ラッパーは
|
|
155
|
+
> 本体を `load` 経由で呼ぶため、このガードがあると gem インストール経由では
|
|
156
|
+
> `main` が発動せず「何もせず終了」する不具合になる。`ruby bin/xxx` の直接実行
|
|
157
|
+
> だけでは見逃すため、公開前後はソースツリー外で `gem install` した実体
|
|
158
|
+
> (`.bat` ラッパー経由)で必ず動作確認する。
|
|
159
|
+
|
|
128
160
|
## 監査ログ
|
|
129
161
|
|
|
130
162
|
全試行が `logs/execution.db` (SQLite) の `executions` テーブルに記録される(DESIGN §12)。
|
|
@@ -269,8 +269,14 @@ module Chocomint
|
|
|
269
269
|
|
|
270
270
|
head = raw.byteslice(0, BINARY_SNIFF_BYTES)
|
|
271
271
|
return true if head.include?("\x00")
|
|
272
|
+
return false if head.dup.force_encoding("UTF-8").valid_encoding?
|
|
272
273
|
|
|
273
|
-
|
|
274
|
+
# 先頭ブロックが有効な UTF-8 でないとき、それが「末尾のマルチバイト文字が
|
|
275
|
+
# スニフ境界 (8192B) で途中まで切れた」だけなら、テキストを誤ってバイナリと
|
|
276
|
+
# みなさない。scrub で不正バイトを除いた結果が末尾数バイトの欠落だけであれば
|
|
277
|
+
# (=不正箇所が末尾に限られる) テキストとして扱う。UTF-8 の 1 文字は最大 4 バイト。
|
|
278
|
+
scrubbed = head.dup.force_encoding("UTF-8").scrub("")
|
|
279
|
+
head.bytesize - scrubbed.bytesize > 3
|
|
274
280
|
end
|
|
275
281
|
|
|
276
282
|
# ---- 保存 (人手編集) ----------------------------------------------------
|
|
@@ -314,11 +320,8 @@ module Chocomint
|
|
|
314
320
|
# stream=true なら SSE でツール実行の進捗を逐次配信する (UI のリアルタイム表示用)。
|
|
315
321
|
return handle_edit_chat_stream(res, instruction, path, history) if body["stream"]
|
|
316
322
|
|
|
317
|
-
#
|
|
318
|
-
|
|
319
|
-
return edit_json(res, 200, edit_chat_reply(instruction, path, history))
|
|
320
|
-
end
|
|
321
|
-
|
|
323
|
+
# 全指示を Planner に流し、必要なツールを自律的に実行させて解決する。
|
|
324
|
+
# 純粋な会話・情報不足の聞き返しは、Planner がツールを使わず finish で応答する。
|
|
322
325
|
request = build_chat_request(path, instruction)
|
|
323
326
|
result = @planner.run(request, expectations: instruction, history: history)
|
|
324
327
|
edit_json(res, 200, edit_chat_result(result, path, instruction))
|
|
@@ -355,12 +358,8 @@ module Chocomint
|
|
|
355
358
|
|
|
356
359
|
# SSE 本体: 会話 or Planner 実行を行い、進捗と最終結果を emit_sse で送る。
|
|
357
360
|
def run_chat_stream(instruction, path, history, emit_sse)
|
|
358
|
-
#
|
|
359
|
-
|
|
360
|
-
emit_sse.call("result", edit_chat_reply(instruction, path, history))
|
|
361
|
-
return
|
|
362
|
-
end
|
|
363
|
-
|
|
361
|
+
# 全指示を Planner に流す。ツールを使わない会話・聞き返しは、Planner が
|
|
362
|
+
# ツール実行なし (進捗イベントなし) で finish し、最終 result だけが届く。
|
|
364
363
|
request = build_chat_request(path, instruction)
|
|
365
364
|
on_event = lambda do |ev|
|
|
366
365
|
case ev[:type]
|
|
@@ -511,42 +510,6 @@ module Chocomint
|
|
|
511
510
|
[]
|
|
512
511
|
end
|
|
513
512
|
|
|
514
|
-
# chat_client があり、指示が「会話」と分類されたときだけ会話として扱う。
|
|
515
|
-
# chat_client 未注入なら常に false (従来どおり全て Planner に流す)。
|
|
516
|
-
def chat_instruction?(instruction)
|
|
517
|
-
return false unless @chat_client
|
|
518
|
-
|
|
519
|
-
@chat_client.classify(instruction) == "chat"
|
|
520
|
-
rescue Chocomint::Error
|
|
521
|
-
# 分類に失敗したら安全側でタスク扱い (Planner に流す)。
|
|
522
|
-
false
|
|
523
|
-
end
|
|
524
|
-
|
|
525
|
-
# 会話 (普通の質問) への直接応答を UI 向け JSON に整形する。
|
|
526
|
-
# 開いているファイルがあれば内容を文脈として渡す。
|
|
527
|
-
def edit_chat_reply(instruction, path, history = [])
|
|
528
|
-
answer = @chat_client.answer(instruction, context: chat_file_context(path), history: history)
|
|
529
|
-
{ "status" => "PASS", "reply" => answer, "steps" => [] }
|
|
530
|
-
rescue Chocomint::Error => e
|
|
531
|
-
{ "status" => "FAIL", "error" => e.message, "steps" => [] }
|
|
532
|
-
end
|
|
533
|
-
|
|
534
|
-
# 会話の文脈として渡すファイル内容 (先頭のみ)。開いていない/読めない場合は nil。
|
|
535
|
-
CHAT_CONTEXT_LIMIT = 4000
|
|
536
|
-
|
|
537
|
-
def chat_file_context(path)
|
|
538
|
-
return nil if path.empty?
|
|
539
|
-
|
|
540
|
-
abs = @path_guard.resolve(path)
|
|
541
|
-
return nil unless File.file?(abs)
|
|
542
|
-
|
|
543
|
-
content = File.read(abs, mode: "rb").force_encoding("UTF-8")
|
|
544
|
-
content = "#{content[0, CHAT_CONTEXT_LIMIT]}…" if content.length > CHAT_CONTEXT_LIMIT
|
|
545
|
-
"現在開いているファイル #{path} の内容:\n```\n#{content}\n```"
|
|
546
|
-
rescue Chocomint::Error
|
|
547
|
-
nil
|
|
548
|
-
end
|
|
549
|
-
|
|
550
513
|
# 対象ファイルがあれば文脈として明示し、無ければ指示だけを渡す。
|
|
551
514
|
# 過去のやり取りは history として別途 messages の先頭に user/assistant のまま積むため
|
|
552
515
|
# (chat_client#answer / Planner#run 経由)、ここでは今回の指示だけを組み立てる。
|
data/lib/chocomint/factory.rb
CHANGED
|
@@ -101,7 +101,8 @@ module Chocomint
|
|
|
101
101
|
max_tokens: config.llm.fetch("max_tokens", 1024),
|
|
102
102
|
timeout: config.llm_timeout_sec,
|
|
103
103
|
payload_logger: logger,
|
|
104
|
-
role: "chat"
|
|
104
|
+
role: "chat",
|
|
105
|
+
provider: config.llm_provider
|
|
105
106
|
)
|
|
106
107
|
LLM::ChatClient.new(base_client: client)
|
|
107
108
|
end
|
|
@@ -114,7 +115,8 @@ module Chocomint
|
|
|
114
115
|
max_tokens: config.llm.fetch("max_tokens", 1024),
|
|
115
116
|
timeout: config.llm_timeout_sec,
|
|
116
117
|
payload_logger: logger,
|
|
117
|
-
role: "primary"
|
|
118
|
+
role: "primary",
|
|
119
|
+
provider: config.llm_provider
|
|
118
120
|
)
|
|
119
121
|
LLM::PrimaryClient.new(base_client: client,
|
|
120
122
|
tool_definitions: registry.tool_definitions)
|
|
@@ -132,7 +134,8 @@ module Chocomint
|
|
|
132
134
|
max_tokens: config.verifier.fetch("max_tokens", 1024),
|
|
133
135
|
timeout: config.llm_timeout_sec,
|
|
134
136
|
payload_logger: logger,
|
|
135
|
-
role: "verifier"
|
|
137
|
+
role: "verifier",
|
|
138
|
+
provider: config.llm_provider
|
|
136
139
|
)
|
|
137
140
|
LLM::VerifierClient.new(base_client: client)
|
|
138
141
|
end
|
|
@@ -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,51 +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
|
-
# history: これまでのやり取り [{ "role" => "user"/"assistant", "content" => String }, ...]
|
|
100
|
-
# (直近の指示より前の文脈として messages の先頭に積む)。
|
|
101
|
-
def answer(instruction, context: nil, history: nil, trace_id: nil)
|
|
102
|
-
content = context.to_s.empty? ? instruction.to_s : "#{context}\n\n#{instruction}"
|
|
103
|
-
system_prompt = "#{ANSWER_SYSTEM_PROMPT}\n#{PrimaryClient.environment_prompt}"
|
|
104
|
-
messages = Array(history) + [{ "role" => "user", "content" => content }]
|
|
105
|
-
response = @client.create_message(
|
|
106
|
-
system: system_prompt,
|
|
107
|
-
messages: messages,
|
|
108
|
-
trace_id: trace_id
|
|
109
|
-
)
|
|
110
|
-
text = strip_emoji(extract_text(response))
|
|
111
|
-
raise Chocomint::Error, "chat response is empty" if text.nil? || text.empty?
|
|
112
|
-
|
|
113
|
-
text
|
|
114
|
-
end
|
|
115
|
-
|
|
116
55
|
# 実行済みステップ列を自然文でまとめる。失敗しても要約なしで続行できるよう
|
|
117
56
|
# 例外時は nil を返す (呼び出し側は生の結果表示にフォールバックする)。
|
|
118
|
-
# 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 メッセージにまとめて渡す (要求とトレースは見出しで切り分ける)。
|
|
119
64
|
def summarize(request, steps, trace_id: nil)
|
|
120
|
-
|
|
121
|
-
ユーザー要求:
|
|
122
|
-
#{request}
|
|
123
|
-
|
|
124
|
-
実行したステップ:
|
|
125
|
-
#{format_steps(steps)}
|
|
126
|
-
MSG
|
|
65
|
+
messages = build_summarize_messages(request, steps)
|
|
127
66
|
response = @client.create_message(
|
|
128
67
|
system: SUMMARIZE_SYSTEM_PROMPT,
|
|
129
|
-
messages:
|
|
68
|
+
messages: messages,
|
|
130
69
|
trace_id: trace_id
|
|
131
70
|
)
|
|
132
71
|
text = strip_emoji(extract_text(response))
|
|
@@ -184,15 +123,41 @@ module Chocomint
|
|
|
184
123
|
|
|
185
124
|
SUMMARY_OUTPUT_LIMIT = 500
|
|
186
125
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
196
161
|
end
|
|
197
162
|
|
|
198
163
|
def extract_text(response)
|
data/lib/chocomint.rb
CHANGED
data/public/edit/edit.css
CHANGED
|
@@ -360,7 +360,7 @@ html, body { margin: 0; height: 100%;
|
|
|
360
360
|
.markdown h4, .markdown h5, .markdown h6 { margin: 0.5rem 0 0.3rem; line-height: 1.3; }
|
|
361
361
|
.markdown h1 { font-size: 1.15rem; } .markdown h2 { font-size: 1.05rem; }
|
|
362
362
|
.markdown h3 { font-size: 0.95rem; }
|
|
363
|
-
.markdown ul { margin: 0.3rem 0; padding-left: 1.
|
|
363
|
+
.markdown ul, .markdown ol { margin: 0.3rem 0; padding-left: 1.5rem; }
|
|
364
364
|
.markdown li { margin: 0.15rem 0; }
|
|
365
365
|
.markdown code { font-family: "Cascadia Code", "Noto Sans JP", monospace;
|
|
366
366
|
font-size: 0.85em; background: #f0f0f3; padding: 0.05rem 0.3rem;
|
data/public/edit/edit.js
CHANGED
|
@@ -1586,14 +1586,24 @@ function renderMarkdown(src) {
|
|
|
1586
1586
|
codeBlocks.push(code.replace(/^\n/, ''));
|
|
1587
1587
|
return ' CB' + (codeBlocks.length - 1) + ' ';
|
|
1588
1588
|
});
|
|
1589
|
+
// ローカル LLM は番号付きリストを改行せず「1. … 2. … 3. …」と 1 行で返すことがある。
|
|
1590
|
+
// 行頭以外に現れる番号マーカー ( "2." など。前が空白/全角空白) の直前で改行して、
|
|
1591
|
+
// 各項目を独立した行に分割する (箇条書きとして描画できるようにするため)。
|
|
1592
|
+
text = text.replace(/[ ]+(?=\d+\.[ ])/g, '\n');
|
|
1593
|
+
|
|
1589
1594
|
const lines = text.split('\n');
|
|
1590
1595
|
let html = '';
|
|
1591
|
-
let
|
|
1592
|
-
const closeList = () => { if (
|
|
1596
|
+
let listTag = null; // 開いているリストの種類 ('ul' / 'ol' / null)
|
|
1597
|
+
const closeList = () => { if (listTag) { html += '</' + listTag + '>'; listTag = null; } };
|
|
1598
|
+
const openList = (tag) => {
|
|
1599
|
+
if (listTag !== tag) { closeList(); html += '<' + tag + '>'; listTag = tag; }
|
|
1600
|
+
};
|
|
1593
1601
|
for (let i = 0; i < lines.length; i++) {
|
|
1594
1602
|
const line = lines[i];
|
|
1595
1603
|
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
1596
1604
|
const li = line.match(/^\s*[-*]\s+(.*)$/);
|
|
1605
|
+
// 番号付きリスト: 「1. 内容」「2) 内容」など。
|
|
1606
|
+
const oli = line.match(/^\s*\d+[.)][ ]+(.*)$/);
|
|
1597
1607
|
// GFM テーブル: 「| 見出し |」+ 次行が区切り (|---|---|) なら表として処理。
|
|
1598
1608
|
if (isTableRow(line) && i + 1 < lines.length && isTableDivider(lines[i + 1])) {
|
|
1599
1609
|
closeList();
|
|
@@ -1607,8 +1617,11 @@ function renderMarkdown(src) {
|
|
|
1607
1617
|
const lvl = h[1].length;
|
|
1608
1618
|
html += '<h' + lvl + '>' + inline(h[2]) + '</h' + lvl + '>';
|
|
1609
1619
|
} else if (li) {
|
|
1610
|
-
|
|
1620
|
+
openList('ul');
|
|
1611
1621
|
html += '<li>' + inline(li[1]) + '</li>';
|
|
1622
|
+
} else if (oli) {
|
|
1623
|
+
openList('ol');
|
|
1624
|
+
html += '<li>' + inline(oli[1]) + '</li>';
|
|
1612
1625
|
} else if (line.trim() === '') {
|
|
1613
1626
|
closeList();
|
|
1614
1627
|
} else if (/^ CB\d+ $/.test(line)) {
|
|
@@ -1849,13 +1862,22 @@ async function compactChat() {
|
|
|
1849
1862
|
}
|
|
1850
1863
|
}
|
|
1851
1864
|
|
|
1865
|
+
// メッセージ要素から「発言そのもの」のテキストだけを取り出す。
|
|
1866
|
+
// 実行時間 (.msg-elapsed) やステータスバッジ (.badge) などの UI 注記は
|
|
1867
|
+
// AI への履歴・要約に混ぜたくないので、複製から取り除いてから textContent を取る。
|
|
1868
|
+
function messageText(m) {
|
|
1869
|
+
const clone = m.cloneNode(true);
|
|
1870
|
+
clone.querySelectorAll('.msg-elapsed, .badge').forEach(el => el.remove());
|
|
1871
|
+
return (clone.textContent || '').replace(/\s+/g, ' ').trim();
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1852
1874
|
// チャットログを AI へ渡す 1 本のプレーンテキストに書き出す (要約入力用)。
|
|
1853
1875
|
// ユーザー発言 / AI 発言 / 実行要約 を role 付きの行にして連結する。
|
|
1854
1876
|
function collectTranscript() {
|
|
1855
1877
|
const lines = [];
|
|
1856
1878
|
chatLog.querySelectorAll('.msg').forEach(m => {
|
|
1857
1879
|
const role = m.classList.contains('user') ? 'ユーザー' : 'アシスタント';
|
|
1858
|
-
const text = (m
|
|
1880
|
+
const text = messageText(m);
|
|
1859
1881
|
if (text) lines.push(role + ': ' + text);
|
|
1860
1882
|
});
|
|
1861
1883
|
return lines.join('\n');
|
|
@@ -1869,7 +1891,7 @@ function collectChatHistory() {
|
|
|
1869
1891
|
// pending (AI 考え中) 行はまだ確定した発言ではないので含めない。
|
|
1870
1892
|
if (m.querySelector('.pending-head')) return;
|
|
1871
1893
|
const role = m.classList.contains('user') ? 'user' : 'assistant';
|
|
1872
|
-
const text = (m
|
|
1894
|
+
const text = messageText(m);
|
|
1873
1895
|
if (text) history.push({ role, content: text });
|
|
1874
1896
|
});
|
|
1875
1897
|
return history;
|