fix: sidebar, always-visible chat input, Venice default model, dropdown direction
Some checks are pending
CI / build-check-test (push) Waiting to run
Some checks are pending
CI / build-check-test (push) Waiting to run
This commit is contained in:
parent
21ff41fc77
commit
2b53445f4e
3 changed files with 223 additions and 187 deletions
131
packages/web-ui/example/src/components/session-sidebar.ts
Normal file
131
packages/web-ui/example/src/components/session-sidebar.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { LitElement, html } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
import type { SessionMetadata } from "@jaeswift/jae-web-ui";
|
||||||
|
|
||||||
|
@customElement("jae-session-sidebar")
|
||||||
|
export class JaeSessionSidebar extends LitElement {
|
||||||
|
@property({ type: Boolean }) collapsed = false;
|
||||||
|
@property({ type: String }) currentSessionId: string | undefined = undefined;
|
||||||
|
@property({ attribute: false }) onLoadSession?: (id: string) => void;
|
||||||
|
@property({ attribute: false }) onNewSession?: () => void;
|
||||||
|
|
||||||
|
@state() private _sessions: SessionMetadata[] = [];
|
||||||
|
@state() private _pinnedIds: Set<string> = new Set();
|
||||||
|
@state() private _confirmDelete: string | null = null;
|
||||||
|
|
||||||
|
protected override createRenderRoot() { return this; }
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
const raw = localStorage.getItem("jae-pinned-sessions");
|
||||||
|
if (raw) { try { this._pinnedIds = new Set(JSON.parse(raw)); } catch {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessions(sessions: SessionMetadata[]) {
|
||||||
|
this._sessions = [...sessions];
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _togglePin(e: Event, id: string) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const s = new Set(this._pinnedIds);
|
||||||
|
s.has(id) ? s.delete(id) : s.add(id);
|
||||||
|
this._pinnedIds = s;
|
||||||
|
localStorage.setItem("jae-pinned-sessions", JSON.stringify([...s]));
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _deleteSession(e: Event, id: string) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this._confirmDelete === id) {
|
||||||
|
this._confirmDelete = null;
|
||||||
|
this.dispatchEvent(new CustomEvent("delete-session", { detail: id, bubbles: true, composed: true }));
|
||||||
|
} else {
|
||||||
|
this._confirmDelete = id;
|
||||||
|
this.requestUpdate();
|
||||||
|
setTimeout(() => { this._confirmDelete = null; this.requestUpdate(); }, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _fmt(iso: string) {
|
||||||
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
|
if (ms < 60000) return "just now";
|
||||||
|
if (ms < 3600000) return Math.floor(ms / 60000) + "m ago";
|
||||||
|
if (ms < 86400000) return Math.floor(ms / 3600000) + "h ago";
|
||||||
|
if (ms < 604800000) return Math.floor(ms / 86400000) + "d ago";
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (this.collapsed) return html``;
|
||||||
|
const pinned = this._sessions.filter(s => this._pinnedIds.has(s.id))
|
||||||
|
.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
|
||||||
|
const rest = this._sessions.filter(s => !this._pinnedIds.has(s.id))
|
||||||
|
.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
|
||||||
|
const sorted = [...pinned, ...rest];
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="flex flex-col h-full border-r border-border bg-background shrink-0" style="width:200px">
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
|
||||||
|
<span class="text-[11px] font-semibold text-muted-foreground uppercase tracking-widest">Chats</span>
|
||||||
|
<button @click=${() => this.onNewSession?.()}
|
||||||
|
class="w-6 h-6 flex items-center justify-center rounded hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="New chat">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto py-1">
|
||||||
|
${sorted.length === 0 ? html`
|
||||||
|
<div class="px-4 py-10 text-center">
|
||||||
|
<div class="text-3xl mb-2">💬</div>
|
||||||
|
<div class="text-xs text-muted-foreground">No chats yet</div>
|
||||||
|
</div>
|
||||||
|
` : sorted.map(s => html`
|
||||||
|
<div class="group relative flex items-center gap-1 px-2 py-1.5 mx-1 my-0.5 rounded-lg cursor-pointer transition-colors select-none
|
||||||
|
${s.id === this.currentSessionId ? "bg-secondary" : "hover:bg-secondary/50"}"
|
||||||
|
@click=${() => this.onLoadSession?.(s.id)}>
|
||||||
|
${this._pinnedIds.has(s.id) ? html`
|
||||||
|
<div class="absolute left-0.5 top-1/2 -translate-y-1/2 w-1 h-4 rounded-full bg-primary/60"></div>
|
||||||
|
` : html``}
|
||||||
|
<div class="flex-1 min-w-0 pl-1">
|
||||||
|
<div class="text-xs font-medium truncate" style="color: inherit">${s.title || "Untitled"}</div>
|
||||||
|
<div class="text-[10px] text-muted-foreground leading-tight">${this._fmt(s.lastModified)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
|
<button @click=${(e: Event) => this._togglePin(e, s.id)}
|
||||||
|
class="w-5 h-5 flex items-center justify-center rounded transition-colors
|
||||||
|
${this._pinnedIds.has(s.id) ? "text-primary" : "text-muted-foreground hover:text-foreground"}"
|
||||||
|
title="${this._pinnedIds.has(s.id) ? "Unpin" : "Pin"}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24"
|
||||||
|
fill="${this._pinnedIds.has(s.id) ? "currentColor" : "none"}"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="17" x2="12" y2="22"/>
|
||||||
|
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button @click=${(e: Event) => this._deleteSession(e, s.id)}
|
||||||
|
class="w-5 h-5 flex items-center justify-center rounded transition-colors
|
||||||
|
${this._confirmDelete === s.id ? "text-destructive bg-destructive/10" : "text-muted-foreground hover:text-destructive"}"
|
||||||
|
title="${this._confirmDelete === s.id ? "Click again to confirm" : "Delete chat"}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-1.5 border-t border-border shrink-0">
|
||||||
|
<div class="text-[10px] text-muted-foreground text-center">
|
||||||
|
${sorted.length} chat${sorted.length !== 1 ? "s" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,7 +58,7 @@ export class JaeUtilityToggle extends LitElement {
|
||||||
|
|
||||||
<!-- Dropdown panel -->
|
<!-- Dropdown panel -->
|
||||||
${this.open ? html`
|
${this.open ? html`
|
||||||
<div class="absolute bottom-full right-0 mb-2 w-72 rounded-xl border border-border bg-background shadow-2xl z-50 overflow-hidden">
|
<div class="absolute top-full right-0 mt-2 w-72 rounded-xl border border-border bg-background shadow-2xl z-50 overflow-hidden">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-2 px-4 py-3 border-b border-border bg-secondary/30">
|
<div class="flex items-center gap-2 px-4 py-3 border-b border-border bg-secondary/30">
|
||||||
<img src="/mascot/jae-default.png" alt="JAE" class="w-7 h-auto" />
|
<img src="/mascot/jae-default.png" alt="JAE" class="w-7 h-auto" />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
|
||||||
import { Agent, type AgentMessage } from "@jaeswift/jae-agent-core";
|
import { Agent, type AgentMessage } from "@jaeswift/jae-agent-core";
|
||||||
import { getModel } from "@jaeswift/jae-ai";
|
import { getModel } from "@jaeswift/jae-ai";
|
||||||
|
|
@ -20,12 +19,12 @@ import {
|
||||||
setAppStorage,
|
setAppStorage,
|
||||||
} from "@jaeswift/jae-web-ui";
|
} from "@jaeswift/jae-web-ui";
|
||||||
import { html, render } from "lit";
|
import { html, render } from "lit";
|
||||||
import { Bell, Brain, Download, History, Keyboard, Plus, Settings, Terminal } from "lucide";
|
import { Brain, Download, History, Keyboard, Plus, Settings } from "lucide";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
import { icon } from "@mariozechner/mini-lit";
|
import { icon } from "@mariozechner/mini-lit";
|
||||||
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
||||||
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
|
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
|
||||||
import { createSystemNotification, customConvertToLlm, registerCustomMessageRenderers } from "./custom-messages.js";
|
import { customConvertToLlm, registerCustomMessageRenderers } from "./custom-messages.js";
|
||||||
import { createWebSearchTool, createImageGenTool, createTTSTool } from "@jaeswift/jae-web-ui";
|
import { createWebSearchTool, createImageGenTool, createTTSTool } from "@jaeswift/jae-web-ui";
|
||||||
import { CommandPalette } from "./components/command-palette.js";
|
import { CommandPalette } from "./components/command-palette.js";
|
||||||
import { KeyboardShortcuts } from "./components/keyboard-shortcuts.js";
|
import { KeyboardShortcuts } from "./components/keyboard-shortcuts.js";
|
||||||
|
|
@ -40,11 +39,11 @@ import { JaeEmptyState } from "./components/empty-state.js";
|
||||||
import { JaeUtilityToggle, type UtilityVisibility } from "./components/utility-toggle.js";
|
import { JaeUtilityToggle, type UtilityVisibility } from "./components/utility-toggle.js";
|
||||||
import "./components/empty-state.js";
|
import "./components/empty-state.js";
|
||||||
import "./components/utility-toggle.js";
|
import "./components/utility-toggle.js";
|
||||||
|
import { JaeSessionSidebar } from "./components/session-sidebar.js";
|
||||||
|
import "./components/session-sidebar.js";
|
||||||
|
|
||||||
// Register custom message renderers
|
|
||||||
registerCustomMessageRenderers();
|
registerCustomMessageRenderers();
|
||||||
|
|
||||||
// Create stores
|
|
||||||
const settings = new SettingsStore();
|
const settings = new SettingsStore();
|
||||||
const providerKeys = new ProviderKeysStore();
|
const providerKeys = new ProviderKeysStore();
|
||||||
const sessions = new SessionsStore();
|
const sessions = new SessionsStore();
|
||||||
|
|
@ -79,11 +78,23 @@ let agent: Agent;
|
||||||
let chatPanel: ChatPanel;
|
let chatPanel: ChatPanel;
|
||||||
let agentUnsubscribe: (() => void) | undefined;
|
let agentUnsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
// --- Feature instances ---
|
|
||||||
const commandPalette = document.createElement("command-palette") as CommandPalette;
|
const commandPalette = document.createElement("command-palette") as CommandPalette;
|
||||||
const keyboardShortcuts = document.createElement("keyboard-shortcuts") as KeyboardShortcuts;
|
const keyboardShortcuts = document.createElement("keyboard-shortcuts") as KeyboardShortcuts;
|
||||||
const memoryManager = document.createElement("memory-manager") as MemoryManager;
|
const memoryManager = document.createElement("memory-manager") as MemoryManager;
|
||||||
const costTracker = document.createElement("cost-tracker") as CostTracker;
|
const costTracker = document.createElement("cost-tracker") as CostTracker;
|
||||||
|
const sidebar = document.createElement("jae-session-sidebar") as JaeSessionSidebar;
|
||||||
|
|
||||||
|
sidebar.onLoadSession = async (id: string) => { await loadSession(id); };
|
||||||
|
sidebar.onNewSession = () => newSession();
|
||||||
|
sidebar.addEventListener("delete-session", async (e: Event) => {
|
||||||
|
const id = (e as CustomEvent<string>).detail;
|
||||||
|
if (storage.sessions) {
|
||||||
|
await storage.sessions.delete(id);
|
||||||
|
if (id === currentSessionId) newSession();
|
||||||
|
await refreshSidebar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const utilityToggle = document.createElement("jae-utility-toggle") as JaeUtilityToggle;
|
const utilityToggle = document.createElement("jae-utility-toggle") as JaeUtilityToggle;
|
||||||
utilityToggle.addEventListener("visibility-change", (e: Event) => {
|
utilityToggle.addEventListener("visibility-change", (e: Event) => {
|
||||||
const vis = (e as CustomEvent<UtilityVisibility>).detail;
|
const vis = (e as CustomEvent<UtilityVisibility>).detail;
|
||||||
|
|
@ -95,11 +106,19 @@ utilityToggle.addEventListener("visibility-change", (e: Event) => {
|
||||||
chatEl.classList.toggle("hide-timestamps", !vis.showTimestamps);
|
chatEl.classList.toggle("hide-timestamps", !vis.showTimestamps);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.appendChild(commandPalette);
|
document.body.appendChild(commandPalette);
|
||||||
document.body.appendChild(keyboardShortcuts);
|
document.body.appendChild(keyboardShortcuts);
|
||||||
document.body.appendChild(memoryManager);
|
document.body.appendChild(memoryManager);
|
||||||
|
|
||||||
// --- Global keyboard handler ---
|
const refreshSidebar = async () => {
|
||||||
|
if (storage.sessions) {
|
||||||
|
const all = await storage.sessions.getAllMetadata();
|
||||||
|
sidebar.setSessions(all);
|
||||||
|
sidebar.currentSessionId = currentSessionId;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", (e: KeyboardEvent) => {
|
window.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||||
const meta = e.metaKey || e.ctrlKey;
|
const meta = e.metaKey || e.ctrlKey;
|
||||||
if (meta && e.key === "k") { e.preventDefault(); commandPalette.show(); return; }
|
if (meta && e.key === "k") { e.preventDefault(); commandPalette.show(); return; }
|
||||||
|
|
@ -110,49 +129,16 @@ window.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Setup command palette commands ---
|
|
||||||
function setupCommands() {
|
function setupCommands() {
|
||||||
commandPalette.setCommands([
|
commandPalette.setCommands([
|
||||||
{
|
{ id: "new-session", label: "New Session", description: "Start a fresh conversation", shortcut: "Ctrl+N", keywords: ["new", "fresh", "start"], action: newSession },
|
||||||
id: "new-session", label: "New Session", description: "Start a fresh conversation",
|
{ id: "sessions", label: "Session History", description: "Browse and load past sessions", shortcut: "Ctrl+H", keywords: ["history", "sessions", "past"], action: () => SessionListDialog.open(async (id) => await loadSession(id), (id) => { if (id === currentSessionId) newSession(); }) },
|
||||||
shortcut: "Ctrl+N", keywords: ["new", "fresh", "start"],
|
{ id: "export-md", label: "Export as Markdown", description: "Download current session as .md", shortcut: "Ctrl+E", keywords: ["export", "download", "markdown"], action: () => handleExport("markdown") },
|
||||||
action: newSession,
|
{ id: "export-json", label: "Export as JSON", description: "Download current session as .json", keywords: ["export", "download", "json"], action: () => handleExport("json") },
|
||||||
},
|
{ id: "memory", label: "Memory Manager", description: "Browse and manage stored memories", keywords: ["memory", "remember", "recall"], action: () => memoryManager.show() },
|
||||||
{
|
{ id: "settings", label: "Settings", description: "Configure providers and models", keywords: ["settings", "config", "provider", "api", "model"], action: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]) },
|
||||||
id: "sessions", label: "Session History", description: "Browse and load past sessions",
|
{ id: "shortcuts", label: "Keyboard Shortcuts", description: "View all keyboard shortcuts", shortcut: "?", keywords: ["keyboard", "shortcuts", "help"], action: () => keyboardShortcuts.show() },
|
||||||
shortcut: "Ctrl+H", keywords: ["history", "sessions", "past"],
|
{ id: "cost", label: "Token Usage & Cost", description: "View API usage stats for this session", keywords: ["tokens", "cost", "usage"], action: () => costTracker.dispatchEvent(new MouseEvent("click")) },
|
||||||
action: () => SessionListDialog.open(async (id) => await loadSession(id), (id) => { if (id === currentSessionId) newSession(); }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "export-md", label: "Export as Markdown", description: "Download current session as .md",
|
|
||||||
shortcut: "Ctrl+E", keywords: ["export", "download", "save", "markdown"],
|
|
||||||
action: () => handleExport("markdown"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "export-json", label: "Export as JSON", description: "Download current session as .json",
|
|
||||||
keywords: ["export", "download", "save", "json"],
|
|
||||||
action: () => handleExport("json"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "memory", label: "Memory Manager", description: "Browse and manage stored memories",
|
|
||||||
keywords: ["memory", "remember", "recall", "brain"],
|
|
||||||
action: () => memoryManager.show(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "settings", label: "Settings", description: "Configure providers and models",
|
|
||||||
keywords: ["settings", "config", "provider", "api", "key", "model"],
|
|
||||||
action: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "shortcuts", label: "Keyboard Shortcuts", description: "View all keyboard shortcuts",
|
|
||||||
shortcut: "?", keywords: ["keyboard", "shortcuts", "help", "keys"],
|
|
||||||
action: () => keyboardShortcuts.show(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cost", label: "Token Usage & Cost", description: "View API usage stats for this session",
|
|
||||||
keywords: ["tokens", "cost", "usage", "spend"],
|
|
||||||
action: () => costTracker.dispatchEvent(new MouseEvent("click")),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +161,7 @@ const generateTitle = (messages: AgentMessage[]): string => {
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
const sentenceEnd = text.search(/[.!?]/);
|
const sentenceEnd = text.search(/[.!?]/);
|
||||||
if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1);
|
if (sentenceEnd > 0 && sentenceEnd <= 50) return text.substring(0, sentenceEnd + 1);
|
||||||
return text.length <= 50 ? text : `${text.substring(0, 47)}...`;
|
return text.length <= 50 ? text : text.substring(0, 47) + "...";
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldSaveSession = (messages: AgentMessage[]): boolean => {
|
const shouldSaveSession = (messages: AgentMessage[]): boolean => {
|
||||||
|
|
@ -203,6 +189,7 @@ const saveSession = async () => {
|
||||||
preview: generateTitle(state.messages),
|
preview: generateTitle(state.messages),
|
||||||
};
|
};
|
||||||
await storage.sessions.save(sessionData, metadata);
|
await storage.sessions.save(sessionData, metadata);
|
||||||
|
await refreshSidebar();
|
||||||
} catch (err) { console.error("Failed to save session:", err); }
|
} catch (err) { console.error("Failed to save session:", err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -214,33 +201,22 @@ const updateUrl = (sessionId: string) => {
|
||||||
|
|
||||||
const createAgent = async (initialState?: Partial<AgentState>) => {
|
const createAgent = async (initialState?: Partial<AgentState>) => {
|
||||||
if (agentUnsubscribe) agentUnsubscribe();
|
if (agentUnsubscribe) agentUnsubscribe();
|
||||||
|
|
||||||
agent = new Agent({
|
agent = new Agent({
|
||||||
initialState: initialState || {
|
initialState: initialState || {
|
||||||
systemPrompt: `You are a helpful AI assistant with access to various tools.
|
systemPrompt: "You are JAE, a helpful AI assistant and coding agent with access to tools including web search, image generation, JavaScript REPL, text-to-speech, and artifact creation. Use these tools whenever helpful.",
|
||||||
|
model: getModel("venice", "llama-3.3-70b"),
|
||||||
Available tools:
|
|
||||||
- JavaScript REPL: Execute JavaScript code in a sandboxed browser environment
|
|
||||||
- Web Search: Search the web via DuckDuckGo for current information
|
|
||||||
- Image Generation: Generate images using Venice AI
|
|
||||||
- Text to Speech: Convert text to audio
|
|
||||||
- Memory: Save and recall information across sessions
|
|
||||||
- Artifacts: Create interactive HTML, SVG, Markdown, and text artifacts
|
|
||||||
|
|
||||||
Feel free to use these tools when needed to provide accurate and helpful responses.`,
|
|
||||||
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
|
|
||||||
thinkingLevel: "off",
|
thinkingLevel: "off",
|
||||||
messages: [],
|
messages: [],
|
||||||
tools: [],
|
tools: [],
|
||||||
},
|
},
|
||||||
convertToLlm: customConvertToLlm,
|
convertToLlm: customConvertToLlm,
|
||||||
onApiKeyRequired: async (provider) => {
|
onApiKeyRequired: async (provider: string) => {
|
||||||
const key = await ApiKeyPromptDialog.prompt(provider);
|
const key = await ApiKeyPromptDialog.prompt(provider);
|
||||||
if (key) await providerKeys.set(provider, key);
|
if (key) await providerKeys.set(provider, key);
|
||||||
return key;
|
return key;
|
||||||
},
|
},
|
||||||
getProviderApiKey: async (provider) => providerKeys.get(provider),
|
getProviderApiKey: async (provider: string) => providerKeys.get(provider),
|
||||||
onStateChange: async (state, prevState) => {
|
onStateChange: async (state: AgentState, prevState: AgentState | undefined) => {
|
||||||
if (prevState?.messages.length !== state.messages.length) {
|
if (prevState?.messages.length !== state.messages.length) {
|
||||||
if (!currentTitle) {
|
if (!currentTitle) {
|
||||||
const generated = generateTitle(state.messages);
|
const generated = generateTitle(state.messages);
|
||||||
|
|
@ -248,22 +224,19 @@ Feel free to use these tools when needed to provide accurate and helpful respons
|
||||||
currentTitle = generated;
|
currentTitle = generated;
|
||||||
if (!currentSessionId) currentSessionId = crypto.randomUUID();
|
if (!currentSessionId) currentSessionId = crypto.randomUUID();
|
||||||
updateUrl(currentSessionId);
|
updateUrl(currentSessionId);
|
||||||
renderApp();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await saveSession();
|
await saveSession();
|
||||||
}
|
}
|
||||||
|
renderApp();
|
||||||
},
|
},
|
||||||
createTools: async (runtimeProvidersFactory) => {
|
createTools: async (runtimeProvidersFactory: any) => {
|
||||||
const replTool = createJavaScriptReplTool();
|
const replTool = createJavaScriptReplTool();
|
||||||
replTool.runtimeProvidersFactory = runtimeProvidersFactory;
|
replTool.runtimeProvidersFactory = runtimeProvidersFactory;
|
||||||
return [replTool, createWebSearchTool(), createImageGenTool(), createTTSTool()];
|
return [replTool, createWebSearchTool(), createImageGenTool(), createTTSTool()];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bind cost tracker to new agent
|
|
||||||
costTracker.bindAgent(agent);
|
costTracker.bindAgent(agent);
|
||||||
|
|
||||||
chatPanel?.setAgent(agent);
|
chatPanel?.setAgent(agent);
|
||||||
if (!currentSessionId) currentSessionId = crypto.randomUUID();
|
if (!currentSessionId) currentSessionId = crypto.randomUUID();
|
||||||
};
|
};
|
||||||
|
|
@ -271,7 +244,7 @@ Feel free to use these tools when needed to provide accurate and helpful respons
|
||||||
const loadSession = async (sessionId: string): Promise<boolean> => {
|
const loadSession = async (sessionId: string): Promise<boolean> => {
|
||||||
if (!storage.sessions) return false;
|
if (!storage.sessions) return false;
|
||||||
const sessionData = await storage.sessions.get(sessionId);
|
const sessionData = await storage.sessions.get(sessionId);
|
||||||
if (!sessionData) { console.error("Session not found:", sessionId); return false; }
|
if (!sessionData) return false;
|
||||||
currentSessionId = sessionId;
|
currentSessionId = sessionId;
|
||||||
const metadata = await storage.sessions.getMetadata(sessionId);
|
const metadata = await storage.sessions.getMetadata(sessionId);
|
||||||
currentTitle = metadata?.title || "";
|
currentTitle = metadata?.title || "";
|
||||||
|
|
@ -279,155 +252,87 @@ const loadSession = async (sessionId: string): Promise<boolean> => {
|
||||||
model: sessionData.model, thinkingLevel: sessionData.thinkingLevel,
|
model: sessionData.model, thinkingLevel: sessionData.thinkingLevel,
|
||||||
messages: sessionData.messages, tools: [],
|
messages: sessionData.messages, tools: [],
|
||||||
});
|
});
|
||||||
|
sidebar.currentSessionId = currentSessionId;
|
||||||
updateUrl(sessionId);
|
updateUrl(sessionId);
|
||||||
renderApp();
|
renderApp();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const newSession = () => {
|
const newSession = () => {
|
||||||
const url = new URL(window.location.href);
|
currentSessionId = undefined;
|
||||||
url.search = "";
|
currentTitle = "";
|
||||||
window.location.href = url.toString();
|
isEditingTitle = false;
|
||||||
|
createAgent().then(() => renderApp());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle suggestion chip clicks from empty state
|
|
||||||
const handleSuggestion = (e: Event) => {
|
const handleSuggestion = (e: Event) => {
|
||||||
const ce = e as CustomEvent<string>;
|
const text = (e as CustomEvent<string>).detail;
|
||||||
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
|
if (chatPanel && (chatPanel as any).agentInterface) {
|
||||||
if (textarea) {
|
(chatPanel as any).agentInterface.setInput(text);
|
||||||
textarea.value = ce.detail;
|
} else {
|
||||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
const textarea = document.querySelector("textarea") as HTMLTextAreaElement;
|
||||||
textarea.focus();
|
if (textarea) { textarea.value = text; textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.focus(); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// RENDER
|
|
||||||
// ============================================================================
|
|
||||||
const renderApp = () => {
|
const renderApp = () => {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
|
const hasMessages = agent && agent.state.messages.length > 0;
|
||||||
const appHtml = html`
|
render(html`
|
||||||
<div class="w-full h-screen flex flex-col bg-background text-foreground overflow-hidden">
|
<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" style="height:44px">
|
||||||
<div class="flex items-center justify-between border-b border-border shrink-0 relative">
|
<div class="flex items-center gap-1 px-2">
|
||||||
<div class="flex items-center gap-2 px-4 py-1">
|
${Button({ variant: "ghost", size: "sm", children: icon(History, "sm"), onClick: () => SessionListDialog.open(async (id) => { await loadSession(id); }, (id) => { if (id === currentSessionId) newSession(); }), title: "Sessions" })}
|
||||||
${Button({ variant: "ghost", size: "sm", children: icon(History, "sm"),
|
${Button({ variant: "ghost", size: "sm", children: icon(Plus, "sm"), onClick: newSession, title: "New Session (Ctrl+N)" })}
|
||||||
onClick: () => SessionListDialog.open(
|
${currentTitle
|
||||||
async (id) => { await loadSession(id); },
|
? isEditingTitle
|
||||||
(id) => { if (id === currentSessionId) newSession(); }
|
? html`<div class="flex items-center gap-2">${Input({ type: "text", value: currentTitle, className: "text-sm w-64", onChange: async (e: Event) => { const v = (e.target as HTMLInputElement).value.trim(); if (v && v !== currentTitle && storage.sessions && currentSessionId) { await storage.sessions.updateTitle(currentSessionId, v); currentTitle = v; await refreshSidebar(); } isEditingTitle = false; renderApp(); }, onKeyDown: async (e: KeyboardEvent) => { if (e.key === "Enter") { const v = (e.target as HTMLInputElement).value.trim(); if (v && v !== currentTitle && storage.sessions && currentSessionId) { await storage.sessions.updateTitle(currentSessionId, v); currentTitle = v; await refreshSidebar(); } isEditingTitle = false; renderApp(); } else if (e.key === "Escape") { isEditingTitle = false; renderApp(); } } })}</div>`
|
||||||
), title: "Sessions (Ctrl+H)" })}
|
: html`<button class="px-2 py-1 text-sm text-foreground hover:bg-secondary rounded transition-colors max-w-xs truncate" @click=${() => { isEditingTitle = true; renderApp(); requestAnimationFrame(() => { const inp = app.querySelector('input[type="text"]') as HTMLInputElement; if (inp) { inp.focus(); inp.select(); } }); }} title="Click to edit">${currentTitle}</button>`
|
||||||
${Button({ variant: "ghost", size: "sm", children: icon(Plus, "sm"), onClick: newSession, title: "New Session (Ctrl+N)" })}
|
: html`<div class="flex items-center gap-2"><img src="/mascot/jae-default.png" alt="JAE" class="w-7 h-auto header-logo cursor-pointer" /><span class="text-base font-semibold text-foreground">JAE</span></div>`
|
||||||
${
|
}
|
||||||
currentTitle
|
</div>
|
||||||
? isEditingTitle
|
<div class="flex items-center gap-1 px-2">
|
||||||
? html`<div class="flex items-center gap-2">
|
${costTracker}
|
||||||
${Input({
|
${Button({ variant: "ghost", size: "sm", children: icon(Brain, "sm"), onClick: () => memoryManager.show(), title: "Memory Manager" })}
|
||||||
type: "text", value: currentTitle, className: "text-sm w-64",
|
${Button({ variant: "ghost", size: "sm", children: icon(Download, "sm"), onClick: () => handleExport(), title: "Export Session (Ctrl+E)" })}
|
||||||
onChange: async (e: Event) => {
|
${Button({ variant: "ghost", size: "sm", children: icon(Keyboard, "sm"), onClick: () => keyboardShortcuts.show(), title: "Keyboard Shortcuts (?)" })}
|
||||||
const newTitle = (e.target as HTMLInputElement).value.trim();
|
${Button({ variant: "ghost", size: "sm", children: html`<span class="text-xs font-mono px-1">⌘K</span>`, onClick: () => commandPalette.show(), title: "Command Palette (Ctrl+K)" })}
|
||||||
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
${utilityToggle}
|
||||||
await storage.sessions.updateTitle(currentSessionId, newTitle);
|
<theme-toggle></theme-toggle>
|
||||||
currentTitle = newTitle;
|
${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"), onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]), title: "Settings" })}
|
||||||
}
|
</div>
|
||||||
isEditingTitle = false; renderApp();
|
</div>
|
||||||
},
|
<div class="flex flex-1 min-h-0 overflow-hidden">
|
||||||
onKeyDown: async (e: KeyboardEvent) => {
|
${sidebar}
|
||||||
if (e.key === "Enter") {
|
<div class="flex flex-col flex-1 min-w-0 min-h-0 relative">
|
||||||
const newTitle = (e.target as HTMLInputElement).value.trim();
|
${!hasMessages ? html`
|
||||||
if (newTitle && newTitle !== currentTitle && storage.sessions && currentSessionId) {
|
<div class="absolute inset-0 z-10 flex flex-col overflow-auto bg-background" @suggestion=${handleSuggestion}>
|
||||||
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`<div class="flex items-center gap-2"><img src="/mascot/jae-default.png" alt="JAE" class="w-7 h-auto header-logo cursor-pointer" title="JAE - Your AI Coding Agent" /><span class="text-base font-semibold text-foreground">JAE</span></div>`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right header controls -->
|
|
||||||
<div class="flex items-center gap-1 px-2">
|
|
||||||
<!-- Cost tracker -->
|
|
||||||
${costTracker}
|
|
||||||
<!-- Memory manager -->
|
|
||||||
${Button({ variant: "ghost", size: "sm", children: icon(Brain, "sm"),
|
|
||||||
onClick: () => memoryManager.show(), title: "Memory Manager" })}
|
|
||||||
<!-- Export -->
|
|
||||||
${Button({ variant: "ghost", size: "sm", children: icon(Download, "sm"),
|
|
||||||
onClick: () => handleExport(), title: "Export Session (Ctrl+E)" })}
|
|
||||||
<!-- Keyboard shortcuts -->
|
|
||||||
${Button({ variant: "ghost", size: "sm", children: icon(Keyboard, "sm"),
|
|
||||||
onClick: () => keyboardShortcuts.show(), title: "Keyboard Shortcuts (?)" })}
|
|
||||||
<!-- Command palette -->
|
|
||||||
${Button({ variant: "ghost", size: "sm",
|
|
||||||
children: html`<span class="text-xs font-mono px-1">⌘K</span>`,
|
|
||||||
onClick: () => commandPalette.show(), title: "Command Palette (Ctrl+K)" })}
|
|
||||||
${utilityToggle}
|
|
||||||
<theme-toggle></theme-toggle>
|
|
||||||
${Button({ variant: "ghost", size: "sm", children: icon(Settings, "sm"),
|
|
||||||
onClick: () => SettingsDialog.open([new ProvidersModelsTab(), new ProxyTab()]),
|
|
||||||
title: "Settings" })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
${agent && agent.state.messages.length === 0 ? html`
|
|
||||||
<div class="flex-1 overflow-auto"
|
|
||||||
@suggestion=${handleSuggestion}>
|
|
||||||
<jae-empty-state></jae-empty-state>
|
<jae-empty-state></jae-empty-state>
|
||||||
</div>
|
</div>
|
||||||
` : html``}
|
` : html``}
|
||||||
|
<div id="chat-wrapper" class="flex flex-col flex-1 min-h-0" style="${!hasMessages ? "visibility:hidden" : ""}">
|
||||||
<!-- Chat Panel -->
|
|
||||||
<div id="chat-wrapper" style="${agent && agent.state.messages.length === 0 ? 'display:none' : 'flex:1;min-height:0;display:flex;flex-direction:column'}">
|
|
||||||
${chatPanel}
|
${chatPanel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
</div>
|
||||||
|
</div>`, app);
|
||||||
render(appHtml, app);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// INIT
|
|
||||||
// ============================================================================
|
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
if (!app) throw new Error("App container not found");
|
if (!app) throw new Error("App container not found");
|
||||||
|
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);
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
chatPanel = new ChatPanel();
|
chatPanel = new ChatPanel();
|
||||||
setupCommands();
|
setupCommands();
|
||||||
|
await refreshSidebar();
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const sessionIdFromUrl = new URLSearchParams(window.location.search).get("session");
|
||||||
const sessionIdFromUrl = urlParams.get("session");
|
|
||||||
|
|
||||||
if (sessionIdFromUrl) {
|
if (sessionIdFromUrl) {
|
||||||
const loaded = await loadSession(sessionIdFromUrl);
|
const loaded = await loadSession(sessionIdFromUrl);
|
||||||
if (!loaded) { newSession(); return; }
|
if (!loaded) { newSession(); return; }
|
||||||
} else {
|
} else {
|
||||||
await createAgent();
|
await createAgent();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderApp();
|
renderApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue