mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-04-28 03:30:23 +00:00
feat: add chat compaction built-in plugin
This commit is contained in:
parent
87ad4dab86
commit
5b3240677d
20 changed files with 1620 additions and 0 deletions
0
plugins/chat_compaction/.toggle-1
Normal file
0
plugins/chat_compaction/.toggle-1
Normal file
71
plugins/chat_compaction/api/compact_chat.py
Normal file
71
plugins/chat_compaction/api/compact_chat.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""API handler for chat compaction."""
|
||||
import importlib
|
||||
from helpers.api import ApiHandler, Input, Output, Request, Response
|
||||
from agent import AgentContext
|
||||
|
||||
|
||||
class CompactChat(ApiHandler):
|
||||
"""Compact the current chat history into a summarized message."""
|
||||
|
||||
async def process(self, input: Input, request: Request) -> Output:
|
||||
ctxid = input.get("context", "")
|
||||
action = input.get("action", "compact")
|
||||
|
||||
if not ctxid:
|
||||
return Response("Missing context id", 400)
|
||||
|
||||
context = AgentContext.get(ctxid)
|
||||
if not context:
|
||||
return Response("Context not found", 404)
|
||||
|
||||
# Validate context is not running
|
||||
if context.is_running():
|
||||
return Response("Cannot compact while agent is running", 409)
|
||||
|
||||
# Check if there's enough content to compact
|
||||
# Count user-visible log items (what the user sees in the UI)
|
||||
visible_count = len(context.log.logs)
|
||||
if visible_count <= 1:
|
||||
return Response("Not enough messages to compact", 400)
|
||||
|
||||
# Force reload compactor module to pick up latest changes
|
||||
import usr.plugins.compaction.helpers.compactor as _compactor_mod
|
||||
importlib.reload(_compactor_mod)
|
||||
|
||||
if action == "stats":
|
||||
# Return statistics for confirmation modal
|
||||
stats = await _compactor_mod.get_compaction_stats(context)
|
||||
return {"ok": True, "stats": stats}
|
||||
|
||||
elif action == "compact":
|
||||
# Get plugin config for model choice
|
||||
from helpers.plugins import get_plugin_config
|
||||
agent = context.agent0
|
||||
plugin_config = get_plugin_config("compaction", agent=agent) or {}
|
||||
use_chat_model = plugin_config.get("use_chat_model", True)
|
||||
|
||||
# Start compaction as a deferred task
|
||||
context.run_task(_run_compaction_task, context, use_chat_model)
|
||||
|
||||
return {"ok": True, "message": "Compaction started"}
|
||||
|
||||
else:
|
||||
return Response(f"Unknown action: {action}", 400)
|
||||
|
||||
|
||||
async def _run_compaction_task(context, use_chat_model: bool):
|
||||
"""Wrapper to run compaction and handle errors."""
|
||||
try:
|
||||
import importlib
|
||||
import usr.plugins.compaction.helpers.compactor as _compactor_mod
|
||||
importlib.reload(_compactor_mod)
|
||||
await _compactor_mod.run_compaction(context, use_chat_model)
|
||||
except Exception as e:
|
||||
# Log error but don't crash the task
|
||||
context.log.log(
|
||||
type="error",
|
||||
heading="Compaction Failed",
|
||||
content=str(e),
|
||||
)
|
||||
from helpers.state_monitor_integration import mark_dirty_all
|
||||
mark_dirty_all(reason="plugins.compaction.compact_chat_error")
|
||||
0
plugins/chat_compaction/compaction/.toggle-1
Normal file
0
plugins/chat_compaction/compaction/.toggle-1
Normal file
71
plugins/chat_compaction/compaction/api/compact_chat.py
Normal file
71
plugins/chat_compaction/compaction/api/compact_chat.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""API handler for chat compaction."""
|
||||
import importlib
|
||||
from helpers.api import ApiHandler, Input, Output, Request, Response
|
||||
from agent import AgentContext
|
||||
|
||||
|
||||
class CompactChat(ApiHandler):
|
||||
"""Compact the current chat history into a summarized message."""
|
||||
|
||||
async def process(self, input: Input, request: Request) -> Output:
|
||||
ctxid = input.get("context", "")
|
||||
action = input.get("action", "compact")
|
||||
|
||||
if not ctxid:
|
||||
return Response("Missing context id", 400)
|
||||
|
||||
context = AgentContext.get(ctxid)
|
||||
if not context:
|
||||
return Response("Context not found", 404)
|
||||
|
||||
# Validate context is not running
|
||||
if context.is_running():
|
||||
return Response("Cannot compact while agent is running", 409)
|
||||
|
||||
# Check if there's enough content to compact
|
||||
# Count user-visible log items (what the user sees in the UI)
|
||||
visible_count = len(context.log.logs)
|
||||
if visible_count <= 1:
|
||||
return Response("Not enough messages to compact", 400)
|
||||
|
||||
# Force reload compactor module to pick up latest changes
|
||||
import usr.plugins.compaction.helpers.compactor as _compactor_mod
|
||||
importlib.reload(_compactor_mod)
|
||||
|
||||
if action == "stats":
|
||||
# Return statistics for confirmation modal
|
||||
stats = await _compactor_mod.get_compaction_stats(context)
|
||||
return {"ok": True, "stats": stats}
|
||||
|
||||
elif action == "compact":
|
||||
# Get plugin config for model choice
|
||||
from helpers.plugins import get_plugin_config
|
||||
agent = context.agent0
|
||||
plugin_config = get_plugin_config("compaction", agent=agent) or {}
|
||||
use_chat_model = plugin_config.get("use_chat_model", True)
|
||||
|
||||
# Start compaction as a deferred task
|
||||
context.run_task(_run_compaction_task, context, use_chat_model)
|
||||
|
||||
return {"ok": True, "message": "Compaction started"}
|
||||
|
||||
else:
|
||||
return Response(f"Unknown action: {action}", 400)
|
||||
|
||||
|
||||
async def _run_compaction_task(context, use_chat_model: bool):
|
||||
"""Wrapper to run compaction and handle errors."""
|
||||
try:
|
||||
import importlib
|
||||
import usr.plugins.compaction.helpers.compactor as _compactor_mod
|
||||
importlib.reload(_compactor_mod)
|
||||
await _compactor_mod.run_compaction(context, use_chat_model)
|
||||
except Exception as e:
|
||||
# Log error but don't crash the task
|
||||
context.log.log(
|
||||
type="error",
|
||||
heading="Compaction Failed",
|
||||
content=str(e),
|
||||
)
|
||||
from helpers.state_monitor_integration import mark_dirty_all
|
||||
mark_dirty_all(reason="plugins.compaction.compact_chat_error")
|
||||
1
plugins/chat_compaction/compaction/default_config.yaml
Normal file
1
plugins/chat_compaction/compaction/default_config.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
use_chat_model: true
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Compact Button Extension</title>
|
||||
<script type="module" src="/plugins/compaction/webui/compact-store.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data="{
|
||||
get disabled() {
|
||||
const ctx = globalThis.getContext?.();
|
||||
const store = Alpine?.store?.('compactStore');
|
||||
const chatInput = Alpine?.store?.('chatInput');
|
||||
return !ctx || store?.compacting || chatInput?.running;
|
||||
},
|
||||
get disabledReason() {
|
||||
const ctx = globalThis.getContext?.();
|
||||
const store = Alpine?.store?.('compactStore');
|
||||
const chatInput = Alpine?.store?.('chatInput');
|
||||
if (!ctx) return 'No active chat selected';
|
||||
if (store?.compacting) return 'Compaction in progress';
|
||||
if (chatInput?.running) return 'Cannot compact while agent is running';
|
||||
return 'Compact chat history into a single summary';
|
||||
}
|
||||
}">
|
||||
<template x-if="$store.compactStore">
|
||||
<button
|
||||
class="text-button"
|
||||
@click="$store.compactStore.fetchStats()"
|
||||
:disabled="disabled"
|
||||
:title="disabledReason"
|
||||
>
|
||||
<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">
|
||||
<polyline points="4 14 10 14 10 20"/><line x1="3" y1="21" x2="10" y2="14"/>
|
||||
<polyline points="20 10 14 10 14 4"/><line x1="21" y1="3" x2="14" y2="10"/>
|
||||
</svg>
|
||||
<p>Compact</p>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Modal with unique class names to avoid global CSS collision -->
|
||||
<div x-data x-show="$store.compactStore?.showModal" style="display: none;">
|
||||
<div class="cmpct-overlay" @click="$store.compactStore?.closeModal()">
|
||||
<div class="cmpct-dialog" @click.stop>
|
||||
<div class="cmpct-header">
|
||||
<h3>Compact Chat History</h3>
|
||||
<button class="cmpct-close" @click="$store.compactStore?.closeModal()">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cmpct-body">
|
||||
<template x-if="$store.compactStore?.stats">
|
||||
<div>
|
||||
<p class="cmpct-desc">
|
||||
This will summarize your entire conversation into a single optimized message.
|
||||
</p>
|
||||
|
||||
<div class="cmpct-stats">
|
||||
<div class="cmpct-stat">
|
||||
<span class="cmpct-stat-label">Messages</span>
|
||||
<span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
|
||||
</div>
|
||||
<div class="cmpct-stat">
|
||||
<span class="cmpct-stat-label">Tokens</span>
|
||||
<span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
|
||||
</div>
|
||||
<div class="cmpct-stat">
|
||||
<span class="cmpct-stat-label">Model</span>
|
||||
<span class="cmpct-stat-value" x-text="$store.compactStore?.stats?.model_name"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cmpct-warning">
|
||||
<span class="material-symbols-outlined">warning</span>
|
||||
<span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!$store.compactStore?.stats">
|
||||
<div class="cmpct-loading">
|
||||
<span class="cmpct-spinner"></span>
|
||||
<span>Loading statistics...</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="cmpct-footer">
|
||||
<button class="cmpct-btn cmpct-btn-cancel" @click="$store.compactStore?.closeModal()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="cmpct-btn cmpct-btn-danger"
|
||||
@click="$store.compactStore?.compact()"
|
||||
:disabled="$store.compactStore?.compacting || !$store.compactStore?.stats"
|
||||
>
|
||||
<template x-if="$store.compactStore?.compacting">
|
||||
<span class="cmpct-spinner"></span>
|
||||
</template>
|
||||
<span>Compact</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* All classes prefixed with cmpct- to avoid global CSS collision */
|
||||
.cmpct-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2002;
|
||||
}
|
||||
|
||||
.cmpct-dialog {
|
||||
background: var(--color-panel, #1a1a1a);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 23px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cmpct-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.cmpct-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #e5e5e5);
|
||||
}
|
||||
|
||||
.cmpct-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary, #999);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.cmpct-close:hover {
|
||||
color: var(--color-text, #e5e5e5);
|
||||
}
|
||||
|
||||
.cmpct-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cmpct-desc {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--color-text-secondary, #999);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cmpct-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cmpct-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
background: var(--color-background-muted, #252525);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cmpct-stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-secondary, #999);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cmpct-stat-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #e5e5e5);
|
||||
}
|
||||
|
||||
.cmpct-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #f59e0b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.cmpct-warning .material-symbols-outlined {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cmpct-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 30px;
|
||||
color: var(--color-text-secondary, #999);
|
||||
}
|
||||
|
||||
.cmpct-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--color-border, #333);
|
||||
}
|
||||
|
||||
.cmpct-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cmpct-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cmpct-btn-cancel {
|
||||
background: var(--color-background-muted, #333);
|
||||
color: var(--color-text, #e5e5e5);
|
||||
}
|
||||
|
||||
.cmpct-btn-cancel:hover:not(:disabled) {
|
||||
background: var(--color-background-hover, #444);
|
||||
}
|
||||
|
||||
.cmpct-btn-danger {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmpct-btn-danger:hover:not(:disabled) {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.cmpct-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: cmpct-spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes cmpct-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
270
plugins/chat_compaction/compaction/helpers/compactor.py
Normal file
270
plugins/chat_compaction/compaction/helpers/compactor.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
"""Core compaction logic for the compaction plugin."""
|
||||
import asyncio
|
||||
from typing import Callable
|
||||
|
||||
from agent import Agent
|
||||
from helpers import history, tokens
|
||||
from helpers.history import History, output_text
|
||||
from helpers.log import Log
|
||||
from helpers.persist_chat import save_tmp_chat, remove_msg_files
|
||||
from helpers.state_monitor_integration import mark_dirty_all
|
||||
from plugins._model_config.helpers.model_config import get_chat_model_config
|
||||
|
||||
|
||||
|
||||
async def run_compaction(context, use_chat_model: bool = True) -> None:
|
||||
"""
|
||||
Compact the chat history into a single summarized message.
|
||||
|
||||
This function:
|
||||
1. Extracts the full conversation text
|
||||
2. Estimates token count and checks against model context window
|
||||
3. If needed, splits history and summarizes iteratively
|
||||
4. Calls the LLM to generate a comprehensive summary
|
||||
5. Replaces the history with a single AI message containing the summary
|
||||
6. Resets the log and creates a response log item
|
||||
7. Persists the changes
|
||||
|
||||
The function streams progress to the frontend via the log system.
|
||||
If any error occurs, the original history is preserved.
|
||||
"""
|
||||
agent = context.agent0
|
||||
|
||||
try:
|
||||
# Step 1: Extract full conversation text
|
||||
history_output = agent.history.output()
|
||||
full_text = output_text(history_output, ai_label="assistant", human_label="user")
|
||||
|
||||
if not full_text.strip():
|
||||
raise ValueError("No conversation content to compact")
|
||||
|
||||
# Step 2: Estimate tokens and get model config
|
||||
token_count = tokens.approximate_tokens(full_text)
|
||||
|
||||
model_config = get_chat_model_config() if use_chat_model else None
|
||||
if model_config is None:
|
||||
# Fallback: use default context length
|
||||
ctx_length = 128000
|
||||
else:
|
||||
ctx_length = int(model_config.get("ctx_length", 128000))
|
||||
|
||||
# Leave some buffer for the prompt and response
|
||||
max_input_tokens = int(ctx_length * 0.7)
|
||||
|
||||
# Step 3: Create progress log item
|
||||
log_item = context.log.log(
|
||||
type="info",
|
||||
heading="Compacting chat history...",
|
||||
content=f"Analyzing {len(agent.history.current.messages)} messages (~{token_count} tokens)...",
|
||||
)
|
||||
|
||||
# Step 4: Handle large histories by chunking if necessary
|
||||
if token_count > max_input_tokens:
|
||||
summary = await _compact_large_history(
|
||||
agent, full_text, token_count, max_input_tokens, log_item, use_chat_model
|
||||
)
|
||||
else:
|
||||
# Single-pass compaction
|
||||
summary = await _compact_single_pass(
|
||||
agent, full_text, log_item, use_chat_model
|
||||
)
|
||||
|
||||
if not summary or not summary.strip():
|
||||
raise ValueError("Compaction produced empty summary")
|
||||
|
||||
# Step 5: Replace history with compacted version
|
||||
agent.history = History(agent=agent)
|
||||
agent.history.add_message(
|
||||
ai=True,
|
||||
content=f"# Chat Compacted\n\n{summary}"
|
||||
)
|
||||
|
||||
# Clear subordinate chain
|
||||
agent.data.pop(Agent.DATA_NAME_SUBORDINATE, None)
|
||||
context.streaming_agent = None
|
||||
|
||||
# Step 6: Reset log and create response
|
||||
context.log.reset()
|
||||
context.log.log(
|
||||
type="response",
|
||||
heading="Chat Compacted",
|
||||
content=summary,
|
||||
update_progress="none",
|
||||
)
|
||||
|
||||
# Step 7: Persist and notify
|
||||
save_tmp_chat(context)
|
||||
remove_msg_files(context.id)
|
||||
|
||||
# Step 8: Force progress bar to inactive state LAST
|
||||
# This must happen after all log operations and persist
|
||||
context.log.set_progress("Waiting for input", 0, False)
|
||||
mark_dirty_all(reason="plugins.compaction.compact_chat")
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't modify history
|
||||
context.log.log(
|
||||
type="error",
|
||||
heading="Compaction Failed",
|
||||
content=str(e),
|
||||
)
|
||||
mark_dirty_all(reason="plugins.compaction.compact_chat_error")
|
||||
raise
|
||||
|
||||
|
||||
async def _compact_single_pass(
|
||||
agent,
|
||||
full_text: str,
|
||||
log_item,
|
||||
use_chat_model: bool
|
||||
) -> str:
|
||||
"""Compact history in a single LLM call."""
|
||||
|
||||
system_prompt = agent.read_prompt("compact.sys.md")
|
||||
user_prompt = agent.read_prompt("compact.msg.md", conversation=full_text)
|
||||
|
||||
if use_chat_model:
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
messages = [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_prompt)
|
||||
]
|
||||
|
||||
async def chat_stream_cb(chunk: str, total: str):
|
||||
if chunk:
|
||||
log_item.stream(content=chunk)
|
||||
|
||||
summary, _ = await agent.call_chat_model(
|
||||
messages=messages,
|
||||
response_callback=chat_stream_cb,
|
||||
)
|
||||
else:
|
||||
async def util_stream_cb(chunk: str):
|
||||
if chunk:
|
||||
log_item.stream(content=chunk)
|
||||
|
||||
summary = await agent.call_utility_model(
|
||||
system=system_prompt,
|
||||
message=user_prompt,
|
||||
callback=util_stream_cb,
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
async def _compact_large_history(
|
||||
agent,
|
||||
full_text: str,
|
||||
token_count: int,
|
||||
max_input_tokens: int,
|
||||
log_item,
|
||||
use_chat_model: bool
|
||||
) -> str:
|
||||
"""
|
||||
Handle large histories by splitting into chunks and summarizing iteratively.
|
||||
"""
|
||||
log_item.update(
|
||||
content=f"History is large (~{token_count} tokens). Splitting into chunks...",
|
||||
)
|
||||
|
||||
# Split conversation into roughly equal halves
|
||||
lines = full_text.split('\n')
|
||||
mid = len(lines) // 2
|
||||
|
||||
chunks = [
|
||||
'\n'.join(lines[:mid]),
|
||||
'\n'.join(lines[mid:])
|
||||
]
|
||||
|
||||
summaries = []
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
log_item.update(
|
||||
content=f"Summarizing part {i}/{len(chunks)}...",
|
||||
)
|
||||
|
||||
system_prompt = agent.read_prompt("compact.sys.md")
|
||||
user_prompt = agent.read_prompt("compact.msg.md", conversation=chunk)
|
||||
|
||||
if use_chat_model:
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
messages = [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_prompt)
|
||||
]
|
||||
chunk_summary, _ = await agent.call_chat_model(
|
||||
messages=messages,
|
||||
response_callback=None, # No streaming for chunks
|
||||
)
|
||||
else:
|
||||
chunk_summary = await agent.call_utility_model(
|
||||
system=system_prompt,
|
||||
message=user_prompt,
|
||||
callback=None,
|
||||
)
|
||||
|
||||
summaries.append(chunk_summary)
|
||||
|
||||
# Combine summaries
|
||||
combined = "\n\n---\n\n".join(summaries)
|
||||
|
||||
log_item.update(
|
||||
content="Creating final summary from parts...",
|
||||
)
|
||||
|
||||
# Final compaction of combined summaries
|
||||
final_prompt = agent.read_prompt("compact.sys.md")
|
||||
final_user = agent.read_prompt(
|
||||
"compact.msg.md",
|
||||
conversation=f"This is a multi-part conversation. Here are summaries of each part:\n\n{combined}"
|
||||
)
|
||||
|
||||
if use_chat_model:
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
messages = [
|
||||
SystemMessage(content=final_prompt),
|
||||
HumanMessage(content=final_user)
|
||||
]
|
||||
final_summary, _ = await agent.call_chat_model(
|
||||
messages=messages,
|
||||
response_callback=lambda chunk, total: log_item.stream(content=chunk),
|
||||
)
|
||||
else:
|
||||
final_summary = await agent.call_utility_model(
|
||||
system=final_prompt,
|
||||
message=final_user,
|
||||
callback=lambda chunk: log_item.stream(content=chunk),
|
||||
)
|
||||
|
||||
return final_summary
|
||||
async def get_compaction_stats(context) -> dict:
|
||||
"""
|
||||
Get statistics about the current chat for the confirmation modal.
|
||||
|
||||
Returns:
|
||||
dict with message_count, token_count, model_name
|
||||
"""
|
||||
agent = context.agent0
|
||||
|
||||
# Count user-visible conversation turns only
|
||||
# 'user' = user sent a message, 'response' = agent final response
|
||||
# Other types (agent, tool, code_exe, etc.) are intermediate processing steps
|
||||
visible_types = {"user", "response"}
|
||||
message_count = sum(
|
||||
1 for item in context.log.logs
|
||||
if item.type in visible_types
|
||||
)
|
||||
|
||||
# Estimate tokens
|
||||
history_output = agent.history.output()
|
||||
full_text = output_text(history_output, ai_label="assistant", human_label="user")
|
||||
token_count = tokens.approximate_tokens(full_text) if full_text else 0
|
||||
|
||||
# Get model name
|
||||
model_config = get_chat_model_config()
|
||||
model_name = model_config.get("name", "Default Model") if model_config else "Utility Model"
|
||||
|
||||
return {
|
||||
"message_count": message_count,
|
||||
"token_count": token_count,
|
||||
"model_name": model_name,
|
||||
}
|
||||
8
plugins/chat_compaction/compaction/plugin.yaml
Normal file
8
plugins/chat_compaction/compaction/plugin.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
name: compaction
|
||||
title: Chat Compaction
|
||||
description: Compact entire chat history into a single optimized summary message.
|
||||
version: 1.0.0
|
||||
settings_sections:
|
||||
- agent
|
||||
per_project_config: false
|
||||
per_agent_config: false
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Compact this conversation to its essential facts. Be maximally concise.
|
||||
|
||||
---
|
||||
{{conversation}}
|
||||
---
|
||||
17
plugins/chat_compaction/compaction/prompts/compact.sys.md
Normal file
17
plugins/chat_compaction/compaction/prompts/compact.sys.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
You are a conversation compactor. Produce the most concise summary possible while preserving critical information.
|
||||
|
||||
Rules:
|
||||
- Extract only: key decisions, final outcomes, actionable facts, unresolved items
|
||||
- Discard: intermediate reasoning, failed attempts, redundant exchanges, pleasantries
|
||||
- Use terse bullet points, not prose
|
||||
- Collapse related items into single lines
|
||||
- Keep exact values: file paths, config values, code identifiers, credentials, URLs
|
||||
- Omit anything that can be re-derived from context
|
||||
- Group by topic, not chronology
|
||||
- No meta-commentary about the summarization
|
||||
- Target: 10-20% of original length
|
||||
|
||||
Output format:
|
||||
- Markdown with short section headers
|
||||
- Bullet lists, no paragraphs
|
||||
- Code/paths in backticks inline, not fenced blocks unless multi-line
|
||||
261
plugins/chat_compaction/compaction/webui/compact-modal.html
Normal file
261
plugins/chat_compaction/compaction/webui/compact-modal.html
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Compact Chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data x-show="$store.compactStore?.showModal" style="display: none;">
|
||||
<div class="modal-overlay" @click="$store.compactStore?.closeModal()">
|
||||
<div class="modal-container" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>Compact Chat History</h3>
|
||||
<button class="modal-close" @click="$store.compactStore?.closeModal()">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
<template x-if="$store.compactStore?.stats">
|
||||
<div class="stats-container">
|
||||
<p class="stats-description">
|
||||
This will summarize your entire conversation into a single optimized message.
|
||||
</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Messages</span>
|
||||
<span class="stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Tokens</span>
|
||||
<span class="stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Model</span>
|
||||
<span class="stat-value" x-text="$store.compactStore?.stats?.model_name"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-warning">
|
||||
<span class="material-symbols-outlined">warning</span>
|
||||
<span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!$store.compactStore?.stats">
|
||||
<div class="stats-loading">
|
||||
<span class="loading-spinner"></span>
|
||||
<span>Loading statistics...</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="button secondary" @click="$store.compactStore?.closeModal()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="button primary danger"
|
||||
@click="$store.compactStore?.compact()"
|
||||
:disabled="$store.compactStore?.compacting || !$store.compactStore?.stats"
|
||||
>
|
||||
<template x-if="$store.compactStore?.compacting">
|
||||
<span class="loading-spinner"></span>
|
||||
</template>
|
||||
<span>Compact</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--color-background-elevated);
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stats-description {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-background-muted);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.stats-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-warning-bg, rgba(245, 158, 11, 0.1));
|
||||
border: 1px solid var(--color-warning-border, rgba(245, 158, 11, 0.3));
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--color-warning-text, #92400e);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stats-warning .material-symbols-outlined {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: var(--color-background-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.button.secondary:hover:not(:disabled) {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
.button.danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-hover, #b91c1c);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module">
|
||||
import { store } from "/plugins/compaction/webui/compact-store.js";
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
67
plugins/chat_compaction/compaction/webui/compact-store.js
Normal file
67
plugins/chat_compaction/compaction/webui/compact-store.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsonApi } from "/js/api.js";
|
||||
import {
|
||||
toastFrontendSuccess,
|
||||
toastFrontendError,
|
||||
} from "/components/notifications/notification-store.js";
|
||||
|
||||
export const store = createStore("compactStore", {
|
||||
compacting: false,
|
||||
stats: null,
|
||||
showModal: false,
|
||||
|
||||
async fetchStats() {
|
||||
try {
|
||||
const ctxid = globalThis.getContext?.();
|
||||
if (!ctxid) {
|
||||
toastFrontendError("No active chat", "Compaction");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await callJsonApi("/plugins/compaction/compact_chat", {
|
||||
context: ctxid,
|
||||
action: "stats",
|
||||
});
|
||||
|
||||
if (!res?.ok) {
|
||||
throw new Error(res?.message || "Failed to fetch stats");
|
||||
}
|
||||
|
||||
this.stats = res.stats;
|
||||
this.showModal = true;
|
||||
} catch (e) {
|
||||
toastFrontendError(e.message, "Compaction");
|
||||
}
|
||||
},
|
||||
|
||||
async compact() {
|
||||
this.compacting = true;
|
||||
try {
|
||||
const ctxid = globalThis.getContext?.();
|
||||
if (!ctxid) {
|
||||
throw new Error("No active chat");
|
||||
}
|
||||
|
||||
const res = await callJsonApi("/plugins/compaction/compact_chat", {
|
||||
context: ctxid,
|
||||
action: "compact",
|
||||
});
|
||||
|
||||
if (!res?.ok) {
|
||||
throw new Error(res?.message || "Compaction failed");
|
||||
}
|
||||
|
||||
toastFrontendSuccess("Compaction started", "Compaction");
|
||||
this.showModal = false;
|
||||
} catch (e) {
|
||||
toastFrontendError(e.message, "Compaction");
|
||||
} finally {
|
||||
this.compacting = false;
|
||||
}
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.stats = null;
|
||||
},
|
||||
});
|
||||
104
plugins/chat_compaction/compaction/webui/config.html
Normal file
104
plugins/chat_compaction/compaction/webui/config.html
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Compaction Plugin Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data>
|
||||
<template x-if="config">
|
||||
<div>
|
||||
<div class="section-title">Compaction Configuration</div>
|
||||
<div class="section-description">
|
||||
Configure how the chat compaction feature works.
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Use chat model</div>
|
||||
<div class="field-description">
|
||||
When enabled, uses the currently selected chat model for compaction.
|
||||
When disabled, uses the utility model (usually faster and cheaper).
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config.use_chat_model" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text, #e5e5e5);
|
||||
}
|
||||
.section-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary, #999);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.field-label {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.field-title {
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e5e5e5);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.field-description {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #999);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-control {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle input {
|
||||
display: none;
|
||||
}
|
||||
.toggler {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--color-border, #444);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggler::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.toggle input:checked + .toggler {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
}
|
||||
.toggle input:checked + .toggler::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module">
|
||||
import { store } from "/components/plugins/plugin-settings-store.js";
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
plugins/chat_compaction/default_config.yaml
Normal file
1
plugins/chat_compaction/default_config.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
use_chat_model: true
|
||||
8
plugins/chat_compaction/plugin.yaml
Normal file
8
plugins/chat_compaction/plugin.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
name: compaction
|
||||
title: Chat Compaction
|
||||
description: Compact entire chat history into a single optimized summary message.
|
||||
version: 1.0.0
|
||||
settings_sections:
|
||||
- agent
|
||||
per_project_config: false
|
||||
per_agent_config: false
|
||||
5
plugins/chat_compaction/prompts/compact.msg.md
Normal file
5
plugins/chat_compaction/prompts/compact.msg.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Compact this conversation to its essential facts. Be maximally concise.
|
||||
|
||||
---
|
||||
{{conversation}}
|
||||
---
|
||||
17
plugins/chat_compaction/prompts/compact.sys.md
Normal file
17
plugins/chat_compaction/prompts/compact.sys.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
You are a conversation compactor. Produce the most concise summary possible while preserving critical information.
|
||||
|
||||
Rules:
|
||||
- Extract only: key decisions, final outcomes, actionable facts, unresolved items
|
||||
- Discard: intermediate reasoning, failed attempts, redundant exchanges, pleasantries
|
||||
- Use terse bullet points, not prose
|
||||
- Collapse related items into single lines
|
||||
- Keep exact values: file paths, config values, code identifiers, credentials, URLs
|
||||
- Omit anything that can be re-derived from context
|
||||
- Group by topic, not chronology
|
||||
- No meta-commentary about the summarization
|
||||
- Target: 10-20% of original length
|
||||
|
||||
Output format:
|
||||
- Markdown with short section headers
|
||||
- Bullet lists, no paragraphs
|
||||
- Code/paths in backticks inline, not fenced blocks unless multi-line
|
||||
261
plugins/chat_compaction/webui/compact-modal.html
Normal file
261
plugins/chat_compaction/webui/compact-modal.html
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Compact Chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data x-show="$store.compactStore?.showModal" style="display: none;">
|
||||
<div class="modal-overlay" @click="$store.compactStore?.closeModal()">
|
||||
<div class="modal-container" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>Compact Chat History</h3>
|
||||
<button class="modal-close" @click="$store.compactStore?.closeModal()">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
<template x-if="$store.compactStore?.stats">
|
||||
<div class="stats-container">
|
||||
<p class="stats-description">
|
||||
This will summarize your entire conversation into a single optimized message.
|
||||
</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Messages</span>
|
||||
<span class="stat-value" x-text="$store.compactStore?.stats?.message_count"></span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Tokens</span>
|
||||
<span class="stat-value" x-text="$store.compactStore?.stats?.token_count?.toLocaleString()"></span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Model</span>
|
||||
<span class="stat-value" x-text="$store.compactStore?.stats?.model_name"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-warning">
|
||||
<span class="material-symbols-outlined">warning</span>
|
||||
<span>This action cannot be undone. The original conversation will be replaced with a summary.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!$store.compactStore?.stats">
|
||||
<div class="stats-loading">
|
||||
<span class="loading-spinner"></span>
|
||||
<span>Loading statistics...</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="button secondary" @click="$store.compactStore?.closeModal()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="button primary danger"
|
||||
@click="$store.compactStore?.compact()"
|
||||
:disabled="$store.compactStore?.compacting || !$store.compactStore?.stats"
|
||||
>
|
||||
<template x-if="$store.compactStore?.compacting">
|
||||
<span class="loading-spinner"></span>
|
||||
</template>
|
||||
<span>Compact</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--color-background-elevated);
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stats-description {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-background-muted);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.stats-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-warning-bg, rgba(245, 158, 11, 0.1));
|
||||
border: 1px solid var(--color-warning-border, rgba(245, 158, 11, 0.3));
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--color-warning-text, #92400e);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stats-warning .material-symbols-outlined {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: var(--color-background-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.button.secondary:hover:not(:disabled) {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
.button.danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-hover, #b91c1c);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module">
|
||||
import { store } from "/plugins/compaction/webui/compact-store.js";
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
67
plugins/chat_compaction/webui/compact-store.js
Normal file
67
plugins/chat_compaction/webui/compact-store.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsonApi } from "/js/api.js";
|
||||
import {
|
||||
toastFrontendSuccess,
|
||||
toastFrontendError,
|
||||
} from "/components/notifications/notification-store.js";
|
||||
|
||||
export const store = createStore("compactStore", {
|
||||
compacting: false,
|
||||
stats: null,
|
||||
showModal: false,
|
||||
|
||||
async fetchStats() {
|
||||
try {
|
||||
const ctxid = globalThis.getContext?.();
|
||||
if (!ctxid) {
|
||||
toastFrontendError("No active chat", "Compaction");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await callJsonApi("/plugins/compaction/compact_chat", {
|
||||
context: ctxid,
|
||||
action: "stats",
|
||||
});
|
||||
|
||||
if (!res?.ok) {
|
||||
throw new Error(res?.message || "Failed to fetch stats");
|
||||
}
|
||||
|
||||
this.stats = res.stats;
|
||||
this.showModal = true;
|
||||
} catch (e) {
|
||||
toastFrontendError(e.message, "Compaction");
|
||||
}
|
||||
},
|
||||
|
||||
async compact() {
|
||||
this.compacting = true;
|
||||
try {
|
||||
const ctxid = globalThis.getContext?.();
|
||||
if (!ctxid) {
|
||||
throw new Error("No active chat");
|
||||
}
|
||||
|
||||
const res = await callJsonApi("/plugins/compaction/compact_chat", {
|
||||
context: ctxid,
|
||||
action: "compact",
|
||||
});
|
||||
|
||||
if (!res?.ok) {
|
||||
throw new Error(res?.message || "Compaction failed");
|
||||
}
|
||||
|
||||
toastFrontendSuccess("Compaction started", "Compaction");
|
||||
this.showModal = false;
|
||||
} catch (e) {
|
||||
toastFrontendError(e.message, "Compaction");
|
||||
} finally {
|
||||
this.compacting = false;
|
||||
}
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.stats = null;
|
||||
},
|
||||
});
|
||||
104
plugins/chat_compaction/webui/config.html
Normal file
104
plugins/chat_compaction/webui/config.html
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Compaction Plugin Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data>
|
||||
<template x-if="config">
|
||||
<div>
|
||||
<div class="section-title">Compaction Configuration</div>
|
||||
<div class="section-description">
|
||||
Configure how the chat compaction feature works.
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Use chat model</div>
|
||||
<div class="field-description">
|
||||
When enabled, uses the currently selected chat model for compaction.
|
||||
When disabled, uses the utility model (usually faster and cheaper).
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config.use_chat_model" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text, #e5e5e5);
|
||||
}
|
||||
.section-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary, #999);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.field-label {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.field-title {
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e5e5e5);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.field-description {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #999);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-control {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle input {
|
||||
display: none;
|
||||
}
|
||||
.toggler {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--color-border, #444);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggler::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.toggle input:checked + .toggler {
|
||||
background: var(--color-primary, #3b82f6);
|
||||
}
|
||||
.toggle input:checked + .toggler::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module">
|
||||
import { store } from "/components/plugins/plugin-settings-store.js";
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue