feat: allow model selection in compaction modal

Nicolas Leão committed Mar 26, 2026 at 16:01 UTC 32eb5f8fba528b0725229f7c9e882d41e074b00f
5 files changed +357 -157
plugins/_chat_compaction/api/compact_chat.py
+7 -7
@@ -33,12 +33,12 @@ class CompactChat(ApiHandler):
33 return {"ok": True, "stats": stats}
34
35 elif action == "compact":
36 - from helpers.plugins import get_plugin_config
37 - agent = context.agent0
38 - plugin_config = get_plugin_config("_chat_compaction", agent=agent) or {}
39 - use_chat_model = plugin_config.get("use_chat_model", True)
36 + use_chat_model = input.get("use_chat_model", True)
37 + preset_name = input.get("preset_name") or None
38
41 - context.run_task(_run_compaction_task, context, use_chat_model)
39 + context.run_task(
40 + _run_compaction_task, context, use_chat_model, preset_name
41 + )
42
43 return {"ok": True, "message": "Compaction started"}
44
@@ -46,10 +46,10 @@ class CompactChat(ApiHandler):
46 return Response(f"Unknown action: {action}", 400)
47
48
49 -async def _run_compaction_task(context, use_chat_model: bool):
49 +async def _run_compaction_task(context, use_chat_model: bool, preset_name: str | None):
50 """Wrapper to run compaction and handle errors."""
51 try:
52 - await run_compaction(context, use_chat_model)
52 + await run_compaction(context, use_chat_model, preset_name)
53 except Exception as e:
54 context.log.log(
55 type="error",
plugins/_chat_compaction/extensions/webui/chat-input-bottom-actions-start/compact-button.html
+110 -13
@@ -58,15 +58,35 @@
58 <span class="cmpct-stat-label">Tokens</span>
59 <span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
60 </div>
61 - <div class="cmpct-stat">
62 - <span class="cmpct-stat-label">Model</span>
63 - <span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.model_name"></span>
61 + </div>
62 +
63 + <!-- Model selection -->
64 + <div class="cmpct-model-section">
65 + <div class="cmpct-model-label">Model</div>
66 +
67 + <div class="cmpct-model-controls">
68 + <select class="cmpct-select"
69 + x-model="$store.compactStore.selectedPresetName">
70 + <option value="">Current</option>
71 + <template x-for="preset in $store.compactStore.presets" :key="preset.name">
72 + <option :value="preset.name" x-text="preset.name"></option>
73 + </template>
74 + </select>
75 +
76 + <div class="cmpct-toggle">
77 + <button :class="{ active: $store.compactStore.useChatModel }"
78 + @click="$store.compactStore.useChatModel = true">Chat</button>
79 + <button :class="{ active: !$store.compactStore.useChatModel }"
80 + @click="$store.compactStore.useChatModel = false">Utility</button>
81 + </div>
82 </div>
83 +
84 + <div class="cmpct-model-name" x-text="$store.compactStore.selectedModelDisplay"></div>
85 </div>
86
67 - <div class="cmpct-warning">
68 - <span class="material-symbols-outlined">warning</span>
69 - <span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
87 + <div class="cmpct-info">
88 + <span class="material-symbols-outlined">check_circle</span>
89 + <span>The context will be replaced with a compacted summary. The original conversation will be backed up.</span>
90 </div>
91 </div>
92 </template>
@@ -115,7 +135,7 @@
135 border-radius: 12px;
136 box-shadow: 0 4px 23px rgba(0, 0, 0, 0.3);
137 width: 90%;
118 - max-width: 420px;
138 + max-width: 560px;
139 overflow: hidden;
140 }
141
@@ -160,7 +180,7 @@
180
181 .cmpct-stats {
182 display: grid;
163 - grid-template-columns: repeat(3, 1fr);
183 + grid-template-columns: repeat(2, 1fr);
184 gap: 12px;
185 margin-bottom: 16px;
186 }
@@ -188,19 +208,96 @@
208 color: var(--color-text, #e5e5e5);
209 }
210
191 - .cmpct-warning {
211 + /* Model selection section */
212 + .cmpct-model-section {
213 + margin-bottom: 16px;
214 + }
215 +
216 + .cmpct-model-label {
217 + font-size: 0.7rem;
218 + color: var(--color-text-secondary, #999);
219 + text-transform: uppercase;
220 + letter-spacing: 0.05em;
221 + margin-bottom: 8px;
222 + }
223 +
224 + .cmpct-model-controls {
225 + display: flex;
226 + gap: 8px;
227 + margin-bottom: 6px;
228 + }
229 +
230 + .cmpct-select {
231 + flex: 1;
232 + min-width: 0;
233 + padding: 6px 10px;
234 + border-radius: 6px;
235 + border: 1px solid var(--color-border, #333);
236 + background: var(--color-background-muted, #252525);
237 + color: var(--color-text, #e5e5e5);
238 + font-size: 0.85rem;
239 + cursor: pointer;
240 + }
241 +
242 + .cmpct-select:focus {
243 + outline: none;
244 + border-color: var(--color-primary, #3b82f6);
245 + }
246 +
247 + .cmpct-toggle {
248 + display: flex;
249 + border-radius: 6px;
250 + border: 1px solid var(--color-border, #333);
251 + overflow: hidden;
252 + flex-shrink: 0;
253 + }
254 +
255 + .cmpct-toggle button {
256 + padding: 6px 14px;
257 + border: none;
258 + background: var(--color-background-muted, #252525);
259 + color: var(--color-text-secondary, #999);
260 + font-size: 0.8rem;
261 + font-weight: 500;
262 + cursor: pointer;
263 + transition: all 0.15s;
264 + }
265 +
266 + .cmpct-toggle button:first-child {
267 + border-right: 1px solid var(--color-border, #333);
268 + }
269 +
270 + .cmpct-toggle button.active {
271 + background: var(--color-primary, #3b82f6);
272 + color: white;
273 + }
274 +
275 + .cmpct-toggle button:hover:not(.active) {
276 + background: var(--color-background-hover, #444);
277 + }
278 +
279 + .cmpct-model-name {
280 + font-size: 0.85rem;
281 + color: var(--color-text, #e5e5e5);
282 + opacity: 0.7;
283 + white-space: nowrap;
284 + overflow: hidden;
285 + text-overflow: ellipsis;
286 + }
287 +
288 + .cmpct-info {
289 display: flex;
290 align-items: flex-start;
291 gap: 8px;
292 padding: 10px 12px;
196 - background: rgba(245, 158, 11, 0.1);
197 - border: 1px solid rgba(245, 158, 11, 0.3);
293 + background: rgba(34, 197, 94, 0.1);
294 + border: 1px solid rgba(34, 197, 94, 0.25);
295 border-radius: 8px;
199 - color: #f59e0b;
296 + color: #4ade80;
297 font-size: 0.8rem;
298 }
299
203 - .cmpct-warning .material-symbols-outlined {
300 + .cmpct-info .material-symbols-outlined {
301 font-size: 1.1rem;
302 flex-shrink: 0;
303 }
plugins/_chat_compaction/helpers/compactor.py
+95 -125
@@ -1,13 +1,11 @@
1 """Core compaction logic for the compaction plugin."""
2 -import asyncio
2 import os
3 from datetime import datetime
5 -from typing import Callable
4
5 +import models as models_module
6 from agent import Agent
8 -from helpers import files, history, tokens
7 +from helpers import tokens
8 from helpers.history import History, output_text
10 -from helpers.log import Log
9 from helpers.persist_chat import (
10 export_json_chat,
11 get_chat_folder_path,
@@ -15,7 +13,14 @@ from helpers.persist_chat import (
13 remove_msg_files,
14 )
15 from helpers.state_monitor_integration import mark_dirty_all
18 -from plugins._model_config.helpers.model_config import get_chat_model_config
16 +from plugins._model_config.helpers.model_config import (
17 + get_chat_model_config,
18 + get_utility_model_config,
19 + get_preset_by_name,
20 + build_model_config,
21 + build_chat_model,
22 + build_utility_model,
23 +)
24
25
26 def _save_pre_compaction_backup(context, full_text: str) -> dict[str, str]:
@@ -40,7 +45,36 @@ def _save_pre_compaction_backup(context, full_text: str) -> dict[str, str]:
45 return {"json": json_path, "txt": txt_path}
46
47
43 -async def run_compaction(context, use_chat_model: bool = True) -> None:
48 +def _build_model(use_chat_model: bool, preset_name: str | None, agent):
49 + """Build the LLM model for compaction based on user selection.
50 +
51 + If preset_name is given, builds from that preset's config.
52 + Otherwise falls back to the agent's currently configured model.
53 + """
54 + if preset_name:
55 + preset = get_preset_by_name(preset_name)
56 + if preset:
57 + model_key = "chat" if use_chat_model else "utility"
58 + cfg = preset.get(model_key, {})
59 + if cfg.get("provider") or cfg.get("name"):
60 + mc = build_model_config(cfg, models_module.ModelType.CHAT)
61 + return cfg, models_module.get_chat_model(
62 + mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
63 + )
64 +
65 + if use_chat_model:
66 + cfg = get_chat_model_config(agent)
67 + return cfg, build_chat_model(agent)
68 + else:
69 + cfg = get_utility_model_config(agent)
70 + return cfg, build_utility_model(agent)
71 +
72 +
73 +async def run_compaction(
74 + context,
75 + use_chat_model: bool = True,
76 + preset_name: str | None = None,
77 +) -> None:
78 """
79 Compact the chat history into a single summarized message.
80
@@ -66,17 +100,11 @@ async def run_compaction(context, use_chat_model: bool = True) -> None:
100 if not full_text.strip():
101 raise ValueError("No conversation content to compact")
102
69 - # Step 2: Estimate tokens and get model config
103 + # Step 2: Estimate tokens, resolve model, and compute context budget
104 token_count = tokens.approximate_tokens(full_text)
71 -
72 - model_config = get_chat_model_config() if use_chat_model else None
73 - if model_config is None:
74 - # Fallback: use default context length
75 - ctx_length = 128000
76 - else:
77 - ctx_length = int(model_config.get("ctx_length", 128000))
78 -
79 - # Leave some buffer for the prompt and response
105 +
106 + resolved_cfg, model = _build_model(use_chat_model, preset_name, agent)
107 + ctx_length = int(resolved_cfg.get("ctx_length", 128000)) if resolved_cfg else 128000
108 max_input_tokens = int(ctx_length * 0.7)
109
110 # Step 3: Create progress log item (count user-visible messages only)
@@ -91,12 +119,11 @@ async def run_compaction(context, use_chat_model: bool = True) -> None:
119 # Step 4: Handle large histories by chunking if necessary
120 if token_count > max_input_tokens:
121 summary = await _compact_large_history(
94 - agent, full_text, token_count, max_input_tokens, log_item, use_chat_model
122 + agent, full_text, token_count, max_input_tokens, log_item, model
123 )
124 else:
97 - # Single-pass compaction
125 summary = await _compact_single_pass(
99 - agent, full_text, log_item, use_chat_model
126 + agent, full_text, log_item, model
127 )
128
129 if not summary or not summary.strip():
@@ -149,130 +176,69 @@ async def run_compaction(context, use_chat_model: bool = True) -> None:
176 raise
177
178
152 -async def _compact_single_pass(
153 - agent,
154 - full_text: str,
155 - log_item,
156 - use_chat_model: bool
157 -) -> str:
158 - """Compact history in a single LLM call."""
159 -
179 +async def _compact_single_pass(agent, full_text: str, log_item, model) -> str:
180 + """Compact history in a single LLM call using the provided model."""
181 system_prompt = agent.read_prompt("compact.sys.md")
182 user_prompt = agent.read_prompt("compact.msg.md", conversation=full_text)
162 -
163 - if use_chat_model:
164 - from langchain_core.messages import HumanMessage, SystemMessage
165 - messages = [
166 - SystemMessage(content=system_prompt),
167 - HumanMessage(content=user_prompt)
168 - ]
169 -
170 - async def chat_stream_cb(chunk: str, total: str):
171 - if chunk:
172 - log_item.stream(content=chunk)
173 -
174 - summary, _ = await agent.call_chat_model(
175 - messages=messages,
176 - response_callback=chat_stream_cb,
177 - )
178 - else:
179 - async def util_stream_cb(chunk: str):
180 - if chunk:
181 - log_item.stream(content=chunk)
182 -
183 - summary = await agent.call_utility_model(
184 - system=system_prompt,
185 - message=user_prompt,
186 - callback=util_stream_cb,
187 - )
188 -
183 +
184 + async def stream_cb(chunk: str, total: str):
185 + if chunk:
186 + log_item.stream(content=chunk)
187 +
188 + summary, _ = await model.unified_call(
189 + system_message=system_prompt,
190 + user_message=user_prompt,
191 + response_callback=stream_cb,
192 + )
193 return summary
194
195
196 async def _compact_large_history(
193 - agent,
194 - full_text: str,
195 - token_count: int,
196 - max_input_tokens: int,
197 - log_item,
198 - use_chat_model: bool
197 + agent, full_text: str, token_count: int, max_input_tokens: int, log_item, model
198 ) -> str:
200 - """
201 - Handle large histories by splitting into chunks and summarizing iteratively.
202 - """
199 + """Handle large histories by splitting into chunks and summarizing iteratively."""
200 log_item.update(
201 content=f"History is large (~{token_count} tokens). Splitting into chunks...",
202 )
206 -
207 - # Split conversation into roughly equal halves
203 +
204 lines = full_text.split('\n')
205 mid = len(lines) // 2
210 -
211 - chunks = [
212 - '\n'.join(lines[:mid]),
213 - '\n'.join(lines[mid:])
214 - ]
215 -
206 + chunks = ['\n'.join(lines[:mid]), '\n'.join(lines[mid:])]
207 +
208 summaries = []
209 for i, chunk in enumerate(chunks, 1):
218 - log_item.update(
219 - content=f"Summarizing part {i}/{len(chunks)}...",
220 - )
221 -
210 + log_item.update(content=f"Summarizing part {i}/{len(chunks)}...")
211 +
212 system_prompt = agent.read_prompt("compact.sys.md")
213 user_prompt = agent.read_prompt("compact.msg.md", conversation=chunk)
224 -
225 - if use_chat_model:
226 - from langchain_core.messages import HumanMessage, SystemMessage
227 - messages = [
228 - SystemMessage(content=system_prompt),
229 - HumanMessage(content=user_prompt)
230 - ]
231 - chunk_summary, _ = await agent.call_chat_model(
232 - messages=messages,
233 - response_callback=None, # No streaming for chunks
234 - )
235 - else:
236 - chunk_summary = await agent.call_utility_model(
237 - system=system_prompt,
238 - message=user_prompt,
239 - callback=None,
240 - )
241 -
214 +
215 + chunk_summary, _ = await model.unified_call(
216 + system_message=system_prompt,
217 + user_message=user_prompt,
218 + )
219 summaries.append(chunk_summary)
243 -
244 - # Combine summaries
220 +
221 combined = "\n\n---\n\n".join(summaries)
246 -
247 - log_item.update(
248 - content="Creating final summary from parts...",
249 - )
250 -
251 - # Final compaction of combined summaries
222 + log_item.update(content="Creating final summary from parts...")
223 +
224 final_prompt = agent.read_prompt("compact.sys.md")
225 final_user = agent.read_prompt(
254 - "compact.msg.md",
255 - conversation=f"This is a multi-part conversation. Here are summaries of each part:\n\n{combined}"
226 + "compact.msg.md",
227 + conversation=f"This is a multi-part conversation. Here are summaries of each part:\n\n{combined}",
228 + )
229 +
230 + async def stream_cb(chunk: str, total: str):
231 + if chunk:
232 + log_item.stream(content=chunk)
233 +
234 + final_summary, _ = await model.unified_call(
235 + system_message=final_prompt,
236 + user_message=final_user,
237 + response_callback=stream_cb,
238 )
257 -
258 - if use_chat_model:
259 - from langchain_core.messages import HumanMessage, SystemMessage
260 - messages = [
261 - SystemMessage(content=final_prompt),
262 - HumanMessage(content=final_user)
263 - ]
264 - final_summary, _ = await agent.call_chat_model(
265 - messages=messages,
266 - response_callback=lambda chunk, total: log_item.stream(content=chunk),
267 - )
268 - else:
269 - final_summary = await agent.call_utility_model(
270 - system=final_prompt,
271 - message=final_user,
272 - callback=lambda chunk: log_item.stream(content=chunk),
273 - )
274 -
239 return final_summary
240 +
241 +
242 async def get_compaction_stats(context) -> dict:
243 """
244 Get statistics about the current chat for the confirmation modal.
@@ -296,12 +262,16 @@ async def get_compaction_stats(context) -> dict:
262 full_text = output_text(history_output, ai_label="assistant", human_label="user")
263 token_count = tokens.approximate_tokens(full_text) if full_text else 0
264
299 - # Get model name
300 - model_config = get_chat_model_config()
301 - model_name = model_config.get("name", "Default Model") if model_config else "Utility Model"
265 + # Get model names for both chat and utility
266 + chat_cfg = get_chat_model_config(agent)
267 + utility_cfg = get_utility_model_config(agent)
268 + chat_model_name = chat_cfg.get("name", "Default") if chat_cfg else "Default"
269 + utility_model_name = utility_cfg.get("name", "Default") if utility_cfg else "Default"
270
271 return {
272 "message_count": message_count,
273 "token_count": token_count,
306 - "model_name": model_name,
274 + "model_name": chat_model_name,
275 + "chat_model_name": chat_model_name,
276 + "utility_model_name": utility_model_name,
277 }
plugins/_chat_compaction/webui/compact-modal.html
+109 -5
@@ -29,10 +29,36 @@
29 <span class="stat-label">Tokens</span>
30 <span class="stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
31 </div>
32 - <div class="stat-item">
33 - <span class="stat-label">Model</span>
34 - <span class="stat-value" x-text="$store.compactStore?.stats?.model_name"></span>
32 + </div>
33 +
34 + <!-- Model selection -->
35 + <div class="model-section">
36 + <div class="model-section-label">Model</div>
37 +
38 + <div class="model-controls">
39 + <!-- Preset dropdown -->
40 + <select class="compact-select"
41 + x-model="$store.compactStore.selectedPresetName">
42 + <option value="">Current</option>
43 + <template x-for="preset in $store.compactStore.presets" :key="preset.name">
44 + <option :value="preset.name" x-text="preset.name"></option>
45 + </template>
46 + </select>
47 +
48 + <!-- Chat / Utility toggle -->
49 + <div class="model-type-toggle">
50 + <button :class="{ active: $store.compactStore.useChatModel }"
51 + @click="$store.compactStore.useChatModel = true">
52 + Chat
53 + </button>
54 + <button :class="{ active: !$store.compactStore.useChatModel }"
55 + @click="$store.compactStore.useChatModel = false">
56 + Utility
57 + </button>
58 + </div>
59 </div>
60 +
61 + <div class="model-name" x-text="$store.compactStore.selectedModelDisplay"></div>
62 </div>
63
64 <div class="stats-warning">
@@ -88,7 +114,7 @@
114 border-radius: var(--border-radius-md);
115 box-shadow: var(--shadow-lg);
116 width: 90%;
91 - max-width: 500px;
117 + max-width: 640px;
118 max-height: 90vh;
119 overflow-y: auto;
120 }
@@ -133,7 +159,7 @@
159
160 .stats-grid {
161 display: grid;
136 - grid-template-columns: repeat(3, 1fr);
162 + grid-template-columns: repeat(2, 1fr);
163 gap: var(--spacing-md);
164 margin-bottom: var(--spacing-lg);
165 }
@@ -160,6 +186,84 @@
186 font-weight: 600;
187 color: var(--color-text);
188 }
189 +
190 + /* ── Model selection ── */
191 + .model-section {
192 + margin-bottom: var(--spacing-lg);
193 + }
194 +
195 + .model-section-label {
196 + font-size: 0.75rem;
197 + color: var(--color-text-secondary);
198 + text-transform: uppercase;
199 + letter-spacing: 0.05em;
200 + margin-bottom: var(--spacing-sm);
201 + }
202 +
203 + .model-controls {
204 + display: flex;
205 + gap: var(--spacing-sm);
206 + margin-bottom: var(--spacing-sm);
207 + }
208 +
209 + .compact-select {
210 + flex: 1;
211 + min-width: 0;
212 + padding: 6px 10px;
213 + border-radius: var(--border-radius-md);
214 + border: 1px solid var(--color-border);
215 + background: var(--color-background-muted);
216 + color: var(--color-text);
217 + font-size: 0.85rem;
218 + cursor: pointer;
219 + appearance: auto;
220 + }
221 +
222 + .compact-select:focus {
223 + outline: none;
224 + border-color: var(--color-primary, #3b82f6);
225 + }
226 +
227 + .model-type-toggle {
228 + display: flex;
229 + border-radius: var(--border-radius-md);
230 + border: 1px solid var(--color-border);
231 + overflow: hidden;
232 + flex-shrink: 0;
233 + }
234 +
235 + .model-type-toggle button {
236 + padding: 6px 14px;
237 + border: none;
238 + background: var(--color-background-muted);
239 + color: var(--color-text-secondary);
240 + font-size: 0.8rem;
241 + font-weight: 500;
242 + cursor: pointer;
243 + transition: all 0.15s;
244 + }
245 +
246 + .model-type-toggle button:not(:last-child) {
247 + border-right: 1px solid var(--color-border);
248 + }
249 +
250 + .model-type-toggle button.active {
251 + background: var(--color-primary, #3b82f6);
252 + color: white;
253 + }
254 +
255 + .model-type-toggle button:hover:not(.active) {
256 + background: var(--color-background-hover);
257 + }
258 +
259 + .model-name {
260 + font-size: 0.85rem;
261 + color: var(--color-text);
262 + opacity: 0.7;
263 + white-space: nowrap;
264 + overflow: hidden;
265 + text-overflow: ellipsis;
266 + }
267
268 .stats-warning {
269 display: flex;
plugins/_chat_compaction/webui/compact-store.js
+36 -7
@@ -9,6 +9,24 @@ export const store = createStore("compactStore", {
9 compacting: false,
10 stats: null,
11 showModal: false,
12 + presets: [],
13 + selectedPresetName: "",
14 + useChatModel: true,
15 +
16 + get selectedModelDisplay() {
17 + if (this.selectedPresetName) {
18 + const preset = this.presets.find(
19 + (p) => p.name === this.selectedPresetName
20 + );
21 + if (preset) {
22 + const cfg = this.useChatModel ? preset.chat : preset.utility;
23 + if (cfg?.name) return cfg.name;
24 + }
25 + }
26 + return this.useChatModel
27 + ? this.stats?.chat_model_name || "Chat Model"
28 + : this.stats?.utility_model_name || "Utility Model";
29 + },
30
31 async fetchStats() {
32 try {
@@ -18,16 +36,22 @@ export const store = createStore("compactStore", {
36 return;
37 }
38
21 - const res = await callJsonApi("/plugins/_chat_compaction/compact_chat", {
22 - context: ctxid,
23 - action: "stats",
24 - });
39 + const [statsRes, presetsRes] = await Promise.all([
40 + callJsonApi("/plugins/_chat_compaction/compact_chat", {
41 + context: ctxid,
42 + action: "stats",
43 + }),
44 + callJsonApi("/plugins/_model_config/model_presets", {
45 + action: "get",
46 + }),
47 + ]);
48
26 - if (!res?.ok) {
27 - throw new Error(res?.message || "Failed to fetch stats");
49 + if (!statsRes?.ok) {
50 + throw new Error(statsRes?.message || "Failed to fetch stats");
51 }
52
30 - this.stats = res.stats;
53 + this.stats = statsRes.stats;
54 + this.presets = presetsRes?.ok ? presetsRes.presets || [] : [];
55 this.showModal = true;
56 } catch (e) {
57 toastFrontendError(e.message, "Compaction");
@@ -45,6 +69,8 @@ export const store = createStore("compactStore", {
69 const res = await callJsonApi("/plugins/_chat_compaction/compact_chat", {
70 context: ctxid,
71 action: "compact",
72 + use_chat_model: this.useChatModel,
73 + preset_name: this.selectedPresetName || null,
74 });
75
76 if (!res?.ok) {
@@ -63,5 +89,8 @@ export const store = createStore("compactStore", {
89 closeModal() {
90 this.showModal = false;
91 this.stats = null;
92 + this.presets = [];
93 + this.selectedPresetName = "";
94 + this.useChatModel = true;
95 },
96 });