diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index cf9bd81634..2cda405b4e 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -126,6 +126,12 @@ import { } from "../services/modelFamilyFallback.ts"; import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts"; import { compressContext, estimateTokens, getTokenLimit } from "../services/contextManager.ts"; +import { + selectCompressionStrategy, + applyCompression, +} from "../services/compression/strategySelector.ts"; +import { estimateCompressionTokens } from "../services/compression/stats.ts"; +import { getCompressionSettings } from "../../src/lib/db/compression.ts"; import { getBackgroundTaskReason, getDegradedModel, @@ -1562,7 +1568,7 @@ export async function handleChatCore({ if (body && Array.isArray(allMessages) && allMessages.length > 0) { let estimatedTokens = estimateTokens(JSON.stringify(allMessages)); - // --- Modular Compression Pipeline (Phase 1: Lite mode) --- + // --- Modular Compression Pipeline (Phase 1 Lite + Phase 2 Standard/Caveman) --- // Runs BEFORE the existing reactive compressContext() to proactively reduce tokens. try { const { getCompressionSettings } = await import("../../src/lib/db/compression.ts"); @@ -1572,7 +1578,7 @@ export async function handleChatCore({ const config = await getCompressionSettings(); const mode = selectCompressionStrategy(config, comboName ?? null, estimatedTokens); if (mode !== "off") { - const result = applyCompression(body, mode, { model: effectiveModel }); + const result = applyCompression(body, mode, { model: effectiveModel, config }); if (result.compressed && result.stats) { body = result.body as typeof body; estimatedTokens = result.stats.compressedTokens; @@ -1636,6 +1642,31 @@ export async function handleChatCore({ `Checking compression: ${estimatedTokens} tokens vs ${threshold} threshold (${contextLimit} limit, ${reservedTokens} reserved)` ); + // Prompt compression pipeline — Phase 1 (lite) + Phase 2 (standard/caveman) + try { + const compressionConfig = getCompressionSettings(); + const estimatedTokens = estimateCompressionTokens(body); + const mode = selectCompressionStrategy(compressionConfig, comboName ?? null, estimatedTokens); + if (mode !== "off") { + const compressionResult = applyCompression(body as Record, mode, { + model: effectiveModel, + config: compressionConfig, + }); + if (compressionResult.compressed && compressionResult.stats) { + body = compressionResult.body as typeof body; + const s = compressionResult.stats; + log?.info?.( + "COMPRESSION", + `Prompt compression (${s.mode}): ${s.originalTokens} → ${s.compressedTokens} tokens (${s.savingsPercent}% savings, ${s.durationMs ?? 0}ms${ + s.rulesApplied?.length ? `, rules: ${s.rulesApplied.join(", ")}` : "" + })` + ); + } + } + } catch (err) { + log?.warn?.("COMPRESSION", "Prompt compression failed (non-fatal): " + err); + } + if (estimatedTokens > threshold) { log?.info?.( "CONTEXT", diff --git a/open-sse/services/compression/caveman.ts b/open-sse/services/compression/caveman.ts new file mode 100644 index 0000000000..e0c40ecdfa --- /dev/null +++ b/open-sse/services/compression/caveman.ts @@ -0,0 +1,149 @@ +import type { CavemanConfig, CavemanRule, CompressionResult, CompressionMode } from "./types.ts"; +import { DEFAULT_CAVEMAN_CONFIG } from "./types.ts"; +import { CAVEMAN_RULES, getRulesForContext } from "./cavemanRules.ts"; +import { extractPreservedBlocks, restorePreservedBlocks } from "./preservation.ts"; +import { createCompressionStats, estimateCompressionTokens } from "./stats.ts"; + +interface ChatMessage { + role: string; + content?: string | Array<{ type: string; text?: string }>; +} + +interface ChatRequestBody { + messages?: ChatMessage[]; + [key: string]: unknown; +} + +export function applyRulesToText( + text: string, + rules: CavemanRule[] +): { text: string; appliedRules: string[] } { + let result = text; + const appliedRules: string[] = []; + + for (const rule of rules) { + const before = result; + const { pattern, replacement } = rule; + if (typeof replacement === "function") { + const fn = replacement; + result = result.replace(pattern, (...args) => { + const match = args[0]; + return fn(match, ...args.slice(1, -2)); + }); + } else { + result = result.replace(pattern, replacement); + } + if (result !== before) { + appliedRules.push(rule.name); + } + } + + return { text: result, appliedRules }; +} + +function cleanupArtifacts(text: string): string { + let result = text; + result = result.replace(/ +/g, " "); + result = result.replace(/ +$/gm, ""); + result = result.replace(/\n{3,}/g, "\n\n"); + result = result.replace(/^\n+/, ""); + result = result.replace(/\n+$/, ""); + return result; +} + +export function cavemanCompress( + body: ChatRequestBody, + options?: Partial +): CompressionResult { + const startMs = performance.now(); + const config: CavemanConfig = { ...DEFAULT_CAVEMAN_CONFIG, ...options }; + + const emptyResult = (): CompressionResult => ({ + body: body as unknown as Record, + compressed: false, + stats: createCompressionStats( + body as unknown as Record, + body as unknown as Record, + "standard" as CompressionMode, + [] + ), + }); + + if (!config.enabled) { + return emptyResult(); + } + + if (!body.messages || !Array.isArray(body.messages) || body.messages.length === 0) { + return emptyResult(); + } + + let totalOriginalTokens = 0; + let totalCompressedTokens = 0; + const allAppliedRules: string[] = []; + + const compressedMessages = body.messages.map((msg): ChatMessage => { + // Only compress simple string content — multi-part messages (arrays) + // would duplicate compressed text across all text parts if naively handled + if (typeof msg.content !== "string") { + const contentStr = Array.isArray(msg.content) + ? msg.content + .map((part) => (part.type === "text" && part.text ? part.text : "")) + .filter(Boolean) + .join("\n") + : ""; + totalOriginalTokens += estimateCompressionTokens(contentStr); + totalCompressedTokens += estimateCompressionTokens(contentStr); + return msg; + } + + const contentStr = msg.content; + totalOriginalTokens += estimateCompressionTokens(contentStr); + + if (!contentStr || contentStr.length < config.minMessageLength) { + totalCompressedTokens += estimateCompressionTokens(contentStr); + return msg; + } + + if (!config.compressRoles.includes(msg.role as "user" | "assistant" | "system")) { + totalCompressedTokens += estimateCompressionTokens(contentStr); + return msg; + } + + const { text: extractedText, blocks } = extractPreservedBlocks(contentStr); + + const rules = getRulesForContext(msg.role).filter( + (rule) => !config.skipRules.includes(rule.name) + ); + const { text: rulesApplied, appliedRules } = applyRulesToText(extractedText, rules); + allAppliedRules.push(...appliedRules); + + const restored = restorePreservedBlocks(rulesApplied, blocks); + + const cleaned = cleanupArtifacts(restored); + + totalCompressedTokens += estimateCompressionTokens(cleaned); + + return { ...msg, content: cleaned }; + }); + + const durationMs = performance.now() - startMs; + const uniqueRules = [...new Set(allAppliedRules)]; + const stats = createCompressionStats( + body as unknown as Record, + { ...body, messages: compressedMessages } as unknown as Record, + "standard" as CompressionMode, + uniqueRules.length > 0 ? ["caveman-rules"] : [], + uniqueRules.length > 0 ? uniqueRules : undefined, + Math.round(durationMs * 100) / 100 + ); + + const compressed = totalCompressedTokens < totalOriginalTokens; + + const result: CompressionResult = { + body: { ...body, messages: compressedMessages } as unknown as Record, + compressed, + stats, + }; + + return result; +} diff --git a/open-sse/services/compression/cavemanRules.ts b/open-sse/services/compression/cavemanRules.ts new file mode 100644 index 0000000000..51e9958d04 --- /dev/null +++ b/open-sse/services/compression/cavemanRules.ts @@ -0,0 +1,258 @@ +import type { CavemanRule } from "./types.ts"; + +const CAVEMAN_RULES: CavemanRule[] = [ + // ── Category 1: Filler Removal (10+ rules) ────────────────────────── + + { + name: "polite_framing", + pattern: + /\b(?:please|kindly|could you please|would you please|can you please|I would like you to|I want you to|I need you to)\b\s*/gi, + replacement: "", + context: "all", + }, + { + name: "hedging", + pattern: + /\b(?:it seems like|it appears that|I think that|I believe that|probably|possibly|maybe it)\b\s*/gi, + replacement: "", + context: "all", + }, + { + name: "verbose_instructions", + pattern: + /\b(?:provide a detailed|give me a comprehensive|write an in-depth|create a thorough|explain in detail)\b/gi, + replacement: (match: string): string => { + const map: Record = { + "provide a detailed": "provide", + "give me a comprehensive": "give", + "write an in-depth": "write", + "create a thorough": "create", + "explain in detail": "explain", + }; + const lower = match.toLowerCase(); + return map[lower] ?? match; + }, + context: "all", + }, + { + name: "filler_adverbs", + pattern: /(? { + const map: Record = { + "the function appears to be handling": "Function:", + "the code seems to": "Code:", + "the class is": "Class:", + "this module is": "Module:", + }; + return map[match.toLowerCase()] ?? match; + }, + context: "all", + }, + { + name: "question_to_directive", + pattern: + /\b(?:Can you explain why|Could you show me how|Would you tell me|Can you tell me)\b\s*/gi, + replacement: (match: string): string => { + const trimmed = match.trimEnd().toLowerCase(); + const map: Record = { + "can you explain why": "Explain why", + "could you show me how": "Show how", + "would you tell me": "Tell me", + "can you tell me": "Tell me", + }; + return map[trimmed] ?? match; + }, + context: "user", + }, + { + name: "context_setup", + pattern: /\b(?:I have the following code|Here is my code|Below is the code)\b\s*[:.]?\s*/gi, + replacement: "Code:", + context: "user", + }, + { + name: "intent_clarification", + pattern: + /\b(?:What I'm trying to do is|My objective is to|What I need is|I'm aiming to)\b\s*/gi, + replacement: "Goal:", + context: "user", + }, + { + name: "background_removal", + pattern: /\b(?:As you may know,?\s*|As we discussed earlier,?\s*)/gi, + replacement: "", + context: "all", + }, + { + name: "meta_commentary", + pattern: /^(?:Note that|Keep in mind that|Remember that)\b\s*/gim, + replacement: "", + context: "all", + }, + { + name: "purpose_statement", + pattern: /\b(?:for the purpose of|with the goal of|in an effort to)\b/gi, + replacement: (match: string): string => { + const map: Record = { + "for the purpose of": "for", + "with the goal of": "to", + "in an effort to": "to", + }; + return map[match.toLowerCase()] ?? match; + }, + context: "all", + }, + + // ── Category 3: Structural Compression (7+ rules) ──────────────────── + + { + name: "list_conjunction", + pattern: /,\s*and also\s+|,\s*as well as\s+/gi, + replacement: ", ", + context: "all", + }, + { + name: "purpose_phrases", + pattern: /\b(?:in order to|so as to)\b\s*/gi, + replacement: "to ", + context: "all", + }, + { + name: "redundant_quantifiers", + pattern: /\b(?:each and every single|each and every|any and all)\b/gi, + replacement: (match: string): string => { + const map: Record = { + "each and every single": "each", + "each and every": "each", + "any and all": "all", + }; + return map[match.toLowerCase()] ?? match; + }, + context: "all", + }, + { + name: "verbose_connectors", + pattern: /\b(?:furthermore|additionally|moreover|in addition)\b\s*/gi, + replacement: "also ", + context: "all", + }, + { + name: "transition_removal", + pattern: /^(?:On the other hand,?\s*|In contrast,?\s*|However,?\s*)/gim, + replacement: "", + context: "all", + }, + { + name: "emphasis_removal", + pattern: /\b(?:very|really|extremely|highly|quite)\s+(?=[a-z])/gi, + replacement: "", + context: "all", + }, + { + name: "passive_voice", + pattern: /\b(?:is being used|is being called|was created|was generated|was implemented)\b/gi, + replacement: (match: string): string => { + const map: Record = { + "is being used": "uses", + "is being called": "calls", + "was created": "created", + "was generated": "generated", + "was implemented": "implemented", + }; + return map[match.toLowerCase()] ?? match; + }, + context: "all", + }, + + // ── Category 4: Multi-Turn Dedup (5+ rules) ───────────────────────── + + { + name: "repeated_context", + pattern: + /\b(?:As we discussed earlier|As mentioned before|As previously stated|As I said before)\b[,.]?\s*/gi, + replacement: "See above. ", + context: "all", + }, + { + name: "repeated_question", + pattern: + /\b(?:Same question as before|I asked this earlier|This is the same question)\b[,.]?\s*/gi, + replacement: "[same question] ", + context: "user", + }, + { + name: "reestablished_context", + pattern: /\b(?:Going back to the code above|Referring back to|Returning to)\b\s*/gi, + replacement: "Re: ", + context: "all", + }, + { + name: "summary_replacement", + pattern: + /\b(?:To summarize what we've discussed|In summary of our conversation|To recap)\b[,.]?\s*/gi, + replacement: "Summary: ", + context: "assistant", + }, +]; + +export function getRulesForContext(context: string): CavemanRule[] { + return CAVEMAN_RULES.filter((rule) => rule.context === "all" || rule.context === context); +} + +export function getRuleByName(name: string): CavemanRule | undefined { + return CAVEMAN_RULES.find((rule) => rule.name === name); +} + +export { CAVEMAN_RULES }; diff --git a/open-sse/services/compression/index.ts b/open-sse/services/compression/index.ts index d1f69eb6af..697a2de887 100644 --- a/open-sse/services/compression/index.ts +++ b/open-sse/services/compression/index.ts @@ -1,20 +1,14 @@ -/** - * Compression Pipeline — Phase 1 (Lite mode) - * - * Modular prompt compression that runs BEFORE the existing reactive context manager. - * Lite mode: 5 techniques, ~10-15% token savings, <1ms latency. - */ - -// Types export type { CompressionMode, CompressionConfig, CompressionStats, CompressionResult, -} from "./types"; -export { DEFAULT_COMPRESSION_CONFIG } from "./types"; + CavemanConfig, + CavemanRule, +} from "./types.ts"; + +export { DEFAULT_COMPRESSION_CONFIG, DEFAULT_CAVEMAN_CONFIG } from "./types.ts"; -// Lite compression techniques export { applyLiteCompression, collapseWhitespace, @@ -22,10 +16,23 @@ export { compressToolResults, removeRedundantContent, replaceImageUrls, -} from "./lite"; +} from "./lite.ts"; -// Strategy selector -export { selectCompressionStrategy, getEffectiveMode, applyCompression } from "./strategySelector"; +export { cavemanCompress, applyRulesToText } from "./caveman.ts"; +export { getRulesForContext, CAVEMAN_RULES } from "./cavemanRules.ts"; +export { extractPreservedBlocks, restorePreservedBlocks } from "./preservation.ts"; -// Stats tracking -export { estimateCompressionTokens, createCompressionStats, trackCompressionStats } from "./stats"; +export { + estimateCompressionTokens, + createCompressionStats, + trackCompressionStats, + getDefaultCompressionConfig, +} from "./stats.ts"; + +export { + selectCompressionStrategy, + getEffectiveMode, + applyCompression, + checkComboOverride, + shouldAutoTrigger, +} from "./strategySelector.ts"; diff --git a/open-sse/services/compression/lite.ts b/open-sse/services/compression/lite.ts index bc64e13ef6..8ee5b6d4f9 100644 --- a/open-sse/services/compression/lite.ts +++ b/open-sse/services/compression/lite.ts @@ -1,5 +1,5 @@ -import type { CompressionResult, CompressionMode } from "./types"; -import { createCompressionStats } from "./stats"; +import type { CompressionResult, CompressionMode } from "./types.ts"; +import { createCompressionStats } from "./stats.ts"; interface Message { role: string; @@ -157,7 +157,7 @@ export function applyLiteCompression( ? createCompressionStats( originalBody, current as Record, - "lite", + "lite" as CompressionMode, techniquesApplied ) : null; diff --git a/open-sse/services/compression/preservation.ts b/open-sse/services/compression/preservation.ts new file mode 100644 index 0000000000..f1491f5d58 --- /dev/null +++ b/open-sse/services/compression/preservation.ts @@ -0,0 +1,53 @@ +interface PreservedBlock { + placeholder: string; + content: string; +} + +export function extractPreservedBlocks(text: string): { text: string; blocks: PreservedBlock[] } { + const blocks: PreservedBlock[] = []; + let result = text; + let counter = 0; + + const addBlock = (content: string): string => { + const placeholder = `[PRESERVED_${counter}]`; + blocks.push({ placeholder, content }); + counter++; + return placeholder; + }; + + // Extract fenced code blocks (```lang\n...\n```) + result = result.replace(/```[a-z]*\n[\s\S]*?\n```/g, (match) => addBlock(match)); + + // Extract inline code (`...`) + result = result.replace(/`[^`\n]+`/g, (match) => addBlock(match)); + + // Extract URLs + result = result.replace(/https?:\/\/[^\s)\]"'>]+/g, (match) => addBlock(match)); + + // Extract file paths (Unix and Windows) + result = result.replace(/(?:\/[a-zA-Z0-9_./-]+|[a-zA-Z]:\\[a-zA-Z0-9_.\\/-]+)/g, (match) => { + if (match.length < 3) return match; + if (match.startsWith("[PRESERVED_")) return match; + return addBlock(match); + }); + + // Extract error-like patterns (TypeError:, Error:, 404, etc.) + result = result.replace( + /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|URIError|EvalError|Error):\s*[^\s,;)]+/g, + (match) => addBlock(match) + ); + + return { text: result, blocks }; +} + +export function restorePreservedBlocks(text: string, blocks: PreservedBlock[]): string { + let result = text; + for (const block of blocks) { + result = result.replace(block.placeholder, () => block.content); + } + return result; +} + +export function shouldPreserve(text: string, preservePatterns: RegExp[]): boolean { + return preservePatterns.some((pattern) => pattern.test(text)); +} diff --git a/open-sse/services/compression/stats.ts b/open-sse/services/compression/stats.ts index 5e84f0b22e..3f878c97b2 100644 --- a/open-sse/services/compression/stats.ts +++ b/open-sse/services/compression/stats.ts @@ -1,4 +1,10 @@ -import type { CompressionStats, CompressionMode } from "./types"; +import { + type CompressionMode, + type CompressionStats, + type CompressionConfig, + DEFAULT_COMPRESSION_CONFIG, + DEFAULT_CAVEMAN_CONFIG, +} from "./types.ts"; const CHARS_PER_TOKEN = 4; @@ -8,11 +14,16 @@ export function estimateCompressionTokens(text: string | object | null | undefin return Math.ceil(str.length / CHARS_PER_TOKEN); } +/** @deprecated Use estimateCompressionTokens instead. */ +export const estimateTokensForStats = estimateCompressionTokens; + export function createCompressionStats( originalBody: Record, compressedBody: Record, mode: CompressionMode, - techniquesUsed: string[] + techniquesUsed: string[], + rulesApplied?: string[], + durationMs?: number ): CompressionStats { const originalTokens = estimateCompressionTokens(originalBody); const compressedTokens = estimateCompressionTokens(compressedBody); @@ -27,12 +38,23 @@ export function createCompressionStats( techniquesUsed, mode, timestamp: Date.now(), + ...(rulesApplied && rulesApplied.length > 0 ? { rulesApplied } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), }; } export function trackCompressionStats(stats: CompressionStats): void { if (stats.originalTokens <= 0) return; + const rulesInfo = stats.rulesApplied?.length ? ` rules=${stats.rulesApplied.join(",")}` : ""; + const durationInfo = stats.durationMs !== undefined ? ` ${stats.durationMs}ms` : ""; console.log( - `[COMPRESSION] mode=${stats.mode} tokens=${stats.originalTokens}->${stats.compressedTokens} savings=${stats.savingsPercent}% techniques=${stats.techniquesUsed.join(",")}` + `[COMPRESSION] mode=${stats.mode} tokens=${stats.originalTokens}->${stats.compressedTokens} savings=${stats.savingsPercent}% techniques=${stats.techniquesUsed.join(",")}${rulesInfo}${durationInfo}` ); } + +export function getDefaultCompressionConfig(): CompressionConfig { + return { + ...DEFAULT_COMPRESSION_CONFIG, + cavemanConfig: { ...DEFAULT_CAVEMAN_CONFIG }, + }; +} diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 0b89a32651..99da6340b9 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -1,5 +1,6 @@ -import type { CompressionConfig, CompressionMode, CompressionResult } from "./types"; -import { applyLiteCompression } from "./lite"; +import type { CompressionConfig, CompressionMode, CompressionResult } from "./types.ts"; +import { applyLiteCompression } from "./lite.ts"; +import { cavemanCompress } from "./caveman.ts"; export function checkComboOverride( config: CompressionConfig, @@ -39,7 +40,7 @@ export function selectCompressionStrategy( export function applyCompression( body: Record, mode: CompressionMode, - options?: { model?: string } + options?: { model?: string; config?: CompressionConfig } ): CompressionResult { if (mode === "off") { return { body, compressed: false, stats: null }; @@ -47,5 +48,11 @@ export function applyCompression( if (mode === "lite") { return applyLiteCompression(body, options); } + if (mode === "standard") { + return cavemanCompress( + body as Parameters[0], + options?.config?.cavemanConfig + ); + } return { body, compressed: false, stats: null }; } diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 4d37d2f9a9..98b1446ca3 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -1,13 +1,27 @@ /** - * Compression Pipeline Types — Phase 1 (Lite mode) + * Compression Pipeline Types — Phase 1 (Lite) + Phase 2 (Standard/Caveman) * - * Modular prompt compression that runs BEFORE the existing reactive context manager. + * Shared type definitions for the compression pipeline. */ -/** Compression mode levels (only 'off' and 'lite' are active in Phase 1) */ export type CompressionMode = "off" | "lite" | "standard" | "aggressive" | "ultra"; -/** Compression configuration stored in DB */ +export interface CavemanRule { + name: string; + pattern: RegExp; + replacement: string | ((match: string, ...groups: string[]) => string); + context: "all" | "user" | "system" | "assistant"; + preservePatterns?: RegExp[]; +} + +export interface CavemanConfig { + enabled: boolean; + compressRoles: ("user" | "assistant" | "system")[]; + skipRules: string[]; + minMessageLength: number; + preservePatterns: string[]; +} + export interface CompressionConfig { enabled: boolean; defaultMode: CompressionMode; @@ -15,9 +29,9 @@ export interface CompressionConfig { cacheMinutes: number; preserveSystemPrompt: boolean; comboOverrides: Record; + cavemanConfig?: CavemanConfig; } -/** Per-request compression statistics */ export interface CompressionStats { originalTokens: number; compressedTokens: number; @@ -25,16 +39,16 @@ export interface CompressionStats { techniquesUsed: string[]; mode: CompressionMode; timestamp: number; + rulesApplied?: string[]; + durationMs?: number; } -/** Result of a compression operation */ export interface CompressionResult { body: Record; compressed: boolean; stats: CompressionStats | null; } -/** Default compression config values */ export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { enabled: false, defaultMode: "off", @@ -43,3 +57,11 @@ export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = { preserveSystemPrompt: true, comboOverrides: {}, }; + +export const DEFAULT_CAVEMAN_CONFIG: CavemanConfig = { + enabled: true, + compressRoles: ["user"], + skipRules: [], + minMessageLength: 50, + preservePatterns: [], +}; diff --git a/src/app/(dashboard)/dashboard/logs/CompressionLogTab.tsx b/src/app/(dashboard)/dashboard/logs/CompressionLogTab.tsx new file mode 100644 index 0000000000..fafd577750 --- /dev/null +++ b/src/app/(dashboard)/dashboard/logs/CompressionLogTab.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +interface CompressionStats { + originalTokens: number; + compressedTokens: number; + savingsPercent: number; + techniquesUsed: string[]; + mode: string; + timestamp: number; + rulesApplied?: string[]; + durationMs?: number; +} + +interface LogEntry { + id: string; + timestamp: string; + model: string; + provider: string; + compressionStats?: CompressionStats | null; +} + +export default function CompressionLogTab() { + const t = useTranslations("settings"); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/logs?filter=compressed&limit=50") + .then((r) => (r.ok ? r.json() : [])) + .then((data) => { + setLogs(Array.isArray(data) ? data : []); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( + +

{t("loading")}

+
+ ); + } + + if (logs.length === 0) { + return ( + +
+ compress +

{t("compressionLogTitle")}

+
+

{t("compressionLogEmpty")}

+
+ ); + } + + return ( + +
+ compress +

{t("compressionLogTitle")}

+
+ +
+ {logs.map((entry) => { + const stats = entry.compressionStats; + if (!stats) return null; + + return ( +
+
+
+ + {entry.provider}/{entry.model} + + + {stats.mode} + +
+
+ + {stats.originalTokens} → {stats.compressedTokens} {t("tokens")} + + = 25 + ? "text-emerald-400" + : stats.savingsPercent >= 10 + ? "text-yellow-400" + : "text-text-muted" + }`} + > + -{stats.savingsPercent.toFixed(1)}% + + {stats.durationMs !== undefined && {stats.durationMs}ms} +
+
+ + {stats.techniquesUsed.length > 0 && ( +
+ {stats.techniquesUsed.map((technique) => ( + + {technique} + + ))} +
+ )} + + {stats.rulesApplied && stats.rulesApplied.length > 0 && ( +
+ {stats.rulesApplied.map((rule) => ( + + {rule.replace(/_/g, " ")} + + ))} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx new file mode 100644 index 0000000000..afb0ca04a6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx @@ -0,0 +1,425 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +type CompressionMode = "off" | "lite" | "standard" | "aggressive" | "ultra"; + +interface CavemanConfig { + enabled: boolean; + compressRoles: ("user" | "assistant" | "system")[]; + skipRules: string[]; + minMessageLength: number; + preservePatterns: string[]; +} + +interface CompressionConfig { + enabled: boolean; + defaultMode: CompressionMode; + autoTriggerTokens: number; + cacheMinutes: number; + preserveSystemPrompt: boolean; + comboOverrides: Record; + cavemanConfig?: CavemanConfig; +} + +const MODES: { value: CompressionMode; labelKey: string; descKey: string; icon: string }[] = [ + { + value: "off", + labelKey: "compressionModeOff", + descKey: "compressionModeOffDesc", + icon: "block", + }, + { + value: "lite", + labelKey: "compressionModeLite", + descKey: "compressionModeLiteDesc", + icon: "compress", + }, + { + value: "standard", + labelKey: "compressionModeStandard", + descKey: "compressionModeStandardDesc", + icon: "speed", + }, +]; + +const ROLE_OPTIONS: { value: "user" | "assistant" | "system"; labelKey: string }[] = [ + { value: "user", labelKey: "compressionRoleUser" }, + { value: "assistant", labelKey: "compressionRoleAssistant" }, + { value: "system", labelKey: "compressionRoleSystem" }, +]; + +const ALL_CAVEMAN_RULES = [ + "polite_framing", + "hedging", + "verbose_instructions", + "filler_adverbs", + "filler_phrases", + "redundant_openers", + "verbose_requests", + "self_reference", + "excessive_gratitude", + "qualifier_removal", + "compound_collapse", + "explanatory_prefix", + "question_to_directive", + "context_setup", + "intent_clarification", + "background_removal", + "meta_commentary", + "purpose_statement", + "list_conjunction", + "purpose_phrases", + "redundant_quantifiers", + "verbose_connectors", + "transition_removal", + "emphasis_removal", + "passive_voice", + "repeated_context", + "repeated_question", + "reestablished_context", + "summary_replacement", +]; + +export default function CompressionSettingsTab() { + const t = useTranslations("settings"); + const [config, setConfig] = useState({ + enabled: false, + defaultMode: "off", + autoTriggerTokens: 0, + cacheMinutes: 5, + preserveSystemPrompt: true, + comboOverrides: {}, + cavemanConfig: { + enabled: true, + compressRoles: ["user"], + skipRules: [], + minMessageLength: 50, + preservePatterns: [], + }, + }); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + const [status, setStatus] = useState<"" | "saved" | "error">(""); + + useEffect(() => { + fetch("/api/settings/compression") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data) setConfig(data); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const save = async (updates: Partial) => { + const newConfig = { ...config, ...updates }; + setConfig(newConfig); + setSaving(true); + setStatus(""); + try { + const res = await fetch("/api/settings/compression", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newConfig), + }); + if (res.ok) { + setStatus("saved"); + setTimeout(() => setStatus(""), 2000); + } else { + setStatus("error"); + } + } catch { + setStatus("error"); + } finally { + setSaving(false); + } + }; + + const toggleCavemanRole = (role: "user" | "assistant" | "system") => { + const currentRoles = config.cavemanConfig?.compressRoles ?? ["user"]; + const newRoles = currentRoles.includes(role) + ? currentRoles.filter((r) => r !== role) + : [...currentRoles, role]; + save({ + cavemanConfig: { ...config.cavemanConfig!, compressRoles: newRoles }, + }); + }; + + const toggleCavemanRule = (rule: string) => { + const currentSkip = config.cavemanConfig?.skipRules ?? []; + const newSkip = currentSkip.includes(rule) + ? currentSkip.filter((r) => r !== rule) + : [...currentSkip, rule]; + save({ + cavemanConfig: { ...config.cavemanConfig!, skipRules: newSkip }, + }); + }; + + if (loading) { + return ( + +

{t("loading")}

+
+ ); + } + + return ( + +
+
+ +
+
+

{t("compressionTitle")}

+

{t("compressionDesc")}

+
+ {status === "saved" && ( + + check_circle {t("saved")} + + )} + {status === "error" && ( + + error {t("saveFailed")} + + )} +
+ +
+ + + {config.enabled && ( +
+

{t("compressionMode")}

+
+ {MODES.map((m) => ( + + ))} +
+
+ )} + + {config.enabled && ( +
+

{t("compressionGeneral")}

+ + + + + + +
+ )} + + {config.enabled && + config.defaultMode !== "off" && + config.defaultMode !== "lite" && + config.cavemanConfig && ( +
+
+
+

+ {t("compressionCavemanConfig")} +

+

+ {t("compressionCavemanConfigDesc")} +

+
+ +
+ + {config.cavemanConfig.enabled && ( + <> +
+

{t("compressionRoles")}

+
+ {ROLE_OPTIONS.map((opt) => ( + + ))} +
+
+ + + +
+

{t("compressionSkipRules")}

+

{t("compressionSkipRulesDesc")}

+
+ {ALL_CAVEMAN_RULES.map((rule) => ( + + ))} +
+
+ +
+

{t("compressionPreservePatterns")}

+

+ {t("compressionPreservePatternsDesc")} +

+