feat: add chat compaction built-in plugin

Nicolas Leão committed Mar 26, 2026 at 13:55 UTC 5b3240677d1e0e75b161e0e9dba48e92fcd9abf7
20 files changed +1620
plugins/chat_compaction/.toggle-1
plugins/chat_compaction/api/compact_chat.py new
+71
@@ -0,0 +1,71 @@
1 +"""API handler for chat compaction."""
2 +import importlib
3 +from helpers.api import ApiHandler, Input, Output, Request, Response
4 +from agent import AgentContext
5 +
6 +
7 +class CompactChat(ApiHandler):
8 + """Compact the current chat history into a summarized message."""
9 +
10 + async def process(self, input: Input, request: Request) -> Output:
11 + ctxid = input.get("context", "")
12 + action = input.get("action", "compact")
13 +
14 + if not ctxid:
15 + return Response("Missing context id", 400)
16 +
17 + context = AgentContext.get(ctxid)
18 + if not context:
19 + return Response("Context not found", 404)
20 +
21 + # Validate context is not running
22 + if context.is_running():
23 + return Response("Cannot compact while agent is running", 409)
24 +
25 + # Check if there's enough content to compact
26 + # Count user-visible log items (what the user sees in the UI)
27 + visible_count = len(context.log.logs)
28 + if visible_count <= 1:
29 + return Response("Not enough messages to compact", 400)
30 +
31 + # Force reload compactor module to pick up latest changes
32 + import usr.plugins.compaction.helpers.compactor as _compactor_mod
33 + importlib.reload(_compactor_mod)
34 +
35 + if action == "stats":
36 + # Return statistics for confirmation modal
37 + stats = await _compactor_mod.get_compaction_stats(context)
38 + return {"ok": True, "stats": stats}
39 +
40 + elif action == "compact":
41 + # Get plugin config for model choice
42 + from helpers.plugins import get_plugin_config
43 + agent = context.agent0
44 + plugin_config = get_plugin_config("compaction", agent=agent) or {}
45 + use_chat_model = plugin_config.get("use_chat_model", True)
46 +
47 + # Start compaction as a deferred task
48 + context.run_task(_run_compaction_task, context, use_chat_model)
49 +
50 + return {"ok": True, "message": "Compaction started"}
51 +
52 + else:
53 + return Response(f"Unknown action: {action}", 400)
54 +
55 +
56 +async def _run_compaction_task(context, use_chat_model: bool):
57 + """Wrapper to run compaction and handle errors."""
58 + try:
59 + import importlib
60 + import usr.plugins.compaction.helpers.compactor as _compactor_mod
61 + importlib.reload(_compactor_mod)
62 + await _compactor_mod.run_compaction(context, use_chat_model)
63 + except Exception as e:
64 + # Log error but don't crash the task
65 + context.log.log(
66 + type="error",
67 + heading="Compaction Failed",
68 + content=str(e),
69 + )
70 + from helpers.state_monitor_integration import mark_dirty_all
71 + mark_dirty_all(reason="plugins.compaction.compact_chat_error")
plugins/chat_compaction/compaction/.toggle-1
plugins/chat_compaction/compaction/api/compact_chat.py new
+71
@@ -0,0 +1,71 @@
1 +"""API handler for chat compaction."""
2 +import importlib
3 +from helpers.api import ApiHandler, Input, Output, Request, Response
4 +from agent import AgentContext
5 +
6 +
7 +class CompactChat(ApiHandler):
8 + """Compact the current chat history into a summarized message."""
9 +
10 + async def process(self, input: Input, request: Request) -> Output:
11 + ctxid = input.get("context", "")
12 + action = input.get("action", "compact")
13 +
14 + if not ctxid:
15 + return Response("Missing context id", 400)
16 +
17 + context = AgentContext.get(ctxid)
18 + if not context:
19 + return Response("Context not found", 404)
20 +
21 + # Validate context is not running
22 + if context.is_running():
23 + return Response("Cannot compact while agent is running", 409)
24 +
25 + # Check if there's enough content to compact
26 + # Count user-visible log items (what the user sees in the UI)
27 + visible_count = len(context.log.logs)
28 + if visible_count <= 1:
29 + return Response("Not enough messages to compact", 400)
30 +
31 + # Force reload compactor module to pick up latest changes
32 + import usr.plugins.compaction.helpers.compactor as _compactor_mod
33 + importlib.reload(_compactor_mod)
34 +
35 + if action == "stats":
36 + # Return statistics for confirmation modal
37 + stats = await _compactor_mod.get_compaction_stats(context)
38 + return {"ok": True, "stats": stats}
39 +
40 + elif action == "compact":
41 + # Get plugin config for model choice
42 + from helpers.plugins import get_plugin_config
43 + agent = context.agent0
44 + plugin_config = get_plugin_config("compaction", agent=agent) or {}
45 + use_chat_model = plugin_config.get("use_chat_model", True)
46 +
47 + # Start compaction as a deferred task
48 + context.run_task(_run_compaction_task, context, use_chat_model)
49 +
50 + return {"ok": True, "message": "Compaction started"}
51 +
52 + else:
53 + return Response(f"Unknown action: {action}", 400)
54 +
55 +
56 +async def _run_compaction_task(context, use_chat_model: bool):
57 + """Wrapper to run compaction and handle errors."""
58 + try:
59 + import importlib
60 + import usr.plugins.compaction.helpers.compactor as _compactor_mod
61 + importlib.reload(_compactor_mod)
62 + await _compactor_mod.run_compaction(context, use_chat_model)
63 + except Exception as e:
64 + # Log error but don't crash the task
65 + context.log.log(
66 + type="error",
67 + heading="Compaction Failed",
68 + content=str(e),
69 + )
70 + from helpers.state_monitor_integration import mark_dirty_all
71 + mark_dirty_all(reason="plugins.compaction.compact_chat_error")
plugins/chat_compaction/compaction/default_config.yaml new
+1
@@ -0,0 +1 @@
1 +use_chat_model: true
plugins/chat_compaction/compaction/extensions/webui/chat-input-bottom-actions-start/compact-button.html new
+282
@@ -0,0 +1,282 @@
1 +<html>
2 +<head>
3 + <title>Compact Button Extension</title>
4 + <script type="module" src="/plugins/compaction/webui/compact-store.js"></script>
5 +</head>
6 +<body>
7 + <div x-data="{
8 + get disabled() {
9 + const ctx = globalThis.getContext?.();
10 + const store = Alpine?.store?.('compactStore');
11 + const chatInput = Alpine?.store?.('chatInput');
12 + return !ctx || store?.compacting || chatInput?.running;
13 + },
14 + get disabledReason() {
15 + const ctx = globalThis.getContext?.();
16 + const store = Alpine?.store?.('compactStore');
17 + const chatInput = Alpine?.store?.('chatInput');
18 + if (!ctx) return 'No active chat selected';
19 + if (store?.compacting) return 'Compaction in progress';
20 + if (chatInput?.running) return 'Cannot compact while agent is running';
21 + return 'Compact chat history into a single summary';
22 + }
23 + }">
24 + <template x-if="$store.compactStore">
25 + <button
26 + class="text-button"
27 + @click="$store.compactStore.fetchStats()"
28 + :disabled="disabled"
29 + :title="disabledReason"
30 + >
31 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" aria-hidden="true">
32 + <polyline points="4 14 10 14 10 20"/><line x1="3" y1="21" x2="10" y2="14"/>
33 + <polyline points="20 10 14 10 14 4"/><line x1="21" y1="3" x2="14" y2="10"/>
34 + </svg>
35 + <p>Compact</p>
36 + </button>
37 + </template>
38 + </div>
39 +
40 + <!-- Modal with unique class names to avoid global CSS collision -->
41 + <div x-data x-show="$store.compactStore?.showModal" style="display: none;">
42 + <div class="cmpct-overlay" @click="$store.compactStore?.closeModal()">
43 + <div class="cmpct-dialog" @click.stop>
44 + <div class="cmpct-header">
45 + <h3>Compact Chat History</h3>
46 + <button class="cmpct-close" @click="$store.compactStore?.closeModal()">
47 + <span class="material-symbols-outlined">close</span>
48 + </button>
49 + </div>
50 +
51 + <div class="cmpct-body">
52 + <template x-if="$store.compactStore?.stats">
53 + <div>
54 + <p class="cmpct-desc">
55 + This will summarize your entire conversation into a single optimized message.
56 + </p>
57 +
58 + <div class="cmpct-stats">
59 + <div class="cmpct-stat">
60 + <span class="cmpct-stat-label">Messages</span>
61 + <span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
62 + </div>
63 + <div class="cmpct-stat">
64 + <span class="cmpct-stat-label">Tokens</span>
65 + <span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
66 + </div>
67 + <div class="cmpct-stat">
68 + <span class="cmpct-stat-label">Model</span>
69 + <span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.model_name"></span>
70 + </div>
71 + </div>
72 +
73 + <div class="cmpct-warning">
74 + <span class="material-symbols-outlined">warning</span>
75 + <span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
76 + </div>
77 + </div>
78 + </template>
79 +
80 + <template x-if="!$store.compactStore?.stats">
81 + <div class="cmpct-loading">
82 + <span class="cmpct-spinner"></span>
83 + <span>Loading statistics...</span>
84 + </div>
85 + </template>
86 + </div>
87 +
88 + <div class="cmpct-footer">
89 + <button class="cmpct-btn cmpct-btn-cancel" @click="$store.compactStore?.closeModal()">
90 + Cancel
91 + </button>
92 + <button
93 + class="cmpct-btn cmpct-btn-danger"
94 + @click="$store.compactStore?.compact()"
95 + :disabled="$store.compactStore?.compacting || !$store.compactStore?.stats"
96 + >
97 + <template x-if="$store.compactStore?.compacting">
98 + <span class="cmpct-spinner"></span>
99 + </template>
100 + <span>Compact</span>
101 + </button>
102 + </div>
103 + </div>
104 + </div>
105 + </div>
106 +
107 + <style>
108 + /* All classes prefixed with cmpct- to avoid global CSS collision */
109 + .cmpct-overlay {
110 + position: fixed;
111 + top: 0; left: 0; right: 0; bottom: 0;
112 + background: rgba(0, 0, 0, 0.5);
113 + display: flex;
114 + align-items: center;
115 + justify-content: center;
116 + z-index: 2002;
117 + }
118 +
119 + .cmpct-dialog {
120 + background: var(--color-panel, #1a1a1a);
121 + border-radius: 12px;
122 + box-shadow: 0 4px 23px rgba(0, 0, 0, 0.3);
123 + width: 90%;
124 + max-width: 420px;
125 + overflow: hidden;
126 + }
127 +
128 + .cmpct-header {
129 + display: flex;
130 + align-items: center;
131 + justify-content: space-between;
132 + padding: 16px 20px;
133 + border-bottom: 1px solid var(--color-border, #333);
134 + }
135 +
136 + .cmpct-header h3 {
137 + margin: 0;
138 + font-size: 1.1rem;
139 + font-weight: 600;
140 + color: var(--color-text, #e5e5e5);
141 + }
142 +
143 + .cmpct-close {
144 + background: transparent;
145 + border: none;
146 + cursor: pointer;
147 + padding: 4px;
148 + border-radius: 4px;
149 + color: var(--color-text-secondary, #999);
150 + transition: color 0.2s;
151 + }
152 +
153 + .cmpct-close:hover {
154 + color: var(--color-text, #e5e5e5);
155 + }
156 +
157 + .cmpct-body {
158 + padding: 20px;
159 + }
160 +
161 + .cmpct-desc {
162 + margin: 0 0 16px 0;
163 + color: var(--color-text-secondary, #999);
164 + font-size: 0.9rem;
165 + }
166 +
167 + .cmpct-stats {
168 + display: grid;
169 + grid-template-columns: repeat(3, 1fr);
170 + gap: 12px;
171 + margin-bottom: 16px;
172 + }
173 +
174 + .cmpct-stat {
175 + display: flex;
176 + flex-direction: column;
177 + align-items: center;
178 + padding: 12px 8px;
179 + background: var(--color-background-muted, #252525);
180 + border-radius: 8px;
181 + }
182 +
183 + .cmpct-stat-label {
184 + font-size: 0.7rem;
185 + color: var(--color-text-secondary, #999);
186 + text-transform: uppercase;
187 + letter-spacing: 0.05em;
188 + margin-bottom: 4px;
189 + }
190 +
191 + .cmpct-stat-value {
192 + font-size: 1.1rem;
193 + font-weight: 600;
194 + color: var(--color-text, #e5e5e5);
195 + }
196 +
197 + .cmpct-warning {
198 + display: flex;
199 + align-items: flex-start;
200 + gap: 8px;
201 + padding: 10px 12px;
202 + background: rgba(245, 158, 11, 0.1);
203 + border: 1px solid rgba(245, 158, 11, 0.3);
204 + border-radius: 8px;
205 + color: #f59e0b;
206 + font-size: 0.8rem;
207 + }
208 +
209 + .cmpct-warning .material-symbols-outlined {
210 + font-size: 1.1rem;
211 + flex-shrink: 0;
212 + }
213 +
214 + .cmpct-loading {
215 + display: flex;
216 + align-items: center;
217 + justify-content: center;
218 + gap: 8px;
219 + padding: 30px;
220 + color: var(--color-text-secondary, #999);
221 + }
222 +
223 + .cmpct-footer {
224 + display: flex;
225 + justify-content: flex-end;
226 + gap: 8px;
227 + padding: 12px 20px;
228 + border-top: 1px solid var(--color-border, #333);
229 + }
230 +
231 + .cmpct-btn {
232 + padding: 8px 16px;
233 + border-radius: 6px;
234 + font-size: 0.9rem;
235 + font-weight: 500;
236 + cursor: pointer;
237 + transition: all 0.2s;
238 + display: flex;
239 + align-items: center;
240 + gap: 6px;
241 + border: none;
242 + }
243 +
244 + .cmpct-btn:disabled {
245 + opacity: 0.6;
246 + cursor: not-allowed;
247 + }
248 +
249 + .cmpct-btn-cancel {
250 + background: var(--color-background-muted, #333);
251 + color: var(--color-text, #e5e5e5);
252 + }
253 +
254 + .cmpct-btn-cancel:hover:not(:disabled) {
255 + background: var(--color-background-hover, #444);
256 + }
257 +
258 + .cmpct-btn-danger {
259 + background: #dc2626;
260 + color: white;
261 + }
262 +
263 + .cmpct-btn-danger:hover:not(:disabled) {
264 + background: #b91c1c;
265 + }
266 +
267 + .cmpct-spinner {
268 + width: 16px;
269 + height: 16px;
270 + border: 2px solid currentColor;
271 + border-top-color: transparent;
272 + border-radius: 50%;
273 + animation: cmpct-spin 0.8s linear infinite;
274 + display: inline-block;
275 + }
276 +
277 + @keyframes cmpct-spin {
278 + to { transform: rotate(360deg); }
279 + }
280 + </style>
281 +</body>
282 +</html>
plugins/chat_compaction/compaction/helpers/compactor.py new
+270
@@ -0,0 +1,270 @@
1 +"""Core compaction logic for the compaction plugin."""
2 +import asyncio
3 +from typing import Callable
4 +
5 +from agent import Agent
6 +from helpers import history, tokens
7 +from helpers.history import History, output_text
8 +from helpers.log import Log
9 +from helpers.persist_chat import save_tmp_chat, remove_msg_files
10 +from helpers.state_monitor_integration import mark_dirty_all
11 +from plugins._model_config.helpers.model_config import get_chat_model_config
12 +
13 +
14 +
15 +async def run_compaction(context, use_chat_model: bool = True) -> None:
16 + """
17 + Compact the chat history into a single summarized message.
18 +
19 + This function:
20 + 1. Extracts the full conversation text
21 + 2. Estimates token count and checks against model context window
22 + 3. If needed, splits history and summarizes iteratively
23 + 4. Calls the LLM to generate a comprehensive summary
24 + 5. Replaces the history with a single AI message containing the summary
25 + 6. Resets the log and creates a response log item
26 + 7. Persists the changes
27 +
28 + The function streams progress to the frontend via the log system.
29 + If any error occurs, the original history is preserved.
30 + """
31 + agent = context.agent0
32 +
33 + try:
34 + # Step 1: Extract full conversation text
35 + history_output = agent.history.output()
36 + full_text = output_text(history_output, ai_label="assistant", human_label="user")
37 +
38 + if not full_text.strip():
39 + raise ValueError("No conversation content to compact")
40 +
41 + # Step 2: Estimate tokens and get model config
42 + token_count = tokens.approximate_tokens(full_text)
43 +
44 + model_config = get_chat_model_config() if use_chat_model else None
45 + if model_config is None:
46 + # Fallback: use default context length
47 + ctx_length = 128000
48 + else:
49 + ctx_length = int(model_config.get("ctx_length", 128000))
50 +
51 + # Leave some buffer for the prompt and response
52 + max_input_tokens = int(ctx_length * 0.7)
53 +
54 + # Step 3: Create progress log item
55 + log_item = context.log.log(
56 + type="info",
57 + heading="Compacting chat history...",
58 + content=f"Analyzing {len(agent.history.current.messages)} messages (~{token_count} tokens)...",
59 + )
60 +
61 + # Step 4: Handle large histories by chunking if necessary
62 + if token_count > max_input_tokens:
63 + summary = await _compact_large_history(
64 + agent, full_text, token_count, max_input_tokens, log_item, use_chat_model
65 + )
66 + else:
67 + # Single-pass compaction
68 + summary = await _compact_single_pass(
69 + agent, full_text, log_item, use_chat_model
70 + )
71 +
72 + if not summary or not summary.strip():
73 + raise ValueError("Compaction produced empty summary")
74 +
75 + # Step 5: Replace history with compacted version
76 + agent.history = History(agent=agent)
77 + agent.history.add_message(
78 + ai=True,
79 + content=f"# Chat Compacted\n\n{summary}"
80 + )
81 +
82 + # Clear subordinate chain
83 + agent.data.pop(Agent.DATA_NAME_SUBORDINATE, None)
84 + context.streaming_agent = None
85 +
86 + # Step 6: Reset log and create response
87 + context.log.reset()
88 + context.log.log(
89 + type="response",
90 + heading="Chat Compacted",
91 + content=summary,
92 + update_progress="none",
93 + )
94 +
95 + # Step 7: Persist and notify
96 + save_tmp_chat(context)
97 + remove_msg_files(context.id)
98 +
99 + # Step 8: Force progress bar to inactive state LAST
100 + # This must happen after all log operations and persist
101 + context.log.set_progress("Waiting for input", 0, False)
102 + mark_dirty_all(reason="plugins.compaction.compact_chat")
103 +
104 + except Exception as e:
105 + # Log error but don't modify history
106 + context.log.log(
107 + type="error",
108 + heading="Compaction Failed",
109 + content=str(e),
110 + )
111 + mark_dirty_all(reason="plugins.compaction.compact_chat_error")
112 + raise
113 +
114 +
115 +async def _compact_single_pass(
116 + agent,
117 + full_text: str,
118 + log_item,
119 + use_chat_model: bool
120 +) -> str:
121 + """Compact history in a single LLM call."""
122 +
123 + system_prompt = agent.read_prompt("compact.sys.md")
124 + user_prompt = agent.read_prompt("compact.msg.md", conversation=full_text)
125 +
126 + if use_chat_model:
127 + from langchain_core.messages import HumanMessage, SystemMessage
128 + messages = [
129 + SystemMessage(content=system_prompt),
130 + HumanMessage(content=user_prompt)
131 + ]
132 +
133 + async def chat_stream_cb(chunk: str, total: str):
134 + if chunk:
135 + log_item.stream(content=chunk)
136 +
137 + summary, _ = await agent.call_chat_model(
138 + messages=messages,
139 + response_callback=chat_stream_cb,
140 + )
141 + else:
142 + async def util_stream_cb(chunk: str):
143 + if chunk:
144 + log_item.stream(content=chunk)
145 +
146 + summary = await agent.call_utility_model(
147 + system=system_prompt,
148 + message=user_prompt,
149 + callback=util_stream_cb,
150 + )
151 +
152 + return summary
153 +
154 +
155 +async def _compact_large_history(
156 + agent,
157 + full_text: str,
158 + token_count: int,
159 + max_input_tokens: int,
160 + log_item,
161 + use_chat_model: bool
162 +) -> str:
163 + """
164 + Handle large histories by splitting into chunks and summarizing iteratively.
165 + """
166 + log_item.update(
167 + content=f"History is large (~{token_count} tokens). Splitting into chunks...",
168 + )
169 +
170 + # Split conversation into roughly equal halves
171 + lines = full_text.split('\n')
172 + mid = len(lines) // 2
173 +
174 + chunks = [
175 + '\n'.join(lines[:mid]),
176 + '\n'.join(lines[mid:])
177 + ]
178 +
179 + summaries = []
180 + for i, chunk in enumerate(chunks, 1):
181 + log_item.update(
182 + content=f"Summarizing part {i}/{len(chunks)}...",
183 + )
184 +
185 + system_prompt = agent.read_prompt("compact.sys.md")
186 + user_prompt = agent.read_prompt("compact.msg.md", conversation=chunk)
187 +
188 + if use_chat_model:
189 + from langchain_core.messages import HumanMessage, SystemMessage
190 + messages = [
191 + SystemMessage(content=system_prompt),
192 + HumanMessage(content=user_prompt)
193 + ]
194 + chunk_summary, _ = await agent.call_chat_model(
195 + messages=messages,
196 + response_callback=None, # No streaming for chunks
197 + )
198 + else:
199 + chunk_summary = await agent.call_utility_model(
200 + system=system_prompt,
201 + message=user_prompt,
202 + callback=None,
203 + )
204 +
205 + summaries.append(chunk_summary)
206 +
207 + # Combine summaries
208 + combined = "\n\n---\n\n".join(summaries)
209 +
210 + log_item.update(
211 + content="Creating final summary from parts...",
212 + )
213 +
214 + # Final compaction of combined summaries
215 + final_prompt = agent.read_prompt("compact.sys.md")
216 + final_user = agent.read_prompt(
217 + "compact.msg.md",
218 + conversation=f"This is a multi-part conversation. Here are summaries of each part:\n\n{combined}"
219 + )
220 +
221 + if use_chat_model:
222 + from langchain_core.messages import HumanMessage, SystemMessage
223 + messages = [
224 + SystemMessage(content=final_prompt),
225 + HumanMessage(content=final_user)
226 + ]
227 + final_summary, _ = await agent.call_chat_model(
228 + messages=messages,
229 + response_callback=lambda chunk, total: log_item.stream(content=chunk),
230 + )
231 + else:
232 + final_summary = await agent.call_utility_model(
233 + system=final_prompt,
234 + message=final_user,
235 + callback=lambda chunk: log_item.stream(content=chunk),
236 + )
237 +
238 + return final_summary
239 +async def get_compaction_stats(context) -> dict:
240 + """
241 + Get statistics about the current chat for the confirmation modal.
242 +
243 + Returns:
244 + dict with message_count, token_count, model_name
245 + """
246 + agent = context.agent0
247 +
248 + # Count user-visible conversation turns only
249 + # 'user' = user sent a message, 'response' = agent final response
250 + # Other types (agent, tool, code_exe, etc.) are intermediate processing steps
251 + visible_types = {"user", "response"}
252 + message_count = sum(
253 + 1 for item in context.log.logs
254 + if item.type in visible_types
255 + )
256 +
257 + # Estimate tokens
258 + history_output = agent.history.output()
259 + full_text = output_text(history_output, ai_label="assistant", human_label="user")
260 + token_count = tokens.approximate_tokens(full_text) if full_text else 0
261 +
262 + # Get model name
263 + model_config = get_chat_model_config()
264 + model_name = model_config.get("name", "Default Model") if model_config else "Utility Model"
265 +
266 + return {
267 + "message_count": message_count,
268 + "token_count": token_count,
269 + "model_name": model_name,
270 + }
plugins/chat_compaction/compaction/plugin.yaml new
+8
@@ -0,0 +1,8 @@
1 +name: compaction
2 +title: Chat Compaction
3 +description: Compact entire chat history into a single optimized summary message.
4 +version: 1.0.0
5 +settings_sections:
6 + - agent
7 +per_project_config: false
8 +per_agent_config: false
plugins/chat_compaction/compaction/prompts/compact.msg.md new
+5
@@ -0,0 +1,5 @@
1 +Compact this conversation to its essential facts. Be maximally concise.
2 +
3 +---
4 +{{conversation}}
5 +---
plugins/chat_compaction/compaction/prompts/compact.sys.md new
+17
@@ -0,0 +1,17 @@
1 +You are a conversation compactor. Produce the most concise summary possible while preserving critical information.
2 +
3 +Rules:
4 +- Extract only: key decisions, final outcomes, actionable facts, unresolved items
5 +- Discard: intermediate reasoning, failed attempts, redundant exchanges, pleasantries
6 +- Use terse bullet points, not prose
7 +- Collapse related items into single lines
8 +- Keep exact values: file paths, config values, code identifiers, credentials, URLs
9 +- Omit anything that can be re-derived from context
10 +- Group by topic, not chronology
11 +- No meta-commentary about the summarization
12 +- Target: 10-20% of original length
13 +
14 +Output format:
15 +- Markdown with short section headers
16 +- Bullet lists, no paragraphs
17 +- Code/paths in backticks inline, not fenced blocks unless multi-line
plugins/chat_compaction/compaction/webui/compact-modal.html new
+261
@@ -0,0 +1,261 @@
1 +<html>
2 +<head>
3 + <title>Compact Chat</title>
4 +</head>
5 +<body>
6 + <div x-data x-show="$store.compactStore?.showModal" style="display: none;">
7 + <div class="modal-overlay" @click="$store.compactStore?.closeModal()">
8 + <div class="modal-container" @click.stop>
9 + <div class="modal-header">
10 + <h3>Compact Chat History</h3>
11 + <button class="modal-close" @click="$store.compactStore?.closeModal()">
12 + <span class="material-symbols-outlined">close</span>
13 + </button>
14 + </div>
15 +
16 + <div class="modal-content">
17 + <template x-if="$store.compactStore?.stats">
18 + <div class="stats-container">
19 + <p class="stats-description">
20 + This will summarize your entire conversation into a single optimized message.
21 + </p>
22 +
23 + <div class="stats-grid">
24 + <div class="stat-item">
25 + <span class="stat-label">Messages</span>
26 + <span class="stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
27 + </div>
28 + <div class="stat-item">
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>
35 + </div>
36 + </div>
37 +
38 + <div class="stats-warning">
39 + <span class="material-symbols-outlined">warning</span>
40 + <span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
41 + </div>
42 + </div>
43 + </template>
44 +
45 + <template x-if="!$store.compactStore?.stats">
46 + <div class="stats-loading">
47 + <span class="loading-spinner"></span>
48 + <span>Loading statistics...</span>
49 + </div>
50 + </template>
51 + </div>
52 +
53 + <div class="modal-footer">
54 + <button class="button secondary" @click="$store.compactStore?.closeModal()">
55 + Cancel
56 + </button>
57 + <button
58 + class="button primary danger"
59 + @click="$store.compactStore?.compact()"
60 + :disabled="$store.compactStore?.compacting || !$store.compactStore?.stats"
61 + >
62 + <template x-if="$store.compactStore?.compacting">
63 + <span class="loading-spinner"></span>
64 + </template>
65 + <span>Compact</span>
66 + </button>
67 + </div>
68 + </div>
69 + </div>
70 + </div>
71 +
72 + <style>
73 + .modal-overlay {
74 + position: fixed;
75 + top: 0;
76 + left: 0;
77 + right: 0;
78 + bottom: 0;
79 + background: rgba(0, 0, 0, 0.5);
80 + display: flex;
81 + align-items: center;
82 + justify-content: center;
83 + z-index: 1000;
84 + }
85 +
86 + .modal-container {
87 + background: var(--color-background-elevated);
88 + border-radius: var(--border-radius-md);
89 + box-shadow: var(--shadow-lg);
90 + width: 90%;
91 + max-width: 500px;
92 + max-height: 90vh;
93 + overflow-y: auto;
94 + }
95 +
96 + .modal-header {
97 + display: flex;
98 + align-items: center;
99 + justify-content: space-between;
100 + padding: var(--spacing-md) var(--spacing-lg);
101 + border-bottom: 1px solid var(--color-border);
102 + }
103 +
104 + .modal-header h3 {
105 + margin: 0;
106 + font-size: 1.1rem;
107 + font-weight: 600;
108 + }
109 +
110 + .modal-close {
111 + background: transparent;
112 + border: none;
113 + cursor: pointer;
114 + padding: var(--spacing-xs);
115 + border-radius: var(--border-radius-sm);
116 + color: var(--color-text-secondary);
117 + transition: color 0.2s;
118 + }
119 +
120 + .modal-close:hover {
121 + color: var(--color-text);
122 + }
123 +
124 + .modal-content {
125 + padding: var(--spacing-lg);
126 + }
127 +
128 + .stats-description {
129 + margin: 0 0 var(--spacing-md) 0;
130 + color: var(--color-text-secondary);
131 + font-size: 0.9rem;
132 + }
133 +
134 + .stats-grid {
135 + display: grid;
136 + grid-template-columns: repeat(3, 1fr);
137 + gap: var(--spacing-md);
138 + margin-bottom: var(--spacing-lg);
139 + }
140 +
141 + .stat-item {
142 + display: flex;
143 + flex-direction: column;
144 + align-items: center;
145 + padding: var(--spacing-md);
146 + background: var(--color-background-muted);
147 + border-radius: var(--border-radius-md);
148 + }
149 +
150 + .stat-label {
151 + font-size: 0.75rem;
152 + color: var(--color-text-secondary);
153 + text-transform: uppercase;
154 + letter-spacing: 0.05em;
155 + margin-bottom: var(--spacing-xs);
156 + }
157 +
158 + .stat-value {
159 + font-size: 1.25rem;
160 + font-weight: 600;
161 + color: var(--color-text);
162 + }
163 +
164 + .stats-warning {
165 + display: flex;
166 + align-items: flex-start;
167 + gap: var(--spacing-sm);
168 + padding: var(--spacing-md);
169 + background: var(--color-warning-bg, rgba(245, 158, 11, 0.1));
170 + border: 1px solid var(--color-warning-border, rgba(245, 158, 11, 0.3));
171 + border-radius: var(--border-radius-md);
172 + color: var(--color-warning-text, #92400e);
173 + font-size: 0.85rem;
174 + }
175 +
176 + .stats-warning .material-symbols-outlined {
177 + font-size: 1.2rem;
178 + flex-shrink: 0;
179 + }
180 +
181 + .stats-loading {
182 + display: flex;
183 + align-items: center;
184 + justify-content: center;
185 + gap: var(--spacing-sm);
186 + padding: var(--spacing-xl);
187 + color: var(--color-text-secondary);
188 + }
189 +
190 + .modal-footer {
191 + display: flex;
192 + justify-content: flex-end;
193 + gap: var(--spacing-sm);
194 + padding: var(--spacing-md) var(--spacing-lg);
195 + border-top: 1px solid var(--color-border);
196 + }
197 +
198 + .button {
199 + padding: var(--spacing-sm) var(--spacing-md);
200 + border-radius: var(--border-radius-md);
201 + font-size: 0.9rem;
202 + font-weight: 500;
203 + cursor: pointer;
204 + transition: all 0.2s;
205 + display: flex;
206 + align-items: center;
207 + gap: var(--spacing-xs);
208 + }
209 +
210 + .button:disabled {
211 + opacity: 0.6;
212 + cursor: not-allowed;
213 + }
214 +
215 + .button.secondary {
216 + background: var(--color-background-muted);
217 + border: 1px solid var(--color-border);
218 + color: var(--color-text);
219 + }
220 +
221 + .button.secondary:hover:not(:disabled) {
222 + background: var(--color-background-hover);
223 + }
224 +
225 + .button.primary {
226 + background: var(--color-primary);
227 + border: none;
228 + color: white;
229 + }
230 +
231 + .button.primary:hover:not(:disabled) {
232 + background: var(--color-primary-hover);
233 + }
234 +
235 + .button.danger {
236 + background: var(--color-danger, #dc2626);
237 + }
238 +
239 + .button.danger:hover:not(:disabled) {
240 + background: var(--color-danger-hover, #b91c1c);
241 + }
242 +
243 + .loading-spinner {
244 + width: 16px;
245 + height: 16px;
246 + border: 2px solid currentColor;
247 + border-top-color: transparent;
248 + border-radius: 50%;
249 + animation: spin 0.8s linear infinite;
250 + }
251 +
252 + @keyframes spin {
253 + to { transform: rotate(360deg); }
254 + }
255 + </style>
256 +
257 + <script type="module">
258 + import { store } from "/plugins/compaction/webui/compact-store.js";
259 + </script>
260 +</body>
261 +</html>
plugins/chat_compaction/compaction/webui/compact-store.js new
+67
@@ -0,0 +1,67 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import {
4 + toastFrontendSuccess,
5 + toastFrontendError,
6 +} from "/components/notifications/notification-store.js";
7 +
8 +export const store = createStore("compactStore", {
9 + compacting: false,
10 + stats: null,
11 + showModal: false,
12 +
13 + async fetchStats() {
14 + try {
15 + const ctxid = globalThis.getContext?.();
16 + if (!ctxid) {
17 + toastFrontendError("No active chat", "Compaction");
18 + return;
19 + }
20 +
21 + const res = await callJsonApi("/plugins/compaction/compact_chat", {
22 + context: ctxid,
23 + action: "stats",
24 + });
25 +
26 + if (!res?.ok) {
27 + throw new Error(res?.message || "Failed to fetch stats");
28 + }
29 +
30 + this.stats = res.stats;
31 + this.showModal = true;
32 + } catch (e) {
33 + toastFrontendError(e.message, "Compaction");
34 + }
35 + },
36 +
37 + async compact() {
38 + this.compacting = true;
39 + try {
40 + const ctxid = globalThis.getContext?.();
41 + if (!ctxid) {
42 + throw new Error("No active chat");
43 + }
44 +
45 + const res = await callJsonApi("/plugins/compaction/compact_chat", {
46 + context: ctxid,
47 + action: "compact",
48 + });
49 +
50 + if (!res?.ok) {
51 + throw new Error(res?.message || "Compaction failed");
52 + }
53 +
54 + toastFrontendSuccess("Compaction started", "Compaction");
55 + this.showModal = false;
56 + } catch (e) {
57 + toastFrontendError(e.message, "Compaction");
58 + } finally {
59 + this.compacting = false;
60 + }
61 + },
62 +
63 + closeModal() {
64 + this.showModal = false;
65 + this.stats = null;
66 + },
67 +});
plugins/chat_compaction/compaction/webui/config.html new
+104
@@ -0,0 +1,104 @@
1 +<html>
2 +<head>
3 + <title>Compaction Plugin Settings</title>
4 +</head>
5 +<body>
6 + <div x-data>
7 + <template x-if="config">
8 + <div>
9 + <div class="section-title">Compaction Configuration</div>
10 + <div class="section-description">
11 + Configure how the chat compaction feature works.
12 + </div>
13 +
14 + <div class="field">
15 + <div class="field-label">
16 + <div class="field-title">Use chat model</div>
17 + <div class="field-description">
18 + When enabled, uses the currently selected chat model for compaction.
19 + When disabled, uses the utility model (usually faster and cheaper).
20 + </div>
21 + </div>
22 + <div class="field-control">
23 + <label class="toggle">
24 + <input type="checkbox" x-model="config.use_chat_model" />
25 + <span class="toggler"></span>
26 + </label>
27 + </div>
28 + </div>
29 + </div>
30 + </template>
31 + </div>
32 +
33 + <style>
34 + .section-title {
35 + font-size: 1rem;
36 + font-weight: 600;
37 + margin-bottom: 8px;
38 + color: var(--color-text, #e5e5e5);
39 + }
40 + .section-description {
41 + font-size: 0.85rem;
42 + color: var(--color-text-secondary, #999);
43 + margin-bottom: 20px;
44 + line-height: 1.5;
45 + }
46 + .field {
47 + margin-bottom: 20px;
48 + }
49 + .field-label {
50 + margin-bottom: 8px;
51 + }
52 + .field-title {
53 + font-weight: 500;
54 + color: var(--color-text, #e5e5e5);
55 + margin-bottom: 4px;
56 + }
57 + .field-description {
58 + font-size: 0.8rem;
59 + color: var(--color-text-secondary, #999);
60 + line-height: 1.4;
61 + }
62 + .field-control {
63 + margin-top: 8px;
64 + }
65 + .toggle {
66 + display: inline-flex;
67 + align-items: center;
68 + cursor: pointer;
69 + }
70 + .toggle input {
71 + display: none;
72 + }
73 + .toggler {
74 + width: 44px;
75 + height: 24px;
76 + background: var(--color-border, #444);
77 + border-radius: 12px;
78 + position: relative;
79 + transition: background 0.2s;
80 + }
81 + .toggler::after {
82 + content: '';
83 + position: absolute;
84 + width: 20px;
85 + height: 20px;
86 + background: white;
87 + border-radius: 50%;
88 + top: 2px;
89 + left: 2px;
90 + transition: transform 0.2s;
91 + }
92 + .toggle input:checked + .toggler {
93 + background: var(--color-primary, #3b82f6);
94 + }
95 + .toggle input:checked + .toggler::after {
96 + transform: translateX(20px);
97 + }
98 + </style>
99 +
100 + <script type="module">
101 + import { store } from "/components/plugins/plugin-settings-store.js";
102 + </script>
103 +</body>
104 +</html>
plugins/chat_compaction/default_config.yaml new
+1
@@ -0,0 +1 @@
1 +use_chat_model: true
plugins/chat_compaction/plugin.yaml new
+8
@@ -0,0 +1,8 @@
1 +name: compaction
2 +title: Chat Compaction
3 +description: Compact entire chat history into a single optimized summary message.
4 +version: 1.0.0
5 +settings_sections:
6 + - agent
7 +per_project_config: false
8 +per_agent_config: false
plugins/chat_compaction/prompts/compact.msg.md new
+5
@@ -0,0 +1,5 @@
1 +Compact this conversation to its essential facts. Be maximally concise.
2 +
3 +---
4 +{{conversation}}
5 +---
plugins/chat_compaction/prompts/compact.sys.md new
+17
@@ -0,0 +1,17 @@
1 +You are a conversation compactor. Produce the most concise summary possible while preserving critical information.
2 +
3 +Rules:
4 +- Extract only: key decisions, final outcomes, actionable facts, unresolved items
5 +- Discard: intermediate reasoning, failed attempts, redundant exchanges, pleasantries
6 +- Use terse bullet points, not prose
7 +- Collapse related items into single lines
8 +- Keep exact values: file paths, config values, code identifiers, credentials, URLs
9 +- Omit anything that can be re-derived from context
10 +- Group by topic, not chronology
11 +- No meta-commentary about the summarization
12 +- Target: 10-20% of original length
13 +
14 +Output format:
15 +- Markdown with short section headers
16 +- Bullet lists, no paragraphs
17 +- Code/paths in backticks inline, not fenced blocks unless multi-line
plugins/chat_compaction/webui/compact-modal.html new
+261
@@ -0,0 +1,261 @@
1 +<html>
2 +<head>
3 + <title>Compact Chat</title>
4 +</head>
5 +<body>
6 + <div x-data x-show="$store.compactStore?.showModal" style="display: none;">
7 + <div class="modal-overlay" @click="$store.compactStore?.closeModal()">
8 + <div class="modal-container" @click.stop>
9 + <div class="modal-header">
10 + <h3>Compact Chat History</h3>
11 + <button class="modal-close" @click="$store.compactStore?.closeModal()">
12 + <span class="material-symbols-outlined">close</span>
13 + </button>
14 + </div>
15 +
16 + <div class="modal-content">
17 + <template x-if="$store.compactStore?.stats">
18 + <div class="stats-container">
19 + <p class="stats-description">
20 + This will summarize your entire conversation into a single optimized message.
21 + </p>
22 +
23 + <div class="stats-grid">
24 + <div class="stat-item">
25 + <span class="stat-label">Messages</span>
26 + <span class="stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
27 + </div>
28 + <div class="stat-item">
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>
35 + </div>
36 + </div>
37 +
38 + <div class="stats-warning">
39 + <span class="material-symbols-outlined">warning</span>
40 + <span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
41 + </div>
42 + </div>
43 + </template>
44 +
45 + <template x-if="!$store.compactStore?.stats">
46 + <div class="stats-loading">
47 + <span class="loading-spinner"></span>
48 + <span>Loading statistics...</span>
49 + </div>
50 + </template>
51 + </div>
52 +
53 + <div class="modal-footer">
54 + <button class="button secondary" @click="$store.compactStore?.closeModal()">
55 + Cancel
56 + </button>
57 + <button
58 + class="button primary danger"
59 + @click="$store.compactStore?.compact()"
60 + :disabled="$store.compactStore?.compacting || !$store.compactStore?.stats"
61 + >
62 + <template x-if="$store.compactStore?.compacting">
63 + <span class="loading-spinner"></span>
64 + </template>
65 + <span>Compact</span>
66 + </button>
67 + </div>
68 + </div>
69 + </div>
70 + </div>
71 +
72 + <style>
73 + .modal-overlay {
74 + position: fixed;
75 + top: 0;
76 + left: 0;
77 + right: 0;
78 + bottom: 0;
79 + background: rgba(0, 0, 0, 0.5);
80 + display: flex;
81 + align-items: center;
82 + justify-content: center;
83 + z-index: 1000;
84 + }
85 +
86 + .modal-container {
87 + background: var(--color-background-elevated);
88 + border-radius: var(--border-radius-md);
89 + box-shadow: var(--shadow-lg);
90 + width: 90%;
91 + max-width: 500px;
92 + max-height: 90vh;
93 + overflow-y: auto;
94 + }
95 +
96 + .modal-header {
97 + display: flex;
98 + align-items: center;
99 + justify-content: space-between;
100 + padding: var(--spacing-md) var(--spacing-lg);
101 + border-bottom: 1px solid var(--color-border);
102 + }
103 +
104 + .modal-header h3 {
105 + margin: 0;
106 + font-size: 1.1rem;
107 + font-weight: 600;
108 + }
109 +
110 + .modal-close {
111 + background: transparent;
112 + border: none;
113 + cursor: pointer;
114 + padding: var(--spacing-xs);
115 + border-radius: var(--border-radius-sm);
116 + color: var(--color-text-secondary);
117 + transition: color 0.2s;
118 + }
119 +
120 + .modal-close:hover {
121 + color: var(--color-text);
122 + }
123 +
124 + .modal-content {
125 + padding: var(--spacing-lg);
126 + }
127 +
128 + .stats-description {
129 + margin: 0 0 var(--spacing-md) 0;
130 + color: var(--color-text-secondary);
131 + font-size: 0.9rem;
132 + }
133 +
134 + .stats-grid {
135 + display: grid;
136 + grid-template-columns: repeat(3, 1fr);
137 + gap: var(--spacing-md);
138 + margin-bottom: var(--spacing-lg);
139 + }
140 +
141 + .stat-item {
142 + display: flex;
143 + flex-direction: column;
144 + align-items: center;
145 + padding: var(--spacing-md);
146 + background: var(--color-background-muted);
147 + border-radius: var(--border-radius-md);
148 + }
149 +
150 + .stat-label {
151 + font-size: 0.75rem;
152 + color: var(--color-text-secondary);
153 + text-transform: uppercase;
154 + letter-spacing: 0.05em;
155 + margin-bottom: var(--spacing-xs);
156 + }
157 +
158 + .stat-value {
159 + font-size: 1.25rem;
160 + font-weight: 600;
161 + color: var(--color-text);
162 + }
163 +
164 + .stats-warning {
165 + display: flex;
166 + align-items: flex-start;
167 + gap: var(--spacing-sm);
168 + padding: var(--spacing-md);
169 + background: var(--color-warning-bg, rgba(245, 158, 11, 0.1));
170 + border: 1px solid var(--color-warning-border, rgba(245, 158, 11, 0.3));
171 + border-radius: var(--border-radius-md);
172 + color: var(--color-warning-text, #92400e);
173 + font-size: 0.85rem;
174 + }
175 +
176 + .stats-warning .material-symbols-outlined {
177 + font-size: 1.2rem;
178 + flex-shrink: 0;
179 + }
180 +
181 + .stats-loading {
182 + display: flex;
183 + align-items: center;
184 + justify-content: center;
185 + gap: var(--spacing-sm);
186 + padding: var(--spacing-xl);
187 + color: var(--color-text-secondary);
188 + }
189 +
190 + .modal-footer {
191 + display: flex;
192 + justify-content: flex-end;
193 + gap: var(--spacing-sm);
194 + padding: var(--spacing-md) var(--spacing-lg);
195 + border-top: 1px solid var(--color-border);
196 + }
197 +
198 + .button {
199 + padding: var(--spacing-sm) var(--spacing-md);
200 + border-radius: var(--border-radius-md);
201 + font-size: 0.9rem;
202 + font-weight: 500;
203 + cursor: pointer;
204 + transition: all 0.2s;
205 + display: flex;
206 + align-items: center;
207 + gap: var(--spacing-xs);
208 + }
209 +
210 + .button:disabled {
211 + opacity: 0.6;
212 + cursor: not-allowed;
213 + }
214 +
215 + .button.secondary {
216 + background: var(--color-background-muted);
217 + border: 1px solid var(--color-border);
218 + color: var(--color-text);
219 + }
220 +
221 + .button.secondary:hover:not(:disabled) {
222 + background: var(--color-background-hover);
223 + }
224 +
225 + .button.primary {
226 + background: var(--color-primary);
227 + border: none;
228 + color: white;
229 + }
230 +
231 + .button.primary:hover:not(:disabled) {
232 + background: var(--color-primary-hover);
233 + }
234 +
235 + .button.danger {
236 + background: var(--color-danger, #dc2626);
237 + }
238 +
239 + .button.danger:hover:not(:disabled) {
240 + background: var(--color-danger-hover, #b91c1c);
241 + }
242 +
243 + .loading-spinner {
244 + width: 16px;
245 + height: 16px;
246 + border: 2px solid currentColor;
247 + border-top-color: transparent;
248 + border-radius: 50%;
249 + animation: spin 0.8s linear infinite;
250 + }
251 +
252 + @keyframes spin {
253 + to { transform: rotate(360deg); }
254 + }
255 + </style>
256 +
257 + <script type="module">
258 + import { store } from "/plugins/compaction/webui/compact-store.js";
259 + </script>
260 +</body>
261 +</html>
plugins/chat_compaction/webui/compact-store.js new
+67
@@ -0,0 +1,67 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { callJsonApi } from "/js/api.js";
3 +import {
4 + toastFrontendSuccess,
5 + toastFrontendError,
6 +} from "/components/notifications/notification-store.js";
7 +
8 +export const store = createStore("compactStore", {
9 + compacting: false,
10 + stats: null,
11 + showModal: false,
12 +
13 + async fetchStats() {
14 + try {
15 + const ctxid = globalThis.getContext?.();
16 + if (!ctxid) {
17 + toastFrontendError("No active chat", "Compaction");
18 + return;
19 + }
20 +
21 + const res = await callJsonApi("/plugins/compaction/compact_chat", {
22 + context: ctxid,
23 + action: "stats",
24 + });
25 +
26 + if (!res?.ok) {
27 + throw new Error(res?.message || "Failed to fetch stats");
28 + }
29 +
30 + this.stats = res.stats;
31 + this.showModal = true;
32 + } catch (e) {
33 + toastFrontendError(e.message, "Compaction");
34 + }
35 + },
36 +
37 + async compact() {
38 + this.compacting = true;
39 + try {
40 + const ctxid = globalThis.getContext?.();
41 + if (!ctxid) {
42 + throw new Error("No active chat");
43 + }
44 +
45 + const res = await callJsonApi("/plugins/compaction/compact_chat", {
46 + context: ctxid,
47 + action: "compact",
48 + });
49 +
50 + if (!res?.ok) {
51 + throw new Error(res?.message || "Compaction failed");
52 + }
53 +
54 + toastFrontendSuccess("Compaction started", "Compaction");
55 + this.showModal = false;
56 + } catch (e) {
57 + toastFrontendError(e.message, "Compaction");
58 + } finally {
59 + this.compacting = false;
60 + }
61 + },
62 +
63 + closeModal() {
64 + this.showModal = false;
65 + this.stats = null;
66 + },
67 +});
plugins/chat_compaction/webui/config.html new
+104
@@ -0,0 +1,104 @@
1 +<html>
2 +<head>
3 + <title>Compaction Plugin Settings</title>
4 +</head>
5 +<body>
6 + <div x-data>
7 + <template x-if="config">
8 + <div>
9 + <div class="section-title">Compaction Configuration</div>
10 + <div class="section-description">
11 + Configure how the chat compaction feature works.
12 + </div>
13 +
14 + <div class="field">
15 + <div class="field-label">
16 + <div class="field-title">Use chat model</div>
17 + <div class="field-description">
18 + When enabled, uses the currently selected chat model for compaction.
19 + When disabled, uses the utility model (usually faster and cheaper).
20 + </div>
21 + </div>
22 + <div class="field-control">
23 + <label class="toggle">
24 + <input type="checkbox" x-model="config.use_chat_model" />
25 + <span class="toggler"></span>
26 + </label>
27 + </div>
28 + </div>
29 + </div>
30 + </template>
31 + </div>
32 +
33 + <style>
34 + .section-title {
35 + font-size: 1rem;
36 + font-weight: 600;
37 + margin-bottom: 8px;
38 + color: var(--color-text, #e5e5e5);
39 + }
40 + .section-description {
41 + font-size: 0.85rem;
42 + color: var(--color-text-secondary, #999);
43 + margin-bottom: 20px;
44 + line-height: 1.5;
45 + }
46 + .field {
47 + margin-bottom: 20px;
48 + }
49 + .field-label {
50 + margin-bottom: 8px;
51 + }
52 + .field-title {
53 + font-weight: 500;
54 + color: var(--color-text, #e5e5e5);
55 + margin-bottom: 4px;
56 + }
57 + .field-description {
58 + font-size: 0.8rem;
59 + color: var(--color-text-secondary, #999);
60 + line-height: 1.4;
61 + }
62 + .field-control {
63 + margin-top: 8px;
64 + }
65 + .toggle {
66 + display: inline-flex;
67 + align-items: center;
68 + cursor: pointer;
69 + }
70 + .toggle input {
71 + display: none;
72 + }
73 + .toggler {
74 + width: 44px;
75 + height: 24px;
76 + background: var(--color-border, #444);
77 + border-radius: 12px;
78 + position: relative;
79 + transition: background 0.2s;
80 + }
81 + .toggler::after {
82 + content: '';
83 + position: absolute;
84 + width: 20px;
85 + height: 20px;
86 + background: white;
87 + border-radius: 50%;
88 + top: 2px;
89 + left: 2px;
90 + transition: transform 0.2s;
91 + }
92 + .toggle input:checked + .toggler {
93 + background: var(--color-primary, #3b82f6);
94 + }
95 + .toggle input:checked + .toggler::after {
96 + transform: translateX(20px);
97 + }
98 + </style>
99 +
100 + <script type="module">
101 + import { store } from "/components/plugins/plugin-settings-store.js";
102 + </script>
103 +</body>
104 +</html>