mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 02:04:05 +00:00
Refactor agent architecture and add session storage
Major architectural improvements: - Renamed AgentSession → Agent (state/ → agent/) - Removed id field from AgentState - Fixed transport abstraction to pass messages directly instead of using callbacks - Eliminated circular dependencies in transport creation Transport changes: - Changed signature: run(messages, userMessage, config, signal) - Removed getMessages callback from ProviderTransport and AppTransport - Transports now filter attachments internally Session storage: - Added SessionRepository with IndexedDB backend - Auto-save sessions after first exchange - Auto-generate titles from first user message - Session list dialog with search and delete - Persistent storage permission dialog - Browser extension now auto-loads last session UI improvements: - ChatPanel creates single AgentInterface instance in setAgent() - Added drag & drop file upload to MessageEditor - Fixed artifacts panel auto-opening on session load - Added "Drop files here" i18n strings - Changed "Continue Without Saving" → "Continue Anyway" Web example: - Complete rewrite of main.ts with clean architecture - Added check script to package.json - Session management with URL state - Editable session titles Browser extension: - Added full session storage support - History and new session buttons - Auto-load most recent session on open - Session titles in header
This commit is contained in:
parent
c18923a8c5
commit
e5cf25a267
23 changed files with 1787 additions and 289 deletions
|
|
@ -64,11 +64,8 @@ export class ApiKeyPromptDialog extends DialogBase {
|
|||
children: html`
|
||||
${DialogHeader({
|
||||
title: i18n("API Key Required"),
|
||||
description: i18n("Enter your API key for {provider}").replace("{provider}", this.provider),
|
||||
})}
|
||||
<div class="mt-4">
|
||||
<provider-key-input .provider=${this.provider}></provider-key-input>
|
||||
</div>
|
||||
<provider-key-input .provider=${this.provider}></provider-key-input>
|
||||
`,
|
||||
})}
|
||||
`;
|
||||
|
|
|
|||
141
packages/web-ui/src/dialogs/PersistentStorageDialog.ts
Normal file
141
packages/web-ui/src/dialogs/PersistentStorageDialog.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { Button, DialogBase, DialogContent, DialogHeader, html } from "@mariozechner/mini-lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
@customElement("persistent-storage-dialog")
|
||||
export class PersistentStorageDialog extends DialogBase {
|
||||
@state() private requesting = false;
|
||||
|
||||
private resolvePromise?: (userApproved: boolean) => void;
|
||||
|
||||
protected modalWidth = "min(500px, 90vw)";
|
||||
protected modalHeight = "auto";
|
||||
|
||||
/**
|
||||
* Request persistent storage permission.
|
||||
* Returns true if browser granted persistent storage, false otherwise.
|
||||
*/
|
||||
static async request(): Promise<boolean> {
|
||||
// Check if already persisted
|
||||
if (navigator.storage?.persisted) {
|
||||
const alreadyPersisted = await navigator.storage.persisted();
|
||||
if (alreadyPersisted) {
|
||||
console.log("✓ Persistent storage already granted");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Show dialog and wait for user response
|
||||
const dialog = new PersistentStorageDialog();
|
||||
dialog.open();
|
||||
|
||||
const userApproved = await new Promise<boolean>((resolve) => {
|
||||
dialog.resolvePromise = resolve;
|
||||
});
|
||||
|
||||
if (!userApproved) {
|
||||
console.warn("⚠ User declined persistent storage - sessions may be lost");
|
||||
return false;
|
||||
}
|
||||
|
||||
// User approved, request from browser
|
||||
if (!navigator.storage?.persist) {
|
||||
console.warn("⚠ Persistent storage API not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const granted = await navigator.storage.persist();
|
||||
if (granted) {
|
||||
console.log("✓ Persistent storage granted - sessions will be preserved");
|
||||
} else {
|
||||
console.warn("⚠ Browser denied persistent storage - sessions may be lost under storage pressure");
|
||||
}
|
||||
return granted;
|
||||
} catch (error) {
|
||||
console.error("Failed to request persistent storage:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleGrant() {
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise(true);
|
||||
this.resolvePromise = undefined;
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
private handleDeny() {
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise(false);
|
||||
this.resolvePromise = undefined;
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
override close() {
|
||||
super.close();
|
||||
if (this.resolvePromise) {
|
||||
this.resolvePromise(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected override renderContent() {
|
||||
return html`
|
||||
${DialogContent({
|
||||
children: html`
|
||||
${DialogHeader({
|
||||
title: i18n("Storage Permission Required"),
|
||||
description: i18n("This app needs persistent storage to save your conversations"),
|
||||
})}
|
||||
|
||||
<div class="mt-4 flex flex-col gap-4">
|
||||
<div class="flex gap-3 p-4 bg-warning/10 border border-warning/20 rounded-lg">
|
||||
<div class="flex-shrink-0 text-warning">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-foreground mb-1">${i18n("Why is this needed?")}</p>
|
||||
<p class="text-muted-foreground">
|
||||
${i18n(
|
||||
"Without persistent storage, your browser may delete saved conversations when it needs disk space. Granting this permission ensures your chat history is preserved.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<p class="mb-2">${i18n("What this means:")}</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2">
|
||||
<li>${i18n("Your conversations will be saved locally in your browser")}</li>
|
||||
<li>${i18n("Data will not be deleted automatically to free up space")}</li>
|
||||
<li>${i18n("You can still manually clear data at any time")}</li>
|
||||
<li>${i18n("No data is sent to external servers")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3 justify-end">
|
||||
${Button({
|
||||
variant: "outline",
|
||||
onClick: () => this.handleDeny(),
|
||||
disabled: this.requesting,
|
||||
children: i18n("Continue Anyway"),
|
||||
})}
|
||||
${Button({
|
||||
variant: "default",
|
||||
onClick: () => this.handleGrant(),
|
||||
disabled: this.requesting,
|
||||
children: this.requesting ? i18n("Requesting...") : i18n("Grant Permission"),
|
||||
})}
|
||||
</div>
|
||||
`,
|
||||
})}
|
||||
`;
|
||||
}
|
||||
}
|
||||
135
packages/web-ui/src/dialogs/SessionListDialog.ts
Normal file
135
packages/web-ui/src/dialogs/SessionListDialog.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { DialogBase, DialogContent, DialogHeader, html } from "@mariozechner/mini-lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
import type { SessionMetadata } from "../storage/types.js";
|
||||
import { formatUsage } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
|
||||
@customElement("session-list-dialog")
|
||||
export class SessionListDialog extends DialogBase {
|
||||
@state() private sessions: SessionMetadata[] = [];
|
||||
@state() private loading = true;
|
||||
|
||||
private onSelectCallback?: (sessionId: string) => void;
|
||||
|
||||
protected modalWidth = "min(600px, 90vw)";
|
||||
protected modalHeight = "min(700px, 90vh)";
|
||||
|
||||
static async open(onSelect: (sessionId: string) => void) {
|
||||
const dialog = new SessionListDialog();
|
||||
dialog.onSelectCallback = onSelect;
|
||||
dialog.open();
|
||||
await dialog.loadSessions();
|
||||
}
|
||||
|
||||
private async loadSessions() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
if (!storage.sessions) {
|
||||
console.error("Session storage not available");
|
||||
this.sessions = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessions = await storage.sessions.listSessions();
|
||||
} catch (err) {
|
||||
console.error("Failed to load sessions:", err);
|
||||
this.sessions = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDelete(sessionId: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!confirm(i18n("Delete this session?"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getAppStorage();
|
||||
if (!storage.sessions) return;
|
||||
|
||||
await storage.sessions.deleteSession(sessionId);
|
||||
await this.loadSessions();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete session:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelect(sessionId: string) {
|
||||
if (this.onSelectCallback) {
|
||||
this.onSelectCallback(sessionId);
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
private formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
return i18n("Today");
|
||||
} else if (days === 1) {
|
||||
return i18n("Yesterday");
|
||||
} else if (days < 7) {
|
||||
return i18n("{days} days ago").replace("{days}", days.toString());
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
protected override renderContent() {
|
||||
return html`
|
||||
${DialogContent({
|
||||
className: "h-full flex flex-col",
|
||||
children: html`
|
||||
${DialogHeader({
|
||||
title: i18n("Sessions"),
|
||||
description: i18n("Load a previous conversation"),
|
||||
})}
|
||||
|
||||
<div class="flex-1 overflow-y-auto mt-4 space-y-2">
|
||||
${
|
||||
this.loading
|
||||
? html`<div class="text-center py-8 text-muted-foreground">${i18n("Loading...")}</div>`
|
||||
: this.sessions.length === 0
|
||||
? html`<div class="text-center py-8 text-muted-foreground">${i18n("No sessions yet")}</div>`
|
||||
: this.sessions.map(
|
||||
(session) => html`
|
||||
<div
|
||||
class="group flex items-start gap-3 p-3 rounded-lg border border-border hover:bg-secondary/50 cursor-pointer transition-colors"
|
||||
@click=${() => this.handleSelect(session.id)}
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm text-foreground truncate">${session.title}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">${this.formatDate(session.lastModified)}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
${session.messageCount} ${i18n("messages")} · ${formatUsage(session.usage)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 text-destructive transition-opacity"
|
||||
@click=${(e: Event) => this.handleDelete(session.id, e)}
|
||||
title=${i18n("Delete")}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18"></path>
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})}
|
||||
`;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue