mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 13:01:41 +00:00
More browser extension work. Old interface fully ported. Direct transport. Small UX fixes.
This commit is contained in:
parent
b3a7b35ec5
commit
d0b2d47b4a
28 changed files with 3604 additions and 65 deletions
312
packages/browser-extension/src/AgentInterface.ts
Normal file
312
packages/browser-extension/src/AgentInterface.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { html, icon } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage, Usage } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
|
||||
import { ModelSelector } from "./dialogs/ModelSelector.js";
|
||||
import type { MessageEditor } from "./MessageEditor.js";
|
||||
import "./MessageEditor.js";
|
||||
import "./MessageList.js";
|
||||
import "./Messages.js"; // Import for side effects to register the custom elements
|
||||
import type { AgentSession, AgentSessionEvent } from "./state/agent-session.js";
|
||||
import { keyStore } from "./state/KeyStore.js";
|
||||
import "./StreamingMessageContainer.js";
|
||||
import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
|
||||
import type { Attachment } from "./utils/attachment-utils.js";
|
||||
import { formatUsage } from "./utils/format.js";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
|
||||
@customElement("agent-interface")
|
||||
export class AgentInterface extends LitElement {
|
||||
// Optional external session: when provided, this component becomes a view over the session
|
||||
@property({ attribute: false }) session?: AgentSession;
|
||||
@property() enableAttachments = true;
|
||||
@property() enableModelSelector = true;
|
||||
@property() enableThinking = true;
|
||||
@property() showThemeToggle = false;
|
||||
@property() showDebugToggle = false;
|
||||
|
||||
// References
|
||||
@query("message-editor") private _messageEditor!: MessageEditor;
|
||||
@query("streaming-message-container") private _streamingContainer!: StreamingMessageContainer;
|
||||
|
||||
private _autoScroll = true;
|
||||
private _lastScrollTop = 0;
|
||||
private _lastClientHeight = 0;
|
||||
private _scrollContainer?: HTMLElement;
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
private _unsubscribeSession?: () => void;
|
||||
|
||||
public setInput(text: string, attachments?: Attachment[]) {
|
||||
const update = () => {
|
||||
if (!this._messageEditor) requestAnimationFrame(update);
|
||||
else {
|
||||
this._messageEditor.value = text;
|
||||
this._messageEditor.attachments = attachments || [];
|
||||
}
|
||||
};
|
||||
update();
|
||||
}
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.style.display = "flex";
|
||||
this.style.flexDirection = "column";
|
||||
this.style.height = "100%";
|
||||
this.style.minHeight = "0";
|
||||
|
||||
// Wait for first render to get scroll container
|
||||
await this.updateComplete;
|
||||
this._scrollContainer = this.querySelector(".overflow-y-auto") as HTMLElement;
|
||||
|
||||
if (this._scrollContainer) {
|
||||
// Set up ResizeObserver to detect content changes
|
||||
this._resizeObserver = new ResizeObserver(() => {
|
||||
if (this._autoScroll && this._scrollContainer) {
|
||||
this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
// Observe the content container inside the scroll container
|
||||
const contentContainer = this._scrollContainer.querySelector(".max-w-3xl");
|
||||
if (contentContainer) {
|
||||
this._resizeObserver.observe(contentContainer);
|
||||
}
|
||||
|
||||
// Set up scroll listener with better detection
|
||||
this._scrollContainer.addEventListener("scroll", this._handleScroll);
|
||||
}
|
||||
|
||||
// Subscribe to external session if provided
|
||||
this.setupSessionSubscription();
|
||||
|
||||
// Attach debug listener if session provided
|
||||
if (this.session) {
|
||||
this.session = this.session; // explicitly set to trigger subscription
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
// Clean up observers and listeners
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
this._resizeObserver = undefined;
|
||||
}
|
||||
|
||||
if (this._scrollContainer) {
|
||||
this._scrollContainer.removeEventListener("scroll", this._handleScroll);
|
||||
}
|
||||
|
||||
if (this._unsubscribeSession) {
|
||||
this._unsubscribeSession();
|
||||
this._unsubscribeSession = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private setupSessionSubscription() {
|
||||
if (this._unsubscribeSession) {
|
||||
this._unsubscribeSession();
|
||||
this._unsubscribeSession = undefined;
|
||||
}
|
||||
if (!this.session) return;
|
||||
this._unsubscribeSession = this.session.subscribe(async (ev: AgentSessionEvent) => {
|
||||
if (ev.type === "state-update") {
|
||||
if (this._streamingContainer) {
|
||||
this._streamingContainer.isStreaming = ev.state.isStreaming;
|
||||
this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming);
|
||||
}
|
||||
this.requestUpdate();
|
||||
} else if (ev.type === "error-no-model") {
|
||||
// TODO show some UI feedback
|
||||
} else if (ev.type === "error-no-api-key") {
|
||||
// Open API keys dialog to configure the missing key
|
||||
ApiKeysDialog.open();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _handleScroll = (_ev: any) => {
|
||||
if (!this._scrollContainer) return;
|
||||
|
||||
const currentScrollTop = this._scrollContainer.scrollTop;
|
||||
const scrollHeight = this._scrollContainer.scrollHeight;
|
||||
const clientHeight = this._scrollContainer.clientHeight;
|
||||
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight;
|
||||
|
||||
// Ignore relayout due to message editor getting pushed up by stats
|
||||
if (clientHeight < this._lastClientHeight) {
|
||||
this._lastClientHeight = clientHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
// Only disable auto-scroll if user scrolled UP or is far from bottom
|
||||
if (currentScrollTop !== 0 && currentScrollTop < this._lastScrollTop && distanceFromBottom > 50) {
|
||||
this._autoScroll = false;
|
||||
} else if (distanceFromBottom < 10) {
|
||||
// Re-enable if very close to bottom
|
||||
this._autoScroll = true;
|
||||
}
|
||||
|
||||
this._lastScrollTop = currentScrollTop;
|
||||
this._lastClientHeight = clientHeight;
|
||||
};
|
||||
|
||||
public async sendMessage(input: string, attachments?: Attachment[]) {
|
||||
if ((!input.trim() && attachments?.length === 0) || this.session?.state.isStreaming) return;
|
||||
const session = this.session;
|
||||
if (!session) throw new Error("No session set on AgentInterface");
|
||||
if (!session.state.model) throw new Error("No model set on AgentInterface");
|
||||
|
||||
// Check if API key exists for the provider (only needed in direct mode)
|
||||
const provider = session.state.model.provider;
|
||||
let apiKey = await keyStore.getKey(provider);
|
||||
|
||||
// If no API key, open the API keys dialog
|
||||
if (!apiKey) {
|
||||
await ApiKeysDialog.open();
|
||||
// Check again after dialog closes
|
||||
apiKey = await keyStore.getKey(provider);
|
||||
// If still no API key, abort the send
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only clear editor after we know we can send
|
||||
this._messageEditor.value = "";
|
||||
this._messageEditor.attachments = [];
|
||||
this._autoScroll = true; // Enable auto-scroll when sending a message
|
||||
|
||||
await this.session?.prompt(input, attachments);
|
||||
}
|
||||
|
||||
private renderMessages() {
|
||||
if (!this.session)
|
||||
return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session available")}</div>`;
|
||||
const state = this.session.state;
|
||||
// Build a map of tool results to allow inline rendering in assistant messages
|
||||
const toolResultsById = new Map<string, ToolResultMessage<any>>();
|
||||
for (const message of state.messages) {
|
||||
if (message.role === "toolResult") {
|
||||
toolResultsById.set(message.toolCallId, message);
|
||||
}
|
||||
}
|
||||
return html`
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Stable messages list - won't re-render during streaming -->
|
||||
<message-list
|
||||
.messages=${this.session.state.messages}
|
||||
.tools=${state.tools}
|
||||
.pendingToolCalls=${this.session ? this.session.state.pendingToolCalls : new Set<string>()}
|
||||
.isStreaming=${state.isStreaming}
|
||||
></message-list>
|
||||
|
||||
<!-- Streaming message container - manages its own updates -->
|
||||
<streaming-message-container
|
||||
class="${state.isStreaming ? "" : "hidden"}"
|
||||
.tools=${state.tools}
|
||||
.isStreaming=${state.isStreaming}
|
||||
.pendingToolCalls=${state.pendingToolCalls}
|
||||
.toolResultsById=${toolResultsById}
|
||||
></streaming-message-container>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStats() {
|
||||
if (!this.session) return html`<div class="text-xs h-5"></div>`;
|
||||
|
||||
const state = this.session.state;
|
||||
const totals = state.messages
|
||||
.filter((m) => m.role === "assistant")
|
||||
.reduce(
|
||||
(acc, msg: any) => {
|
||||
const usage = msg.usage;
|
||||
if (usage) {
|
||||
acc.input += usage.input;
|
||||
acc.output += usage.output;
|
||||
acc.cacheRead += usage.cacheRead;
|
||||
acc.cacheWrite += usage.cacheWrite;
|
||||
acc.cost.total += usage.cost.total;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
} satisfies Usage,
|
||||
);
|
||||
|
||||
const hasTotals = totals.input || totals.output || totals.cacheRead || totals.cacheWrite;
|
||||
const totalsText = hasTotals ? formatUsage(totals) : "";
|
||||
|
||||
return html`
|
||||
<div class="text-xs text-muted-foreground flex justify-between items-center h-5">
|
||||
<div class="flex items-center gap-1">
|
||||
${this.showThemeToggle ? html`<theme-toggle></theme-toggle>` : html``}
|
||||
</div>
|
||||
<div class="flex ml-auto items-center gap-3">${totalsText ? html`<span>${totalsText}</span>` : ""}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.session)
|
||||
return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session set")}</div>`;
|
||||
|
||||
const session = this.session;
|
||||
const state = this.session.state;
|
||||
return html`
|
||||
<div class="flex flex-col h-full bg-background text-foreground">
|
||||
<!-- Messages Area -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="max-w-3xl mx-auto p-4 pb-0">${this.renderMessages()}</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="shrink-0">
|
||||
<div class="max-w-3xl mx-auto px-2">
|
||||
<message-editor
|
||||
.isStreaming=${state.isStreaming}
|
||||
.currentModel=${state.model}
|
||||
.thinkingLevel=${state.thinkingLevel}
|
||||
.showAttachmentButton=${this.enableAttachments}
|
||||
.showModelSelector=${this.enableModelSelector}
|
||||
.showThinking=${this.enableThinking}
|
||||
.onSend=${(input: string, attachments: Attachment[]) => {
|
||||
this.sendMessage(input, attachments);
|
||||
}}
|
||||
.onAbort=${() => session.abort()}
|
||||
.onModelSelect=${() => {
|
||||
ModelSelector.open(state.model, (model) => session.setModel(model));
|
||||
}}
|
||||
.onThinkingChange=${
|
||||
this.enableThinking
|
||||
? (level: "off" | "minimal" | "low" | "medium" | "high") => {
|
||||
session.setThinkingLevel(level);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
></message-editor>
|
||||
${this.renderStats()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Register custom element with guard
|
||||
if (!customElements.get("agent-interface")) {
|
||||
customElements.define("agent-interface", AgentInterface);
|
||||
}
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
import { html } from "@mariozechner/mini-lit";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import { calculateTool, getCurrentTimeTool, getModel } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { ModelSelector } from "./dialogs/ModelSelector.js";
|
||||
import "./MessageEditor.js";
|
||||
import type { Attachment } from "./utils/attachment-utils.js";
|
||||
import "./AgentInterface.js";
|
||||
import { AgentSession } from "./state/agent-session.js";
|
||||
import { getAuthToken } from "./utils/auth-token.js";
|
||||
|
||||
@customElement("pi-chat-panel")
|
||||
export class ChatPanel extends LitElement {
|
||||
@state() currentModel: Model<any> | null = null;
|
||||
@state() messageText = "";
|
||||
@state() attachments: Attachment[] = [];
|
||||
@state() private session!: AgentSession;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
|
|
@ -19,50 +16,42 @@ export class ChatPanel extends LitElement {
|
|||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Set default model
|
||||
this.currentModel = getModel("anthropic", "claude-3-5-haiku-20241022");
|
||||
|
||||
// Ensure panel fills height and allows flex layout
|
||||
this.style.display = "flex";
|
||||
this.style.flexDirection = "column";
|
||||
this.style.height = "100%";
|
||||
this.style.minHeight = "0";
|
||||
|
||||
// Create agent session with default settings
|
||||
this.session = new AgentSession({
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful AI assistant.",
|
||||
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
|
||||
tools: [calculateTool, getCurrentTimeTool],
|
||||
thinkingLevel: "off",
|
||||
},
|
||||
authTokenProvider: async () => getAuthToken(),
|
||||
transportMode: "direct", // Use direct mode by default (API keys from KeyStore)
|
||||
});
|
||||
}
|
||||
|
||||
private handleSend = (text: string, attachments: Attachment[]) => {
|
||||
// For now just alert and clear
|
||||
alert(`Message: ${text}\nAttachments: ${attachments.length}`);
|
||||
this.messageText = "";
|
||||
this.attachments = [];
|
||||
};
|
||||
|
||||
private handleModelSelect = () => {
|
||||
ModelSelector.open(this.currentModel, (model) => {
|
||||
this.currentModel = model;
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Messages area (empty for now) -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<!-- Messages will go here -->
|
||||
</div>
|
||||
if (!this.session) {
|
||||
return html`<div class="flex items-center justify-center h-full">
|
||||
<div class="text-muted-foreground">Loading...</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
<!-- Message editor at the bottom -->
|
||||
<div class="p-4 border-t border-border">
|
||||
<message-editor
|
||||
.value=${this.messageText}
|
||||
.currentModel=${this.currentModel}
|
||||
.attachments=${this.attachments}
|
||||
.showAttachmentButton=${true}
|
||||
.showThinking=${false}
|
||||
.onInput=${(value: string) => {
|
||||
this.messageText = value;
|
||||
}}
|
||||
.onSend=${this.handleSend}
|
||||
.onModelSelect=${this.handleModelSelect}
|
||||
.onFilesChange=${(files: Attachment[]) => {
|
||||
this.attachments = files;
|
||||
}}
|
||||
></message-editor>
|
||||
</div>
|
||||
</div>
|
||||
return html`
|
||||
<agent-interface
|
||||
.session=${this.session}
|
||||
.enableAttachments=${true}
|
||||
.enableModelSelector=${true}
|
||||
.enableThinking=${true}
|
||||
.showThemeToggle=${false}
|
||||
.showDebugToggle=${false}
|
||||
></agent-interface>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
packages/browser-extension/src/ConsoleBlock.ts
Normal file
67
packages/browser-extension/src/ConsoleBlock.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { html, icon } from "@mariozechner/mini-lit";
|
||||
import { LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { Check, Copy } from "lucide";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
|
||||
export class ConsoleBlock extends LitElement {
|
||||
@property() content: string = "";
|
||||
@state() private copied = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
private async copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.content || "");
|
||||
this.copied = true;
|
||||
setTimeout(() => {
|
||||
this.copied = false;
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
console.error("Copy failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
override updated() {
|
||||
// Auto-scroll to bottom on content changes
|
||||
const container = this.querySelector(".console-scroll") as HTMLElement | null;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="border border-border rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
|
||||
<span class="text-xs text-muted-foreground font-mono">${i18n("console")}</span>
|
||||
<button
|
||||
@click=${() => this.copy()}
|
||||
class="flex items-center gap-1 px-2 py-0.5 text-xs rounded hover:bg-accent text-muted-foreground hover:text-accent-foreground transition-colors"
|
||||
title="${i18n("Copy output")}"
|
||||
>
|
||||
${this.copied ? icon(Check, "sm") : icon(Copy, "sm")}
|
||||
${this.copied ? html`<span>${i18n("Copied!")}</span>` : ""}
|
||||
</button>
|
||||
</div>
|
||||
<div class="console-scroll overflow-auto max-h-64">
|
||||
<pre class="!bg-background !border-0 !rounded-none m-0 p-3 text-xs text-foreground font-mono whitespace-pre-wrap">
|
||||
${this.content || ""}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Register custom element
|
||||
if (!customElements.get("console-block")) {
|
||||
customElements.define("console-block", ConsoleBlock);
|
||||
}
|
||||
82
packages/browser-extension/src/MessageList.ts
Normal file
82
packages/browser-extension/src/MessageList.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { html } from "@mariozechner/mini-lit";
|
||||
import type {
|
||||
AgentTool,
|
||||
AssistantMessage as AssistantMessageType,
|
||||
Message,
|
||||
ToolResultMessage as ToolResultMessageType,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { LitElement, type TemplateResult } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
export class MessageList extends LitElement {
|
||||
@property({ type: Array }) messages: Message[] = [];
|
||||
@property({ type: Array }) tools: AgentTool[] = [];
|
||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
private buildRenderItems() {
|
||||
// Map tool results by call id for quick lookup
|
||||
const resultByCallId = new Map<string, ToolResultMessageType>();
|
||||
for (const message of this.messages) {
|
||||
if (message.role === "toolResult") {
|
||||
resultByCallId.set(message.toolCallId, message);
|
||||
}
|
||||
}
|
||||
|
||||
const items: Array<{ key: string; template: TemplateResult }> = [];
|
||||
let index = 0;
|
||||
for (const msg of this.messages) {
|
||||
if (msg.role === "user") {
|
||||
items.push({
|
||||
key: `msg:${index}`,
|
||||
template: html`<user-message .message=${msg}></user-message>`,
|
||||
});
|
||||
index++;
|
||||
} else if (msg.role === "assistant") {
|
||||
const amsg = msg as AssistantMessageType;
|
||||
items.push({
|
||||
key: `msg:${index}`,
|
||||
template: html`<assistant-message
|
||||
.message=${amsg}
|
||||
.tools=${this.tools}
|
||||
.isStreaming=${this.isStreaming}
|
||||
.pendingToolCalls=${this.pendingToolCalls}
|
||||
.toolResultsById=${resultByCallId}
|
||||
.hideToolCalls=${false}
|
||||
></assistant-message>`,
|
||||
});
|
||||
index++;
|
||||
} else {
|
||||
// Skip standalone toolResult messages; they are rendered via paired tool-message above
|
||||
// For completeness, other roles are not expected
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const items = this.buildRenderItems();
|
||||
return html`<div class="flex flex-col gap-3">
|
||||
${repeat(
|
||||
items,
|
||||
(it) => it.key,
|
||||
(it) => it.template,
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Register custom element
|
||||
if (!customElements.get("message-list")) {
|
||||
customElements.define("message-list", MessageList);
|
||||
}
|
||||
310
packages/browser-extension/src/Messages.ts
Normal file
310
packages/browser-extension/src/Messages.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { Button, html, icon } from "@mariozechner/mini-lit";
|
||||
import type {
|
||||
AgentTool,
|
||||
AssistantMessage as AssistantMessageType,
|
||||
ToolCall,
|
||||
ToolResultMessage as ToolResultMessageType,
|
||||
UserMessage as UserMessageType,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-ai/dist/agent/types.js";
|
||||
import { LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { Bug, Loader, Wrench } from "lucide";
|
||||
import { renderToolParams, renderToolResult } from "./tools/index.js";
|
||||
import type { Attachment } from "./utils/attachment-utils.js";
|
||||
import { formatUsage } from "./utils/format.js";
|
||||
import { i18n } from "./utils/i18n.js";
|
||||
|
||||
export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] };
|
||||
export type AppMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType;
|
||||
|
||||
@customElement("user-message")
|
||||
export class UserMessage extends LitElement {
|
||||
@property({ type: Object }) message!: UserMessageWithAttachments;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
override render() {
|
||||
const content =
|
||||
typeof this.message.content === "string"
|
||||
? this.message.content
|
||||
: this.message.content.find((c) => c.type === "text")?.text || "";
|
||||
|
||||
return html`
|
||||
<div class="py-4 px-4 border-l-4 border-accent-foreground/60 text-primary-foreground">
|
||||
<markdown-block .content=${content}></markdown-block>
|
||||
${
|
||||
this.message.attachments && this.message.attachments.length > 0
|
||||
? html`
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
${this.message.attachments.map(
|
||||
(attachment) => html` <attachment-tile .attachment=${attachment}></attachment-tile> `,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("assistant-message")
|
||||
export class AssistantMessage extends LitElement {
|
||||
@property({ type: Object }) message!: AssistantMessageType;
|
||||
@property({ type: Array }) tools?: AgentTool<any>[];
|
||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
||||
@property({ type: Boolean }) hideToolCalls = false;
|
||||
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessageType>;
|
||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
override render() {
|
||||
// Render content in the order it appears
|
||||
const orderedParts: TemplateResult[] = [];
|
||||
|
||||
for (const chunk of this.message.content) {
|
||||
if (chunk.type === "text" && chunk.text.trim() !== "") {
|
||||
orderedParts.push(html`<markdown-block .content=${chunk.text}></markdown-block>`);
|
||||
} else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") {
|
||||
orderedParts.push(html` <markdown-block .content=${chunk.thinking} .isThinking=${true}></markdown-block> `);
|
||||
} else if (chunk.type === "toolCall") {
|
||||
if (!this.hideToolCalls) {
|
||||
const tool = this.tools?.find((t) => t.name === chunk.name);
|
||||
const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
|
||||
const result = this.toolResultsById?.get(chunk.id);
|
||||
const aborted = !pending && !result && !this.isStreaming;
|
||||
orderedParts.push(
|
||||
html`<tool-message
|
||||
.tool=${tool}
|
||||
.toolCall=${chunk}
|
||||
.result=${result}
|
||||
.pending=${pending}
|
||||
.aborted=${aborted}
|
||||
.isStreaming=${this.isStreaming}
|
||||
></tool-message>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${orderedParts.length ? html` <div class="px-4 flex flex-col gap-3">${orderedParts}</div> ` : ""}
|
||||
${
|
||||
this.message.usage
|
||||
? html` <div class="px-4 mt-2 text-xs text-muted-foreground">${formatUsage(this.message.usage)}</div> `
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this.message.stopReason === "error" && this.message.errorMessage
|
||||
? html`
|
||||
<div class="mx-4 mt-3 p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
|
||||
<strong>${i18n("Error:")}</strong> ${this.message.errorMessage}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this.message.stopReason === "aborted"
|
||||
? html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("tool-message-debug")
|
||||
export class ToolMessageDebugView extends LitElement {
|
||||
@property({ type: Object }) callArgs: any;
|
||||
@property({ type: String }) result?: AgentToolResult<any>;
|
||||
@property({ type: Boolean }) hasResult: boolean = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this; // light DOM for shared styles
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
private pretty(value: unknown): { content: string; isJson: boolean } {
|
||||
try {
|
||||
if (typeof value === "string") {
|
||||
const maybeJson = JSON.parse(value);
|
||||
return { content: JSON.stringify(maybeJson, null, 2), isJson: true };
|
||||
}
|
||||
return { content: JSON.stringify(value, null, 2), isJson: true };
|
||||
} catch {
|
||||
return { content: typeof value === "string" ? value : String(value), isJson: false };
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const output = this.pretty(this.result?.output);
|
||||
const details = this.pretty(this.result?.details);
|
||||
|
||||
return html`
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<div>
|
||||
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Call")}</div>
|
||||
<code-block .code=${this.pretty(this.callArgs).content} language="json"></code-block>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Result")}</div>
|
||||
${
|
||||
this.hasResult
|
||||
? html`<code-block .code=${output.content} language="${output.isJson ? "json" : "text"}"></code-block>
|
||||
<code-block .code=${details.content} language="${details.isJson ? "json" : "text"}"></code-block>`
|
||||
: html`<div class="text-xs text-muted-foreground">${i18n("(no result)")}</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("tool-message")
|
||||
export class ToolMessage extends LitElement {
|
||||
@property({ type: Object }) toolCall!: ToolCall;
|
||||
@property({ type: Object }) tool?: AgentTool<any>;
|
||||
@property({ type: Object }) result?: ToolResultMessageType;
|
||||
@property({ type: Boolean }) pending: boolean = false;
|
||||
@property({ type: Boolean }) aborted: boolean = false;
|
||||
@property({ type: Boolean }) isStreaming: boolean = false;
|
||||
@state() private _showDebug = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
private toggleDebug = () => {
|
||||
this._showDebug = !this._showDebug;
|
||||
};
|
||||
|
||||
override render() {
|
||||
const toolLabel = this.tool?.label || this.toolCall.name;
|
||||
const toolName = this.tool?.name || this.toolCall.name;
|
||||
const isError = this.result?.isError === true;
|
||||
const hasResult = !!this.result;
|
||||
|
||||
let statusIcon: TemplateResult;
|
||||
if (this.pending || (this.isStreaming && !hasResult)) {
|
||||
statusIcon = html`<span class="inline-block text-muted-foreground animate-spin">${icon(Loader, "md")}</span>`;
|
||||
} else if (this.aborted && !hasResult) {
|
||||
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "md")}</span>`;
|
||||
} else if (hasResult && isError) {
|
||||
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "md")}</span>`;
|
||||
} else if (hasResult) {
|
||||
statusIcon = html`<span class="inline-block text-foreground">${icon(Wrench, "md")}</span>`;
|
||||
} else {
|
||||
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "md")}</span>`;
|
||||
}
|
||||
|
||||
// Normalize error text
|
||||
let errorMessage = this.result?.output || "";
|
||||
if (isError) {
|
||||
try {
|
||||
const parsed = JSON.parse(errorMessage);
|
||||
if ((parsed as any).error) errorMessage = (parsed as any).error;
|
||||
else if ((parsed as any).message) errorMessage = (parsed as any).message;
|
||||
} catch {}
|
||||
errorMessage = errorMessage.replace(/^(Tool )?Error:\s*/i, "");
|
||||
errorMessage = errorMessage.replace(/^Error:\s*/i, "");
|
||||
}
|
||||
|
||||
const paramsTpl = renderToolParams(
|
||||
toolName,
|
||||
this.toolCall.arguments,
|
||||
this.isStreaming || (this.pending && !hasResult),
|
||||
);
|
||||
const resultTpl =
|
||||
hasResult && !isError ? renderToolResult(toolName, this.toolCall.arguments, this.result!) : undefined;
|
||||
|
||||
return html`
|
||||
<div class="p-2.5 border border-border rounded-md bg-card text-card-foreground">
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div class="flex items-center gap-2">
|
||||
${statusIcon}
|
||||
<span class="font-medium">${toolLabel}</span>
|
||||
</div>
|
||||
${Button({
|
||||
variant: this._showDebug ? "default" : "ghost",
|
||||
size: "sm",
|
||||
onClick: this.toggleDebug,
|
||||
children: icon(Bug, "sm"),
|
||||
className: "h-8 w-8",
|
||||
})}
|
||||
</div>
|
||||
|
||||
${
|
||||
this._showDebug
|
||||
? html`<tool-message-debug
|
||||
.callArgs=${this.toolCall.arguments}
|
||||
.result=${this.result}
|
||||
.hasResult=${!!this.result}
|
||||
></tool-message-debug>`
|
||||
: html`
|
||||
<div class="mt-2 text-sm text-muted-foreground">${paramsTpl}</div>
|
||||
${
|
||||
this.pending && !hasResult
|
||||
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Waiting for tool result…")}</div>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
this.aborted && !hasResult
|
||||
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Call was aborted; no result.")}</div>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
hasResult && isError
|
||||
? html`<div class="mt-2 p-2 border border-destructive rounded bg-destructive/10 text-sm text-destructive">
|
||||
${errorMessage}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
${resultTpl ? html`<div class="mt-2">${resultTpl}</div>` : ""}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("aborted-message")
|
||||
export class AbortedMessage extends LitElement {
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
return html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`;
|
||||
}
|
||||
}
|
||||
99
packages/browser-extension/src/StreamingMessageContainer.ts
Normal file
99
packages/browser-extension/src/StreamingMessageContainer.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { html } from "@mariozechner/mini-lit";
|
||||
import type { AgentTool, Message, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
|
||||
export class StreamingMessageContainer extends LitElement {
|
||||
@property({ type: Array }) tools: AgentTool[] = [];
|
||||
@property({ type: Boolean }) isStreaming = false;
|
||||
@property({ type: Object }) pendingToolCalls?: Set<string>;
|
||||
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessage>;
|
||||
|
||||
@state() private _message: Message | null = null;
|
||||
private _pendingMessage: Message | null = null;
|
||||
private _updateScheduled = false;
|
||||
private _immediateUpdate = false;
|
||||
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.display = "block";
|
||||
}
|
||||
|
||||
// Public method to update the message with batching for performance
|
||||
public setMessage(message: Message | null, immediate = false) {
|
||||
// Store the latest message
|
||||
this._pendingMessage = message;
|
||||
|
||||
// If this is an immediate update (like clearing), apply it right away
|
||||
if (immediate || message === null) {
|
||||
this._immediateUpdate = true;
|
||||
this._message = message;
|
||||
this.requestUpdate();
|
||||
// Cancel any pending updates since we're clearing
|
||||
this._pendingMessage = null;
|
||||
this._updateScheduled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise batch updates for performance during streaming
|
||||
if (!this._updateScheduled) {
|
||||
this._updateScheduled = true;
|
||||
|
||||
requestAnimationFrame(async () => {
|
||||
// Only apply the update if we haven't been cleared
|
||||
if (!this._immediateUpdate && this._pendingMessage !== null) {
|
||||
this._message = this._pendingMessage;
|
||||
this.requestUpdate();
|
||||
}
|
||||
// Reset for next batch
|
||||
this._pendingMessage = null;
|
||||
this._updateScheduled = false;
|
||||
this._immediateUpdate = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
// Show loading indicator if loading but no message yet
|
||||
if (!this._message) {
|
||||
if (this.isStreaming)
|
||||
return html`<div class="flex flex-col gap-3 mb-3">
|
||||
<span class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"></span>
|
||||
</div>`;
|
||||
return html``; // Empty until a message is set
|
||||
}
|
||||
const msg = this._message;
|
||||
|
||||
if (msg.role === "toolResult") {
|
||||
// Skip standalone tool result in streaming; the stable list will render paired tool-message
|
||||
return html``;
|
||||
} else if (msg.role === "user") {
|
||||
// Skip standalone tool result in streaming; the stable list will render it immediiately
|
||||
return html``;
|
||||
} else if (msg.role === "assistant") {
|
||||
// Assistant message - render inline tool messages during streaming
|
||||
return html`
|
||||
<div class="flex flex-col gap-3 mb-3">
|
||||
<assistant-message
|
||||
.message=${msg}
|
||||
.tools=${this.tools}
|
||||
.isStreaming=${this.isStreaming}
|
||||
.pendingToolCalls=${this.pendingToolCalls}
|
||||
.toolResultsById=${this.toolResultsById}
|
||||
.hideToolCalls=${false}
|
||||
></assistant-message>
|
||||
${this.isStreaming ? html`<span class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"></span>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register custom element
|
||||
if (!customElements.get("streaming-message-container")) {
|
||||
customElements.define("streaming-message-container", StreamingMessageContainer);
|
||||
}
|
||||
|
|
@ -190,7 +190,7 @@ export class ApiKeysDialog extends DialogBase {
|
|||
(provider) => html`
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium capitalize">${provider}</span>
|
||||
<span class="text-sm font-medium text-muted-foreground capitalize">${provider}</span>
|
||||
${
|
||||
this.apiKeys[provider]
|
||||
? Badge({ children: i18n("Configured"), variant: "default" })
|
||||
|
|
|
|||
|
|
@ -17,13 +17,18 @@ export class PromptDialog extends DialogBase {
|
|||
@property() isPassword = false;
|
||||
|
||||
@state() private inputValue = "";
|
||||
private resolvePromise?: (value: string | null) => void;
|
||||
private resolvePromise?: (value: string | undefined) => void;
|
||||
private inputRef = createRef<HTMLInputElement>();
|
||||
|
||||
protected override modalWidth = "min(400px, 90vw)";
|
||||
protected override modalHeight = "auto";
|
||||
|
||||
static async ask(title: string, message: string, defaultValue = "", isPassword = false): Promise<string | null> {
|
||||
static async ask(
|
||||
title: string,
|
||||
message: string,
|
||||
defaultValue = "",
|
||||
isPassword = false,
|
||||
): Promise<string | undefined> {
|
||||
const dialog = new PromptDialog();
|
||||
dialog.headerTitle = title;
|
||||
dialog.message = message;
|
||||
|
|
@ -48,7 +53,7 @@ export class PromptDialog extends DialogBase {
|
|||
}
|
||||
|
||||
private handleCancel() {
|
||||
this.resolvePromise?.(null);
|
||||
this.resolvePromise?.(undefined);
|
||||
this.close();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<html lang="en">
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>pi-ai</title>
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
</head>
|
||||
<body class="h-full w-full">
|
||||
<body class="h-full w-full m-0 overflow-hidden">
|
||||
<script type="module" src="sidepanel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -23,12 +23,6 @@ export class Header extends LitElement {
|
|||
return this;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const resp = await fetch("https://genai.mariozechner.at/api/health");
|
||||
console.log(await resp.json());
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="flex items-center px-4 py-2 border-b border-border mb-4">
|
||||
|
|
@ -48,9 +42,9 @@ export class Header extends LitElement {
|
|||
}
|
||||
|
||||
const app = html`
|
||||
<div class="w-full h-full flex flex-col bg-background text-foreground">
|
||||
<pi-chat-header></pi-chat-header>
|
||||
<pi-chat-panel></pi-chat-panel>
|
||||
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<pi-chat-header class="shrink-0"></pi-chat-header>
|
||||
<pi-chat-panel class="flex-1 min-h-0"></pi-chat-panel>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
|
|||
306
packages/browser-extension/src/state/agent-session.ts
Normal file
306
packages/browser-extension/src/state/agent-session.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import type { Context } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
type AgentTool,
|
||||
type AssistantMessage as AssistantMessageType,
|
||||
getModel,
|
||||
type ImageContent,
|
||||
type Message,
|
||||
type Model,
|
||||
type TextContent,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { AppMessage } from "../Messages.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { getAuthToken } from "../utils/auth-token.js";
|
||||
import { DirectTransport } from "./transports/DirectTransport.js";
|
||||
import { ProxyTransport } from "./transports/ProxyTransport.js";
|
||||
import type { AgentRunConfig, AgentTransport } from "./transports/types.js";
|
||||
import type { DebugLogEntry } from "./types.js";
|
||||
|
||||
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
|
||||
|
||||
export interface AgentSessionState {
|
||||
id: string;
|
||||
systemPrompt: string;
|
||||
model: Model<any> | null;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
tools: AgentTool<any>[];
|
||||
messages: AppMessage[];
|
||||
isStreaming: boolean;
|
||||
streamMessage: Message | null;
|
||||
pendingToolCalls: Set<string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type AgentSessionEvent =
|
||||
| { type: "state-update"; state: AgentSessionState }
|
||||
| { type: "error-no-model" }
|
||||
| { type: "error-no-api-key"; provider: string };
|
||||
|
||||
export type TransportMode = "direct" | "proxy";
|
||||
|
||||
export interface AgentSessionOptions {
|
||||
initialState?: Partial<AgentSessionState>;
|
||||
messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
|
||||
debugListener?: (entry: DebugLogEntry) => void;
|
||||
transportMode?: TransportMode;
|
||||
authTokenProvider?: () => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
export class AgentSession {
|
||||
private _state: AgentSessionState = {
|
||||
id: "default",
|
||||
systemPrompt: "",
|
||||
model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"),
|
||||
thinkingLevel: "off",
|
||||
tools: [],
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
streamMessage: null,
|
||||
pendingToolCalls: new Set<string>(),
|
||||
error: undefined,
|
||||
};
|
||||
private listeners = new Set<(e: AgentSessionEvent) => void>();
|
||||
private abortController?: AbortController;
|
||||
private transport: AgentTransport;
|
||||
private messagePreprocessor?: (messages: AppMessage[]) => Promise<Message[]>;
|
||||
private debugListener?: (entry: DebugLogEntry) => void;
|
||||
|
||||
constructor(opts: AgentSessionOptions = {}) {
|
||||
this._state = { ...this._state, ...opts.initialState };
|
||||
this.messagePreprocessor = opts.messagePreprocessor;
|
||||
this.debugListener = opts.debugListener;
|
||||
|
||||
const mode = opts.transportMode || "direct";
|
||||
|
||||
if (mode === "proxy") {
|
||||
this.transport = new ProxyTransport(async () => this.preprocessMessages());
|
||||
} else {
|
||||
this.transport = new DirectTransport(async () => this.preprocessMessages());
|
||||
}
|
||||
}
|
||||
|
||||
private async preprocessMessages(): Promise<Message[]> {
|
||||
const filtered = this._state.messages.map((m) => {
|
||||
if (m.role === "user") {
|
||||
const { attachments, ...rest } = m as AppMessage & { attachments?: Attachment[] };
|
||||
return rest;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
return this.messagePreprocessor ? this.messagePreprocessor(filtered as AppMessage[]) : (filtered as Message[]);
|
||||
}
|
||||
|
||||
get state(): AgentSessionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
subscribe(fn: (e: AgentSessionEvent) => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
fn({ type: "state-update", state: this._state });
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
// Mutators
|
||||
setSystemPrompt(v: string) {
|
||||
this.patch({ systemPrompt: v });
|
||||
}
|
||||
setModel(m: Model<any> | null) {
|
||||
this.patch({ model: m });
|
||||
}
|
||||
setThinkingLevel(l: ThinkingLevel) {
|
||||
this.patch({ thinkingLevel: l });
|
||||
}
|
||||
setTools(t: AgentTool<any>[]) {
|
||||
this.patch({ tools: t });
|
||||
}
|
||||
replaceMessages(ms: AppMessage[]) {
|
||||
this.patch({ messages: ms.slice() });
|
||||
}
|
||||
appendMessage(m: AppMessage) {
|
||||
this.patch({ messages: [...this._state.messages, m] });
|
||||
}
|
||||
clearMessages() {
|
||||
this.patch({ messages: [] });
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
async prompt(input: string, attachments?: Attachment[]) {
|
||||
const model = this._state.model;
|
||||
if (!model) {
|
||||
this.emit({ type: "error-no-model" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build user message with attachments
|
||||
const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
|
||||
if (attachments?.length) {
|
||||
for (const a of attachments) {
|
||||
if (a.type === "image") {
|
||||
content.push({ type: "image", data: a.content, mimeType: a.mimeType });
|
||||
} else if (a.type === "document" && a.extractedText) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: `\n\n[Document: ${a.fileName}]\n${a.extractedText}`,
|
||||
isDocument: true,
|
||||
} as TextContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage: AppMessage = {
|
||||
role: "user",
|
||||
content,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
};
|
||||
|
||||
this.abortController = new AbortController();
|
||||
this.patch({ isStreaming: true, streamMessage: null, error: undefined });
|
||||
|
||||
const reasoning =
|
||||
this._state.thinkingLevel === "off"
|
||||
? undefined
|
||||
: this._state.thinkingLevel === "minimal"
|
||||
? "low"
|
||||
: this._state.thinkingLevel;
|
||||
const cfg: AgentRunConfig = {
|
||||
systemPrompt: this._state.systemPrompt,
|
||||
tools: this._state.tools,
|
||||
model,
|
||||
reasoning,
|
||||
};
|
||||
|
||||
try {
|
||||
let partial: Message | null = null;
|
||||
let turnDebug: DebugLogEntry | null = null;
|
||||
let turnStart = 0;
|
||||
for await (const ev of this.transport.run(userMessage as Message, cfg, this.abortController.signal)) {
|
||||
switch (ev.type) {
|
||||
case "turn_start": {
|
||||
turnStart = performance.now();
|
||||
// Build request context snapshot
|
||||
const existing = this._state.messages as Message[];
|
||||
const ctx: Context = {
|
||||
systemPrompt: this._state.systemPrompt,
|
||||
messages: [...existing],
|
||||
tools: this._state.tools,
|
||||
};
|
||||
turnDebug = {
|
||||
timestamp: new Date().toISOString(),
|
||||
request: {
|
||||
provider: cfg.model.provider,
|
||||
model: cfg.model.id,
|
||||
context: { ...ctx },
|
||||
},
|
||||
sseEvents: [],
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "message_start":
|
||||
case "message_update": {
|
||||
partial = ev.message;
|
||||
// Collect SSE-like events for debug (drop heavy partial)
|
||||
if (ev.type === "message_update" && ev.assistantMessageEvent && turnDebug) {
|
||||
const copy: any = { ...ev.assistantMessageEvent };
|
||||
if (copy && "partial" in copy) delete copy.partial;
|
||||
turnDebug.sseEvents.push(JSON.stringify(copy));
|
||||
if (!turnDebug.ttft) turnDebug.ttft = performance.now() - turnStart;
|
||||
}
|
||||
this.patch({ streamMessage: ev.message });
|
||||
break;
|
||||
}
|
||||
case "message_end": {
|
||||
partial = null;
|
||||
this.appendMessage(ev.message as AppMessage);
|
||||
this.patch({ streamMessage: null });
|
||||
if (turnDebug) {
|
||||
if (ev.message.role !== "assistant" && ev.message.role !== "toolResult") {
|
||||
turnDebug.request.context.messages.push(ev.message);
|
||||
}
|
||||
if (ev.message.role === "assistant") turnDebug.response = ev.message as any;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_execution_start": {
|
||||
const s = new Set(this._state.pendingToolCalls);
|
||||
s.add(ev.toolCallId);
|
||||
this.patch({ pendingToolCalls: s });
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
const s = new Set(this._state.pendingToolCalls);
|
||||
s.delete(ev.toolCallId);
|
||||
this.patch({ pendingToolCalls: s });
|
||||
break;
|
||||
}
|
||||
case "turn_end": {
|
||||
// finalize current turn
|
||||
if (turnDebug) {
|
||||
turnDebug.totalTime = performance.now() - turnStart;
|
||||
this.debugListener?.(turnDebug);
|
||||
turnDebug = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_end": {
|
||||
this.patch({ streamMessage: null });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (partial && partial.role === "assistant" && partial.content.length > 0) {
|
||||
const onlyEmpty = !partial.content.some(
|
||||
(c) =>
|
||||
(c.type === "thinking" && c.thinking.trim().length > 0) ||
|
||||
(c.type === "text" && c.text.trim().length > 0) ||
|
||||
(c.type === "toolCall" && c.name.trim().length > 0),
|
||||
);
|
||||
if (!onlyEmpty) {
|
||||
this.appendMessage(partial as AppMessage);
|
||||
} else {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (String(err?.message || err) === "no-api-key") {
|
||||
this.emit({ type: "error-no-api-key", provider: model.provider });
|
||||
} else {
|
||||
const msg: AssistantMessageType = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: this.abortController?.signal.aborted ? "aborted" : "error",
|
||||
errorMessage: err?.message || String(err),
|
||||
};
|
||||
this.appendMessage(msg as AppMessage);
|
||||
this.patch({ error: err?.message || String(err) });
|
||||
}
|
||||
} finally {
|
||||
this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set<string>() });
|
||||
this.abortController = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private patch(p: Partial<AgentSessionState>): void {
|
||||
this._state = { ...this._state, ...p };
|
||||
this.emit({ type: "state-update", state: this._state });
|
||||
}
|
||||
|
||||
private emit(e: AgentSessionEvent) {
|
||||
this.listeners.forEach((l) => l(e));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai";
|
||||
import { keyStore } from "../KeyStore.js";
|
||||
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
||||
|
||||
export class DirectTransport implements AgentTransport {
|
||||
constructor(private readonly getMessages: () => Promise<Message[]>) {}
|
||||
|
||||
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
// Get API key from KeyStore
|
||||
const apiKey = await keyStore.getKey(cfg.model.provider);
|
||||
if (!apiKey) {
|
||||
throw new Error("no-api-key");
|
||||
}
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: cfg.systemPrompt,
|
||||
messages: await this.getMessages(),
|
||||
tools: cfg.tools,
|
||||
};
|
||||
|
||||
const pc: PromptConfig = {
|
||||
model: cfg.model,
|
||||
reasoning: cfg.reasoning,
|
||||
apiKey,
|
||||
};
|
||||
|
||||
// Yield events from agentLoop
|
||||
for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal)) {
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
import type {
|
||||
AgentContext,
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
Context,
|
||||
Message,
|
||||
Model,
|
||||
PromptConfig,
|
||||
SimpleStreamOptions,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { agentLoop } from "@mariozechner/pi-ai";
|
||||
import { AssistantMessageEventStream } from "@mariozechner/pi-ai/dist/utils/event-stream.js";
|
||||
import { parseStreamingJson } from "@mariozechner/pi-ai/dist/utils/json-parse.js";
|
||||
import { clearAuthToken, getAuthToken } from "../../utils/auth-token.js";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ProxyAssistantMessageEvent } from "./proxy-types.js";
|
||||
import type { AgentRunConfig, AgentTransport } from "./types.js";
|
||||
|
||||
/**
|
||||
* Stream function that proxies through a server instead of calling providers directly.
|
||||
* The server strips the partial field from delta events to reduce bandwidth.
|
||||
* We reconstruct the partial message client-side.
|
||||
*/
|
||||
function streamSimpleProxy(
|
||||
model: Model<any>,
|
||||
context: Context,
|
||||
options: SimpleStreamOptions & { authToken: string },
|
||||
proxyUrl: string,
|
||||
): AssistantMessageEventStream {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
// Initialize the partial message that we'll build up from events
|
||||
const partial: AssistantMessage = {
|
||||
role: "assistant",
|
||||
stopReason: "stop",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
|
||||
// Set up abort handler to cancel the reader
|
||||
const abortHandler = () => {
|
||||
if (reader) {
|
||||
reader.cancel("Request aborted by user").catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener("abort", abortHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${proxyUrl}/api/stream`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
context,
|
||||
options: {
|
||||
temperature: options.temperature,
|
||||
maxTokens: options.maxTokens,
|
||||
reasoning: options.reasoning,
|
||||
// Don't send apiKey or signal - those are added server-side
|
||||
},
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Proxy error: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error) {
|
||||
errorMessage = `Proxy error: ${errorData.error}`;
|
||||
}
|
||||
} catch {
|
||||
// Couldn't parse error response, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Parse SSE stream
|
||||
reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// Check if aborted after reading
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Request aborted by user");
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data) {
|
||||
const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent;
|
||||
let event: AssistantMessageEvent | undefined;
|
||||
|
||||
// Handle different event types
|
||||
// Server sends events with partial for non-delta events,
|
||||
// and without partial for delta events
|
||||
switch (proxyEvent.type) {
|
||||
case "start":
|
||||
event = { type: "start", partial };
|
||||
break;
|
||||
|
||||
case "text_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
type: "text",
|
||||
text: "",
|
||||
};
|
||||
event = { type: "text_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
break;
|
||||
|
||||
case "text_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "text") {
|
||||
content.text += proxyEvent.delta;
|
||||
event = {
|
||||
type: "text_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received text_delta for non-text content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "text_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "text") {
|
||||
content.textSignature = proxyEvent.contentSignature;
|
||||
event = {
|
||||
type: "text_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
content: content.text,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received text_end for non-text content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "thinking_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
};
|
||||
event = { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
break;
|
||||
|
||||
case "thinking_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "thinking") {
|
||||
content.thinking += proxyEvent.delta;
|
||||
event = {
|
||||
type: "thinking_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received thinking_delta for non-thinking content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "thinking_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "thinking") {
|
||||
content.thinkingSignature = proxyEvent.contentSignature;
|
||||
event = {
|
||||
type: "thinking_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
content: content.thinking,
|
||||
partial,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Received thinking_end for non-thinking content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "toolcall_start":
|
||||
partial.content[proxyEvent.contentIndex] = {
|
||||
type: "toolCall",
|
||||
id: proxyEvent.id,
|
||||
name: proxyEvent.toolName,
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
} satisfies ToolCall & { partialJson: string } as ToolCall;
|
||||
event = { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
|
||||
break;
|
||||
|
||||
case "toolcall_delta": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
(content as any).partialJson += proxyEvent.delta;
|
||||
content.arguments = parseStreamingJson((content as any).partialJson) || {};
|
||||
event = {
|
||||
type: "toolcall_delta",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
delta: proxyEvent.delta,
|
||||
partial,
|
||||
};
|
||||
partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
|
||||
} else {
|
||||
throw new Error("Received toolcall_delta for non-toolCall content");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "toolcall_end": {
|
||||
const content = partial.content[proxyEvent.contentIndex];
|
||||
if (content?.type === "toolCall") {
|
||||
delete (content as any).partialJson;
|
||||
event = {
|
||||
type: "toolcall_end",
|
||||
contentIndex: proxyEvent.contentIndex,
|
||||
toolCall: content,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "done":
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.usage = proxyEvent.usage;
|
||||
event = { type: "done", reason: proxyEvent.reason, message: partial };
|
||||
break;
|
||||
|
||||
case "error":
|
||||
partial.stopReason = proxyEvent.reason;
|
||||
partial.errorMessage = proxyEvent.errorMessage;
|
||||
partial.usage = proxyEvent.usage;
|
||||
event = { type: "error", reason: proxyEvent.reason, error: partial };
|
||||
break;
|
||||
|
||||
default: {
|
||||
// Exhaustive check
|
||||
const _exhaustiveCheck: never = proxyEvent;
|
||||
console.warn(`Unhandled event type: ${(proxyEvent as any).type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Push the event to stream
|
||||
if (event) {
|
||||
stream.push(event);
|
||||
} else {
|
||||
throw new Error("Failed to create event from proxy event");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if aborted after reading
|
||||
if (options.signal?.aborted) {
|
||||
throw new Error("Request aborted by user");
|
||||
}
|
||||
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.toLowerCase().includes("proxy") && errorMessage.includes("Unauthorized")) {
|
||||
clearAuthToken();
|
||||
}
|
||||
partial.stopReason = options.signal?.aborted ? "aborted" : "error";
|
||||
partial.errorMessage = errorMessage;
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: partial.stopReason,
|
||||
error: partial,
|
||||
} satisfies AssistantMessageEvent);
|
||||
stream.end();
|
||||
} finally {
|
||||
// Clean up abort handler
|
||||
if (options.signal) {
|
||||
options.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
// Proxy transport executes the turn using a remote proxy server
|
||||
export class ProxyTransport implements AgentTransport {
|
||||
// Hardcoded proxy URL for now - will be made configurable later
|
||||
private readonly proxyUrl = "https://genai.mariozechner.at";
|
||||
|
||||
constructor(private readonly getMessages: () => Promise<Message[]>) {}
|
||||
|
||||
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
|
||||
const authToken = await getAuthToken();
|
||||
if (!authToken) {
|
||||
throw new Error(i18n("Auth token is required for proxy transport"));
|
||||
}
|
||||
|
||||
// Use proxy - no local API key needed
|
||||
const streamFn = (model: Model<any>, context: Context, options: SimpleStreamOptions | undefined) => {
|
||||
return streamSimpleProxy(
|
||||
model,
|
||||
context,
|
||||
{
|
||||
...options,
|
||||
authToken,
|
||||
},
|
||||
this.proxyUrl,
|
||||
);
|
||||
};
|
||||
|
||||
const context: AgentContext = {
|
||||
systemPrompt: cfg.systemPrompt,
|
||||
messages: await this.getMessages(),
|
||||
tools: cfg.tools,
|
||||
};
|
||||
|
||||
const pc: PromptConfig = {
|
||||
model: cfg.model,
|
||||
reasoning: cfg.reasoning,
|
||||
};
|
||||
|
||||
// Yield events from the upstream agentLoop iterator
|
||||
// Pass streamFn as the 5th parameter to use proxy
|
||||
for await (const ev of agentLoop(userMessage as unknown as UserMessage, context, pc, signal, streamFn)) {
|
||||
yield ev;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
packages/browser-extension/src/state/transports/index.ts
Normal file
3
packages/browser-extension/src/state/transports/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./DirectTransport.js";
|
||||
export * from "./ProxyTransport.js";
|
||||
export * from "./types.js";
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import type { StopReason, Usage } from "@mariozechner/pi-ai";
|
||||
|
||||
export type ProxyAssistantMessageEvent =
|
||||
| { type: "start" }
|
||||
| { type: "text_start"; contentIndex: number }
|
||||
| { type: "text_delta"; contentIndex: number; delta: string }
|
||||
| { type: "text_end"; contentIndex: number; contentSignature?: string }
|
||||
| { type: "thinking_start"; contentIndex: number }
|
||||
| { type: "thinking_delta"; contentIndex: number; delta: string }
|
||||
| { type: "thinking_end"; contentIndex: number; contentSignature?: string }
|
||||
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
|
||||
| { type: "toolcall_delta"; contentIndex: number; delta: string }
|
||||
| { type: "toolcall_end"; contentIndex: number }
|
||||
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; usage: Usage }
|
||||
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; errorMessage: string; usage: Usage };
|
||||
16
packages/browser-extension/src/state/transports/types.ts
Normal file
16
packages/browser-extension/src/state/transports/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { AgentEvent, AgentTool, Message, Model } from "@mariozechner/pi-ai";
|
||||
|
||||
// The minimal configuration needed to run a turn.
|
||||
export interface AgentRunConfig {
|
||||
systemPrompt: string;
|
||||
tools: AgentTool<any>[];
|
||||
model: Model<any>;
|
||||
reasoning?: "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
// Events yielded by transports must match the @mariozechner/pi-ai prompt() events.
|
||||
// We re-export the Message type above; consumers should use the upstream AgentEvent type.
|
||||
|
||||
export interface AgentTransport {
|
||||
run(userMessage: Message, config: AgentRunConfig, signal?: AbortSignal): AsyncIterable<AgentEvent>; // passthrough of AgentEvent from upstream
|
||||
}
|
||||
11
packages/browser-extension/src/state/types.ts
Normal file
11
packages/browser-extension/src/state/types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { AssistantMessage, Context } from "@mariozechner/pi-ai";
|
||||
|
||||
export interface DebugLogEntry {
|
||||
timestamp: string;
|
||||
request: { provider: string; model: string; context: Context };
|
||||
response?: AssistantMessage;
|
||||
error?: unknown;
|
||||
sseEvents: string[];
|
||||
ttft?: number;
|
||||
totalTime?: number;
|
||||
}
|
||||
38
packages/browser-extension/src/tools/index.ts
Normal file
38
packages/browser-extension/src/tools/index.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
|
||||
import { BashRenderer } from "./renderers/BashRenderer.js";
|
||||
import { CalculateRenderer } from "./renderers/CalculateRenderer.js";
|
||||
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
|
||||
import { GetCurrentTimeRenderer } from "./renderers/GetCurrentTimeRenderer.js";
|
||||
|
||||
// Register all built-in tool renderers
|
||||
registerToolRenderer("calculate", new CalculateRenderer());
|
||||
registerToolRenderer("get_current_time", new GetCurrentTimeRenderer());
|
||||
registerToolRenderer("bash", new BashRenderer());
|
||||
|
||||
const defaultRenderer = new DefaultRenderer();
|
||||
|
||||
/**
|
||||
* Render tool call parameters
|
||||
*/
|
||||
export function renderToolParams(toolName: string, params: any, isStreaming?: boolean): TemplateResult {
|
||||
const renderer = getToolRenderer(toolName);
|
||||
if (renderer) {
|
||||
return renderer.renderParams(params, isStreaming);
|
||||
}
|
||||
return defaultRenderer.renderParams(params, isStreaming);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tool result
|
||||
*/
|
||||
export function renderToolResult(toolName: string, params: any, result: ToolResultMessage): TemplateResult {
|
||||
const renderer = getToolRenderer(toolName);
|
||||
if (renderer) {
|
||||
return renderer.renderResult(params, result);
|
||||
}
|
||||
return defaultRenderer.renderResult(params, result);
|
||||
}
|
||||
|
||||
export { registerToolRenderer, getToolRenderer };
|
||||
18
packages/browser-extension/src/tools/renderer-registry.ts
Normal file
18
packages/browser-extension/src/tools/renderer-registry.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { ToolRenderer } from "./types.js";
|
||||
|
||||
// Registry of tool renderers
|
||||
export const toolRenderers = new Map<string, ToolRenderer>();
|
||||
|
||||
/**
|
||||
* Register a custom tool renderer
|
||||
*/
|
||||
export function registerToolRenderer(toolName: string, renderer: ToolRenderer): void {
|
||||
toolRenderers.set(toolName, renderer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a tool renderer by name
|
||||
*/
|
||||
export function getToolRenderer(toolName: string): ToolRenderer | undefined {
|
||||
return toolRenderers.get(toolName);
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ToolRenderer } from "../types.js";
|
||||
|
||||
interface BashParams {
|
||||
command: string;
|
||||
}
|
||||
|
||||
// Bash tool has undefined details (only uses output)
|
||||
export class BashRenderer implements ToolRenderer<BashParams, undefined> {
|
||||
renderParams(params: BashParams, isStreaming?: boolean): TemplateResult {
|
||||
if (isStreaming && (!params.command || params.command.length === 0)) {
|
||||
return html`<div class="text-sm text-muted-foreground">${i18n("Writing command...")}</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span>${i18n("Running command:")}</span>
|
||||
<code class="ml-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.command}</code>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderResult(_params: BashParams, result: ToolResultMessage<undefined>): TemplateResult {
|
||||
const output = result.output || "";
|
||||
const isError = result.isError === true;
|
||||
|
||||
if (isError) {
|
||||
return html`
|
||||
<div class="text-sm">
|
||||
<div class="text-destructive font-medium mb-1">${i18n("Command failed:")}</div>
|
||||
<pre class="text-xs font-mono text-destructive bg-destructive/10 p-2 rounded overflow-x-auto">${output}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Display the command output
|
||||
return html`
|
||||
<div class="text-sm">
|
||||
<pre class="text-xs font-mono text-foreground bg-muted/50 p-2 rounded overflow-x-auto">${output}</pre>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ToolRenderer } from "../types.js";
|
||||
|
||||
interface CalculateParams {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
// Calculate tool has undefined details (only uses output)
|
||||
export class CalculateRenderer implements ToolRenderer<CalculateParams, undefined> {
|
||||
renderParams(params: CalculateParams, isStreaming?: boolean): TemplateResult {
|
||||
if (isStreaming && !params.expression) {
|
||||
return html`<div class="text-sm text-muted-foreground">${i18n("Writing expression...")}</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span>${i18n("Calculating")}</span>
|
||||
<code class="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.expression}</code>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderResult(_params: CalculateParams, result: ToolResultMessage<undefined>): TemplateResult {
|
||||
// Parse the output to make it look nicer
|
||||
const output = result.output || "";
|
||||
const isError = result.isError === true;
|
||||
|
||||
if (isError) {
|
||||
return html`<div class="text-sm text-destructive">${output}</div>`;
|
||||
}
|
||||
|
||||
// Try to split on = to show expression and result separately
|
||||
const parts = output.split(" = ");
|
||||
if (parts.length === 2) {
|
||||
return html`
|
||||
<div class="text-sm font-mono">
|
||||
<span class="text-muted-foreground">${parts[0]}</span>
|
||||
<span class="text-muted-foreground mx-1">=</span>
|
||||
<span class="text-foreground font-semibold">${parts[1]}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Fallback to showing the whole output
|
||||
return html`<div class="text-sm font-mono text-foreground">${output}</div>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ToolRenderer } from "../types.js";
|
||||
|
||||
export class DefaultRenderer implements ToolRenderer {
|
||||
renderParams(params: any, isStreaming?: boolean): TemplateResult {
|
||||
let text: string;
|
||||
let isJson = false;
|
||||
|
||||
try {
|
||||
text = JSON.stringify(JSON.parse(params), null, 2);
|
||||
isJson = true;
|
||||
} catch {
|
||||
try {
|
||||
text = JSON.stringify(params, null, 2);
|
||||
isJson = true;
|
||||
} catch {
|
||||
text = String(params);
|
||||
}
|
||||
}
|
||||
|
||||
if (isStreaming && (!text || text === "{}" || text === "null")) {
|
||||
return html`<div class="text-sm text-muted-foreground">${i18n("Preparing tool parameters...")}</div>`;
|
||||
}
|
||||
|
||||
return html`<console-block .content=${text}></console-block>`;
|
||||
}
|
||||
|
||||
renderResult(_params: any, result: ToolResultMessage): TemplateResult {
|
||||
// Just show the output field - that's what was sent to the LLM
|
||||
const text = result.output || i18n("(no output)");
|
||||
|
||||
return html`<div class="text-sm text-muted-foreground whitespace-pre-wrap font-mono">${text}</div>`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { html, type TemplateResult } from "@mariozechner/mini-lit";
|
||||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { i18n } from "../../utils/i18n.js";
|
||||
import type { ToolRenderer } from "../types.js";
|
||||
|
||||
interface GetCurrentTimeParams {
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
// GetCurrentTime tool has undefined details (only uses output)
|
||||
export class GetCurrentTimeRenderer implements ToolRenderer<GetCurrentTimeParams, undefined> {
|
||||
renderParams(params: GetCurrentTimeParams, isStreaming?: boolean): TemplateResult {
|
||||
if (params.timezone) {
|
||||
return html`
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span>${i18n("Getting current time in")}</span>
|
||||
<code class="mx-1 px-1.5 py-0.5 bg-muted rounded text-xs font-mono">${params.timezone}</code>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<span>${i18n("Getting current date and time")}${isStreaming ? "..." : ""}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderResult(_params: GetCurrentTimeParams, result: ToolResultMessage<undefined>): TemplateResult {
|
||||
const output = result.output || "";
|
||||
const isError = result.isError === true;
|
||||
|
||||
if (isError) {
|
||||
return html`<div class="text-sm text-destructive">${output}</div>`;
|
||||
}
|
||||
|
||||
// Display the date/time result
|
||||
return html`<div class="text-sm font-mono text-foreground">${output}</div>`;
|
||||
}
|
||||
}
|
||||
7
packages/browser-extension/src/tools/types.ts
Normal file
7
packages/browser-extension/src/tools/types.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { TemplateResult } from "lit";
|
||||
|
||||
export interface ToolRenderer<TParams = any, TDetails = any> {
|
||||
renderParams(params: TParams, isStreaming?: boolean): TemplateResult;
|
||||
renderResult(params: TParams, result: ToolResultMessage<TDetails>): TemplateResult;
|
||||
}
|
||||
22
packages/browser-extension/src/utils/auth-token.ts
Normal file
22
packages/browser-extension/src/utils/auth-token.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { PromptDialog } from "../dialogs/PromptDialog.js";
|
||||
import { i18n } from "./i18n.js";
|
||||
|
||||
export async function getAuthToken(): Promise<string | undefined> {
|
||||
let authToken: string | undefined = localStorage.getItem(`auth-token`) || "";
|
||||
if (authToken) return authToken;
|
||||
|
||||
while (true) {
|
||||
authToken = (
|
||||
await PromptDialog.ask(i18n("Enter Auth Token"), i18n("Please enter your auth token."), "", true)
|
||||
)?.trim();
|
||||
if (authToken) {
|
||||
localStorage.setItem(`auth-token`, authToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return authToken?.trim() || undefined;
|
||||
}
|
||||
|
||||
export async function clearAuthToken() {
|
||||
localStorage.removeItem(`auth-token`);
|
||||
}
|
||||
|
|
@ -44,6 +44,30 @@ declare module "@mariozechner/mini-lit" {
|
|||
"No content available": string;
|
||||
"Failed to display text content": string;
|
||||
"API keys are required to use AI models. Get your keys from the provider's website.": string;
|
||||
console: string;
|
||||
"Copy output": string;
|
||||
"Copied!": string;
|
||||
"Error:": string;
|
||||
"Request aborted": string;
|
||||
Call: string;
|
||||
Result: string;
|
||||
"(no result)": string;
|
||||
"Waiting for tool result…": string;
|
||||
"Call was aborted; no result.": string;
|
||||
"No session available": string;
|
||||
"No session set": string;
|
||||
"Preparing tool parameters...": string;
|
||||
"(no output)": string;
|
||||
"Writing expression...": string;
|
||||
Calculating: string;
|
||||
"Getting current time in": string;
|
||||
"Getting current date and time": string;
|
||||
"Writing command...": string;
|
||||
"Running command:": string;
|
||||
"Command failed:": string;
|
||||
"Enter Auth Token": string;
|
||||
"Please enter your auth token.": string;
|
||||
"Auth token is required for proxy transport": string;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +118,30 @@ const translations = {
|
|||
"Failed to display text content": "Failed to display text content",
|
||||
"API keys are required to use AI models. Get your keys from the provider's website.":
|
||||
"API keys are required to use AI models. Get your keys from the provider's website.",
|
||||
console: "console",
|
||||
"Copy output": "Copy output",
|
||||
"Copied!": "Copied!",
|
||||
"Error:": "Error:",
|
||||
"Request aborted": "Request aborted",
|
||||
Call: "Call",
|
||||
Result: "Result",
|
||||
"(no result)": "(no result)",
|
||||
"Waiting for tool result…": "Waiting for tool result…",
|
||||
"Call was aborted; no result.": "Call was aborted; no result.",
|
||||
"No session available": "No session available",
|
||||
"No session set": "No session set",
|
||||
"Preparing tool parameters...": "Preparing tool parameters...",
|
||||
"(no output)": "(no output)",
|
||||
"Writing expression...": "Writing expression...",
|
||||
Calculating: "Calculating",
|
||||
"Getting current time in": "Getting current time in",
|
||||
"Getting current date and time": "Getting current date and time",
|
||||
"Writing command...": "Writing command...",
|
||||
"Running command:": "Running command:",
|
||||
"Command failed:": "Command failed:",
|
||||
"Enter Auth Token": "Enter Auth Token",
|
||||
"Please enter your auth token.": "Please enter your auth token.",
|
||||
"Auth token is required for proxy transport": "Auth token is required for proxy transport",
|
||||
},
|
||||
de: {
|
||||
...defaultGerman,
|
||||
|
|
@ -141,6 +189,30 @@ const translations = {
|
|||
"Failed to display text content": "Textinhalt konnte nicht angezeigt werden",
|
||||
"API keys are required to use AI models. Get your keys from the provider's website.":
|
||||
"API-Schlüssel sind erforderlich, um KI-Modelle zu verwenden. Holen Sie sich Ihre Schlüssel von der Website des Anbieters.",
|
||||
console: "Konsole",
|
||||
"Copy output": "Ausgabe kopieren",
|
||||
"Copied!": "Kopiert!",
|
||||
"Error:": "Fehler:",
|
||||
"Request aborted": "Anfrage abgebrochen",
|
||||
Call: "Aufruf",
|
||||
Result: "Ergebnis",
|
||||
"(no result)": "(kein Ergebnis)",
|
||||
"Waiting for tool result…": "Warte auf Tool-Ergebnis…",
|
||||
"Call was aborted; no result.": "Aufruf wurde abgebrochen; kein Ergebnis.",
|
||||
"No session available": "Keine Sitzung verfügbar",
|
||||
"No session set": "Keine Sitzung gesetzt",
|
||||
"Preparing tool parameters...": "Bereite Tool-Parameter vor...",
|
||||
"(no output)": "(keine Ausgabe)",
|
||||
"Writing expression...": "Schreibe Ausdruck...",
|
||||
Calculating: "Berechne",
|
||||
"Getting current time in": "Hole aktuelle Zeit in",
|
||||
"Getting current date and time": "Hole aktuelles Datum und Uhrzeit",
|
||||
"Writing command...": "Schreibe Befehl...",
|
||||
"Running command:": "Führe Befehl aus:",
|
||||
"Command failed:": "Befehl fehlgeschlagen:",
|
||||
"Enter Auth Token": "Auth-Token eingeben",
|
||||
"Please enter your auth token.": "Bitte geben Sie Ihr Auth-Token ein.",
|
||||
"Auth token is required for proxy transport": "Auth-Token ist für Proxy-Transport erforderlich",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue