mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 20:01:24 +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
|
|
@ -1,57 +1,309 @@
|
|||
import { Button, icon } from "@mariozechner/mini-lit";
|
||||
import { Button, icon, Input } from "@mariozechner/mini-lit";
|
||||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||
import { ApiKeyPromptDialog, ApiKeysTab, ChatPanel, initAppStorage, ProxyTab, SettingsDialog } from "@mariozechner/pi-web-ui";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
Agent,
|
||||
AgentState,
|
||||
ApiKeyPromptDialog,
|
||||
ApiKeysTab,
|
||||
AppStorage,
|
||||
ChatPanel,
|
||||
PersistentStorageDialog,
|
||||
ProviderTransport,
|
||||
ProxyTab,
|
||||
SessionIndexedDBBackend,
|
||||
SessionListDialog,
|
||||
setAppStorage,
|
||||
SettingsDialog,
|
||||
} from "@mariozechner/pi-web-ui";
|
||||
import type { AppMessage } from "@mariozechner/pi-web-ui";
|
||||
import { html, render } from "lit";
|
||||
import { Settings } from "lucide";
|
||||
import { History, Plus, Settings } from "lucide";
|
||||
import "./app.css";
|
||||
|
||||
// Initialize storage with default configuration (localStorage)
|
||||
initAppStorage();
|
||||
const storage = new AppStorage({
|
||||
sessions: new SessionIndexedDBBackend("pi-web-ui-sessions"),
|
||||
});
|
||||
setAppStorage(storage);
|
||||
|
||||
const systemPrompt = `You are a helpful AI assistant with access to various tools.
|
||||
let currentSessionId: string | undefined;
|
||||
let currentTitle = "";
|
||||
let isEditingTitle = false;
|
||||
let agent: Agent;
|
||||
let chatPanel: ChatPanel;
|
||||
let agentUnsubscribe: (() => void) | undefined;
|
||||
|
||||
const generateTitle = (messages: AppMessage[]): string => {
|
||||
const firstUserMsg = messages.find((m) => m.role === "user");
|
||||
if (!firstUserMsg || firstUserMsg.role !== "user") return "";
|
||||
|
||||
let text = "";
|
||||
const content = firstUserMsg.content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else {
|
||||
const textBlocks = content.filter((c: any) => c.type === "text");
|
||||
text = textBlocks.map((c: any) => c.text || "").join(" ");
|
||||
}
|
||||
|
||||
text = text.trim();
|
||||
if (!text) return "";
|
||||
|
||||
const sentenceEnd = text.search(/[.!?]/);
|
||||
if (sentenceEnd > 0 && sentenceEnd <= 50) {
|
||||
return text.substring(0, sentenceEnd + 1);
|
||||
}
|
||||
return text.length <= 50 ? text : text.substring(0, 47) + "...";
|
||||
};
|
||||
|
||||
const shouldSaveSession = (messages: AppMessage[]): boolean => {
|
||||
const hasUserMsg = messages.some((m: any) => m.role === "user");
|
||||
const hasAssistantMsg = messages.some((m: any) => m.role === "assistant");
|
||||
return hasUserMsg && hasAssistantMsg;
|
||||
};
|
||||
|
||||
const saveSession = async () => {
|
||||
if (!storage.sessions || !currentSessionId || !agent || !currentTitle) return;
|
||||
|
||||
const state = agent.state;
|
||||
if (!shouldSaveSession(state.messages)) return;
|
||||
|
||||
try {
|
||||
await storage.sessions.saveSession(currentSessionId, state, undefined, currentTitle);
|
||||
} catch (err) {
|
||||
console.error("Failed to save session:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUrl = (sessionId: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("session", sessionId);
|
||||
window.history.replaceState({}, "", url);
|
||||
};
|
||||
|
||||
const createAgent = async (initialState?: Partial<AgentState>) => {
|
||||
if (agentUnsubscribe) {
|
||||
agentUnsubscribe();
|
||||
}
|
||||
|
||||
const transport = new ProviderTransport();
|
||||
|
||||
agent = new Agent({
|
||||
initialState: initialState || {
|
||||
systemPrompt: `You are a helpful AI assistant with access to various tools.
|
||||
|
||||
Available tools:
|
||||
- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment (can do calculations, get time, process data, create visualizations, etc.)
|
||||
- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
|
||||
|
||||
Feel free to use these tools when needed to provide accurate and helpful responses.`;
|
||||
Feel free to use these tools when needed to provide accurate and helpful responses.`,
|
||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
||||
thinkingLevel: "off",
|
||||
messages: [],
|
||||
tools: [],
|
||||
},
|
||||
transport,
|
||||
});
|
||||
|
||||
// Create and configure the chat panel
|
||||
const chatPanel = new ChatPanel();
|
||||
chatPanel.systemPrompt = systemPrompt;
|
||||
chatPanel.additionalTools = [];
|
||||
chatPanel.onApiKeyRequired = async (provider: string) => {
|
||||
return await ApiKeyPromptDialog.prompt(provider);
|
||||
agentUnsubscribe = agent.subscribe((event: any) => {
|
||||
if (event.type === "state-update") {
|
||||
const messages = event.state.messages;
|
||||
|
||||
// Generate title after first successful response
|
||||
if (!currentTitle && shouldSaveSession(messages)) {
|
||||
currentTitle = generateTitle(messages);
|
||||
}
|
||||
|
||||
// Create session ID on first successful save
|
||||
if (!currentSessionId && shouldSaveSession(messages)) {
|
||||
currentSessionId = crypto.randomUUID();
|
||||
updateUrl(currentSessionId);
|
||||
}
|
||||
|
||||
// Auto-save
|
||||
if (currentSessionId) {
|
||||
saveSession();
|
||||
}
|
||||
|
||||
renderApp();
|
||||
}
|
||||
});
|
||||
|
||||
await chatPanel.setAgent(agent);
|
||||
};
|
||||
|
||||
// Render the app structure
|
||||
const appHtml = html`
|
||||
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border shrink-0">
|
||||
<div class="px-4 py-3">
|
||||
<span class="text-base font-semibold text-foreground">Pi Web UI Example</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<theme-toggle></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Settings, "sm"),
|
||||
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
|
||||
title: "Settings",
|
||||
})}
|
||||
const loadSession = async (sessionId: string) => {
|
||||
if (!storage.sessions) return;
|
||||
|
||||
const sessionData = await storage.sessions.loadSession(sessionId);
|
||||
if (!sessionData) {
|
||||
console.error("Session not found:", sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
currentSessionId = sessionId;
|
||||
const metadata = await storage.sessions.getMetadata(sessionId);
|
||||
currentTitle = metadata?.title || "";
|
||||
|
||||
await createAgent({
|
||||
model: sessionData.model,
|
||||
thinkingLevel: sessionData.thinkingLevel,
|
||||
messages: sessionData.messages,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
updateUrl(sessionId);
|
||||
renderApp();
|
||||
};
|
||||
|
||||
const newSession = () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.search = "";
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RENDER
|
||||
// ============================================================================
|
||||
const renderApp = () => {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) return;
|
||||
|
||||
const appHtml = html`
|
||||
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border shrink-0">
|
||||
<div class="flex items-center gap-2 px-4 py-">
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(History, "sm"),
|
||||
onClick: () => {
|
||||
SessionListDialog.open(async (sessionId) => {
|
||||
await loadSession(sessionId);
|
||||
});
|
||||
},
|
||||
title: "Sessions",
|
||||
})}
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Plus, "sm"),
|
||||
onClick: newSession,
|
||||
title: "New Session",
|
||||
})}
|
||||
|
||||
${currentTitle
|
||||
? isEditingTitle
|
||||
? html`<div class="flex items-center gap-2">
|
||||
${Input({
|
||||
type: "text",
|
||||
value: currentTitle,
|
||||
className: "text-sm w-64",
|
||||
onChange: async (e: Event) => {
|
||||
const newTitle = (e.target as HTMLInputElement).value.trim();
|
||||
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
||||
await storage.sessions.updateTitle(currentSessionId, newTitle);
|
||||
currentTitle = newTitle;
|
||||
}
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
},
|
||||
onKeyDown: async (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
const newTitle = (e.target as HTMLInputElement).value.trim();
|
||||
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
||||
await storage.sessions.updateTitle(currentSessionId, newTitle);
|
||||
currentTitle = newTitle;
|
||||
}
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
} else if (e.key === "Escape") {
|
||||
isEditingTitle = false;
|
||||
renderApp();
|
||||
}
|
||||
},
|
||||
})}
|
||||
</div>`
|
||||
: html`<button
|
||||
class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors"
|
||||
@click=${() => {
|
||||
isEditingTitle = true;
|
||||
renderApp();
|
||||
requestAnimationFrame(() => {
|
||||
const input = app?.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
}}
|
||||
title="Click to edit title"
|
||||
>
|
||||
${currentTitle}
|
||||
</button>`
|
||||
: html`<span class="text-base font-semibold text-foreground">Pi Web UI Example</span>`}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 px-2">
|
||||
<theme-toggle></theme-toggle>
|
||||
${Button({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: icon(Settings, "sm"),
|
||||
onClick: () => SettingsDialog.open([new ApiKeysTab(), new ProxyTab()]),
|
||||
title: "Settings",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel -->
|
||||
${chatPanel}
|
||||
</div>
|
||||
`;
|
||||
|
||||
<!-- Chat Panel -->
|
||||
${chatPanel}
|
||||
</div>
|
||||
`;
|
||||
render(appHtml, app);
|
||||
};
|
||||
|
||||
const app = document.getElementById("app");
|
||||
if (!app) {
|
||||
throw new Error("App container not found");
|
||||
// ============================================================================
|
||||
// INIT
|
||||
// ============================================================================
|
||||
async function initApp() {
|
||||
const app = document.getElementById("app");
|
||||
if (!app) throw new Error("App container not found");
|
||||
|
||||
// Show loading
|
||||
render(
|
||||
html`
|
||||
<div class="w-full h-screen flex items-center justify-center bg-background text-foreground">
|
||||
<div class="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
`,
|
||||
app,
|
||||
);
|
||||
|
||||
// Request persistent storage
|
||||
if (storage.sessions) {
|
||||
await PersistentStorageDialog.request();
|
||||
}
|
||||
|
||||
// Create ChatPanel
|
||||
chatPanel = new ChatPanel();
|
||||
chatPanel.onApiKeyRequired = async (provider: string) => {
|
||||
return await ApiKeyPromptDialog.prompt(provider);
|
||||
};
|
||||
|
||||
// Check for session in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const sessionIdFromUrl = urlParams.get("session");
|
||||
|
||||
if (sessionIdFromUrl) {
|
||||
await loadSession(sessionIdFromUrl);
|
||||
} else {
|
||||
await createAgent();
|
||||
}
|
||||
|
||||
renderApp();
|
||||
}
|
||||
|
||||
render(appHtml, app);
|
||||
initApp();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue