mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 15:04:52 +00:00
web-ui package
This commit is contained in:
parent
7159c9734e
commit
f2eecb78d2
55 changed files with 10932 additions and 13 deletions
312
packages/web-ui/src/components/AgentInterface.ts
Normal file
312
packages/web-ui/src/components/AgentInterface.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { html } 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 { Attachment } from "../utils/attachment-utils.js";
|
||||
import { formatUsage } from "../utils/format.js";
|
||||
import { i18n } from "../utils/i18n.js";
|
||||
import type { StreamingMessageContainer } from "./StreamingMessageContainer.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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue