summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat')
-rw-r--r--src/features/chat/index.ts2
-rw-r--r--src/features/chat/ui/CompactionView.svelte153
2 files changed, 155 insertions, 0 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index 9b94392..9c65cd4 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -17,6 +17,8 @@ export {
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
export { default as ChatView } from "./ui/ChatView.svelte";
+export type { CompactNowResult, SaveCompactThresholdResult } from "./ui/CompactionView.svelte";
+export { default as CompactionView } from "./ui/CompactionView.svelte";
export { default as Composer } from "./ui/Composer.svelte";
export { default as ModelSelector } from "./ui/ModelSelector.svelte";
export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte";
diff --git a/src/features/chat/ui/CompactionView.svelte b/src/features/chat/ui/CompactionView.svelte
new file mode 100644
index 0000000..ce2a0a0
--- /dev/null
+++ b/src/features/chat/ui/CompactionView.svelte
@@ -0,0 +1,153 @@
+<script lang="ts">
+ export type CompactNowResult =
+ | { readonly ok: true; readonly messagesSummarized: number; readonly messagesKept: number }
+ | { readonly ok: false; readonly error: string };
+
+ export type SaveCompactThresholdResult =
+ | { readonly ok: true; readonly threshold: number }
+ | { readonly ok: false; readonly error: string };
+
+ let {
+ threshold,
+ canCompact,
+ compactNow,
+ saveThreshold,
+ }: {
+ /** The conversation's auto-compact threshold, or null when not yet fetched. 0 = disabled. */
+ threshold: number | null;
+ /** Whether a real conversation is focused (a draft has nothing to compact). */
+ canCompact: boolean;
+ compactNow: () => Promise<CompactNowResult | null>;
+ saveThreshold: (threshold: number) => Promise<SaveCompactThresholdResult | null>;
+ } = $props();
+
+ const DEFAULT_THRESHOLD = 350000;
+
+ let compacting = $state(false);
+ let compactError = $state<string | null>(null);
+ let compactResult = $state<{ summarized: number; kept: number } | null>(null);
+
+ let thresholdInput = $state("");
+ let savingThreshold = $state(false);
+ let thresholdError = $state<string | null>(null);
+ let thresholdSaved = $state(false);
+
+ // Sync the input from the prop when it changes (focus switch / initial load).
+ let lastThreshold = $state<number | null>(null);
+ $effect(() => {
+ if (threshold !== lastThreshold) {
+ lastThreshold = threshold;
+ thresholdInput = threshold !== null ? String(threshold) : "";
+ thresholdError = null;
+ thresholdSaved = false;
+ }
+ });
+
+ const thresholdLabel = $derived(
+ threshold == null
+ ? "Loading…"
+ : threshold === 0
+ ? "Disabled (manual only)"
+ : threshold === DEFAULT_THRESHOLD
+ ? `${threshold.toLocaleString("en-US")} (default)`
+ : threshold.toLocaleString("en-US"),
+ );
+
+ async function handleCompact() {
+ if (compacting || !canCompact) return;
+ compacting = true;
+ compactError = null;
+ compactResult = null;
+ const result = await compactNow();
+ compacting = false;
+ if (result === null) return;
+ if (result.ok) {
+ compactResult = { summarized: result.messagesSummarized, kept: result.messagesKept };
+ } else {
+ compactError = result.error;
+ }
+ }
+
+ async function handleSaveThreshold() {
+ const value = Number.parseInt(thresholdInput, 10);
+ if (Number.isNaN(value) || value < 0) {
+ thresholdError = "Must be a non-negative number";
+ return;
+ }
+ savingThreshold = true;
+ thresholdError = null;
+ thresholdSaved = false;
+ const result = await saveThreshold(value);
+ savingThreshold = false;
+ if (result === null) return;
+ if (result.ok) {
+ thresholdSaved = true;
+ } else {
+ thresholdError = result.error;
+ }
+ }
+</script>
+
+<div class="flex flex-col gap-3">
+ <!-- Manual compaction -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Manual compaction</span>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={!canCompact || compacting}
+ onclick={handleCompact}
+ >
+ {#if compacting}
+ <span class="loading loading-spinner loading-xs"></span>
+ Compacting…
+ {:else}
+ Compact now
+ {/if}
+ </button>
+ {#if !canCompact}
+ <p class="text-xs opacity-60">Open or start a conversation to compact its history.</p>
+ {:else if compactError}
+ <p class="text-xs text-error">{compactError}</p>
+ {:else if compactResult}
+ <p class="text-xs text-success">
+ Compacted — {compactResult.summarized} messages summarized, {compactResult.kept} kept.
+ </p>
+ {:else}
+ <p class="text-xs opacity-50">
+ Summarizes old messages into a system summary + retains the most recent messages.
+ </p>
+ {/if}
+ </section>
+
+ <!-- Auto-compact threshold -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Auto-compact threshold</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-32"
+ min="0"
+ placeholder={DEFAULT_THRESHOLD.toLocaleString("en-US")}
+ value={thresholdInput}
+ disabled={savingThreshold}
+ onchange={handleSaveThreshold}
+ aria-label="Compact threshold (tokens)"
+ />
+ <span class="text-xs opacity-60">tokens</span>
+ {#if savingThreshold}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <p class="text-xs opacity-50">
+ Current: {thresholdLabel}
+ <br />
+ 0 disables auto-compact. Default is {DEFAULT_THRESHOLD.toLocaleString("en-US")}.
+ </p>
+ {#if thresholdError}
+ <p class="text-xs text-error">{thresholdError}</p>
+ {:else if thresholdSaved}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ </section>
+</div>