mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
810 lines
26 KiB
TypeScript
810 lines
26 KiB
TypeScript
/**
|
|
* Onyx Chat Widget - Main Component
|
|
* Orchestrates launcher/inline modes and manages widget lifecycle
|
|
*/
|
|
|
|
import { LitElement, html, TemplateResult } from "lit";
|
|
import { customElement, property, state } from "lit/decorators.js";
|
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|
import { marked } from "marked";
|
|
import DOMPurify from "dompurify";
|
|
import { WidgetConfig, ChatMessage, TokenProvider } from "./types/widget-types";
|
|
import { SearchDocument, ResolvedCitation } from "./types/api-types";
|
|
import {
|
|
resolveConfig,
|
|
resolveAuthToken,
|
|
deriveCredentialIdentity,
|
|
} from "./config/config";
|
|
import { theme } from "./styles/theme";
|
|
import { widgetStyles } from "./styles/widget-styles";
|
|
import { ApiService } from "./services/api-service";
|
|
import { processPacket } from "./services/stream-parser";
|
|
import { saveSession, loadSession, clearSession } from "./utils/storage";
|
|
import { DEFAULT_LOGO } from "./assets/logo";
|
|
|
|
@customElement("onyx-chat-widget")
|
|
export class OnyxChatWidget extends LitElement {
|
|
static styles = [theme, widgetStyles];
|
|
|
|
// Configuration attributes
|
|
@property({ attribute: "backend-url" }) backendUrl?: string;
|
|
@property({ attribute: "api-key" }) apiKey?: string;
|
|
@property({ attribute: "agent-id", type: Number }) agentId?: number;
|
|
@property({ attribute: "primary-color" }) primaryColor?: string;
|
|
@property({ attribute: "background-color" }) backgroundColor?: string;
|
|
@property({ attribute: "text-color" }) textColor?: string;
|
|
@property({ attribute: "agent-name" }) agentName?: string;
|
|
@property({ attribute: "logo" }) logo?: string;
|
|
// Reflected so `:host([mode="inline"])` also matches when set as a property.
|
|
@property({ reflect: true }) mode?: "launcher" | "inline";
|
|
@property({ attribute: "include-citations", type: Boolean })
|
|
includeCitations?: boolean;
|
|
@property({ attribute: "start-expanded", type: Boolean })
|
|
startExpanded?: boolean;
|
|
|
|
// Assigned as a JS property, since a function cannot ride an HTML attribute.
|
|
// Takes precedence over `api-key` and keeps the credential out of the markup.
|
|
@property({ attribute: false }) tokenProvider?: TokenProvider;
|
|
|
|
// Internal state
|
|
@state() private isOpen = false;
|
|
@state() private chatSessionId?: string;
|
|
@state() private messages: ChatMessage[] = [];
|
|
@state() private isLoading = false;
|
|
@state() private isStreaming = false;
|
|
@state() private streamingStatus = ""; // e.g., "Searching the web...", "Generating response..."
|
|
@state() private error?: string;
|
|
@state() private inputValue = "";
|
|
|
|
private config!: WidgetConfig;
|
|
private apiService!: ApiService;
|
|
private sessionRestored = false;
|
|
private abortController?: AbortController;
|
|
// Citation state — plain fields (not @state) since Map mutations don't trigger Lit re-renders
|
|
private documentMap = new Map<string, SearchDocument>();
|
|
private citationMap = new Map<number, string>();
|
|
|
|
constructor() {
|
|
super();
|
|
// Configure marked options
|
|
marked.setOptions({
|
|
breaks: true, // Convert \n to <br>
|
|
gfm: true, // GitHub Flavored Markdown
|
|
});
|
|
}
|
|
|
|
updated(changedProperties: Map<string, any>) {
|
|
super.updated(changedProperties);
|
|
|
|
// A host that assigns `tokenProvider` after mount only becomes able to
|
|
// identify the stored transcript now.
|
|
if (changedProperties.has("tokenProvider")) {
|
|
this.restoreSession();
|
|
}
|
|
|
|
// Auto-scroll when messages change or streaming status changes
|
|
if (
|
|
changedProperties.has("messages") ||
|
|
changedProperties.has("isStreaming")
|
|
) {
|
|
this.scrollToBottom();
|
|
}
|
|
}
|
|
|
|
private scrollToBottom() {
|
|
// Use requestAnimationFrame to ensure DOM is updated
|
|
requestAnimationFrame(() => {
|
|
const messagesContainer = this.shadowRoot?.querySelector(".messages");
|
|
if (messagesContainer) {
|
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
}
|
|
});
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
// Resolve configuration
|
|
this.config = resolveConfig({
|
|
backendUrl: this.backendUrl,
|
|
apiKey: this.apiKey,
|
|
agentId: this.agentId,
|
|
primaryColor: this.primaryColor,
|
|
backgroundColor: this.backgroundColor,
|
|
textColor: this.textColor,
|
|
agentName: this.agentName,
|
|
logo: this.logo,
|
|
mode: this.mode,
|
|
includeCitations: this.includeCitations,
|
|
startExpanded: this.startExpanded,
|
|
});
|
|
|
|
// Apply custom colors
|
|
this.applyCustomColors();
|
|
|
|
// Initialize API service. Auth is read at request time so a `tokenProvider`
|
|
// assigned after the element mounts is still picked up.
|
|
this.apiService = new ApiService(this.config.backendUrl, () =>
|
|
resolveAuthToken(this.tokenProvider, this.config.apiKey)
|
|
);
|
|
|
|
// Auto-open if inline mode
|
|
if (this.config.mode === "inline") {
|
|
this.isOpen = true;
|
|
}
|
|
|
|
this.restoreSession();
|
|
}
|
|
|
|
/**
|
|
* Restore the persisted transcript, once a credential identifies who it
|
|
* belongs to. This waits rather than restoring at mount because a host
|
|
* normally assigns `tokenProvider` after the element is parsed, and a
|
|
* transcript must never be shown to a different signed-in user.
|
|
*/
|
|
private restoreSession(): void {
|
|
if (this.sessionRestored) return;
|
|
|
|
void (async () => {
|
|
let identity: string;
|
|
try {
|
|
identity = await this.currentIdentity();
|
|
} catch {
|
|
// No credential yet. A later `tokenProvider` assignment retries.
|
|
return;
|
|
}
|
|
|
|
this.sessionRestored = true;
|
|
|
|
const stored = loadSession(identity);
|
|
if (!stored) return;
|
|
this.chatSessionId = stored.sessionId;
|
|
this.messages = stored.messages;
|
|
})();
|
|
}
|
|
|
|
/** Who the current credential represents; scopes the stored transcript. */
|
|
private async currentIdentity(): Promise<string> {
|
|
return deriveCredentialIdentity(
|
|
await resolveAuthToken(this.tokenProvider, this.config.apiKey)
|
|
);
|
|
}
|
|
|
|
private applyCustomColors() {
|
|
// Primary color (buttons, accents)
|
|
if (this.config.primaryColor) {
|
|
this.style.setProperty("--theme-primary-05", this.config.primaryColor);
|
|
this.style.setProperty(
|
|
"--theme-primary-06",
|
|
this.adjustBrightness(this.config.primaryColor, -10)
|
|
);
|
|
}
|
|
|
|
// Background color
|
|
if (this.config.backgroundColor) {
|
|
this.style.setProperty(
|
|
"--background-neutral-00",
|
|
this.config.backgroundColor
|
|
);
|
|
this.style.setProperty(
|
|
"--background-neutral-03",
|
|
this.adjustBrightness(this.config.backgroundColor, -10)
|
|
);
|
|
}
|
|
|
|
// Text color
|
|
if (this.config.textColor) {
|
|
this.style.setProperty("--text-04", this.config.textColor);
|
|
}
|
|
}
|
|
|
|
private adjustBrightness(color: string, percent: number): string {
|
|
const num = parseInt(color.replace("#", ""), 16);
|
|
const amt = Math.round(2.55 * percent);
|
|
const R = (num >> 16) + amt;
|
|
const G = ((num >> 8) & 0x00ff) + amt;
|
|
const B = (num & 0x0000ff) + amt;
|
|
return (
|
|
"#" +
|
|
(
|
|
0x1000000 +
|
|
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
|
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
|
|
(B < 255 ? (B < 1 ? 0 : B) : 255)
|
|
)
|
|
.toString(16)
|
|
.slice(1)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Public API: Reset conversation
|
|
*/
|
|
public resetConversation() {
|
|
// Abort any active streaming request first
|
|
if (this.abortController) {
|
|
this.abortController.abort();
|
|
this.abortController = undefined;
|
|
}
|
|
|
|
this.messages = [];
|
|
this.chatSessionId = undefined;
|
|
this.error = undefined;
|
|
this.inputValue = "";
|
|
this.isStreaming = false;
|
|
this.isLoading = false;
|
|
this.streamingStatus = "";
|
|
this.documentMap.clear();
|
|
this.citationMap.clear();
|
|
clearSession();
|
|
}
|
|
|
|
/**
|
|
* Render markdown content safely.
|
|
* Strips [[n]](url) citation links before markdown parsing so they render
|
|
* as plain [n] text references. Citation badges are rendered separately.
|
|
* Renumbers citations to sequential display numbers (1, 2, 3...).
|
|
*/
|
|
private renderMarkdown(content: string, citations?: ResolvedCitation[]) {
|
|
try {
|
|
let stripped = content;
|
|
if (this.config.includeCitations) {
|
|
if (citations?.length) {
|
|
// Build a map from backend citation number → sequential display number
|
|
const displayMap = new Map<number, number>();
|
|
citations.forEach((c, i) => displayMap.set(c.citation_number, i + 1));
|
|
|
|
// Replace [[n]](url) with superscript-style display number
|
|
stripped = stripped.replace(
|
|
/\[\[(\d+)\]\]\([^)]*\)/g,
|
|
(_match, num) => {
|
|
const displayNum = displayMap.get(Number(num));
|
|
return displayNum ? `<sup>[${displayNum}]</sup>` : "";
|
|
}
|
|
);
|
|
} else {
|
|
// Still streaming or no citations resolved yet — strip raw links
|
|
stripped = stripped.replace(/\[\[(\d+)\]\]\([^)]*\)/g, "");
|
|
}
|
|
}
|
|
const htmlContent = marked.parse(stripped, { async: false }) as string;
|
|
const sanitizedHTML = DOMPurify.sanitize(htmlContent, {
|
|
ADD_TAGS: ["sup"],
|
|
});
|
|
return unsafeHTML(sanitizedHTML);
|
|
} catch (err) {
|
|
console.error("Failed to parse markdown:", err);
|
|
return content; // Fallback to plain text
|
|
}
|
|
}
|
|
|
|
private static readonly CITATIONS_COLLAPSED_COUNT = 1;
|
|
|
|
/**
|
|
* Render a single citation badge.
|
|
*/
|
|
private renderCitationBadge(
|
|
c: ResolvedCitation,
|
|
displayNum: number
|
|
): TemplateResult {
|
|
const title = c.semantic_identifier || "Source";
|
|
const safeHref =
|
|
c.link && /^https?:\/\//i.test(c.link) ? c.link : undefined;
|
|
return safeHref
|
|
? html`<a
|
|
class="citation-badge"
|
|
href=${safeHref}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
title=${title}
|
|
><span class="citation-num">${displayNum}</span
|
|
><span class="citation-title">${title}</span></a
|
|
>`
|
|
: html`<span class="citation-badge" title=${title}
|
|
><span class="citation-num">${displayNum}</span
|
|
><span class="citation-title">${title}</span></span
|
|
>`;
|
|
}
|
|
|
|
/**
|
|
* Toggle expanded state for a citation list.
|
|
*/
|
|
private toggleCitationExpand(e: Event): void {
|
|
const container = (e.target as HTMLElement).closest(".citation-list");
|
|
if (container) {
|
|
container.classList.toggle("expanded");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render citation badges for a message.
|
|
* Shows first 3 inline, collapses the rest behind a "+N more" toggle.
|
|
*/
|
|
private renderCitations(
|
|
citations?: ResolvedCitation[]
|
|
): string | TemplateResult {
|
|
if (!citations?.length) return "";
|
|
const limit = OnyxChatWidget.CITATIONS_COLLAPSED_COUNT;
|
|
const visible = citations.slice(0, limit);
|
|
const overflow = citations.slice(limit);
|
|
|
|
return html`
|
|
<div class="citation-list">
|
|
${visible.map((c, i) => this.renderCitationBadge(c, i + 1))}
|
|
${overflow.length > 0
|
|
? html`
|
|
<button class="citation-more" @click=${this.toggleCitationExpand}>
|
|
+${overflow.length} more
|
|
</button>
|
|
<div class="citation-overflow">
|
|
${overflow.map((c, i) =>
|
|
this.renderCitationBadge(c, limit + i + 1)
|
|
)}
|
|
</div>
|
|
`
|
|
: ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private toggleOpen() {
|
|
this.isOpen = !this.isOpen;
|
|
}
|
|
|
|
private close() {
|
|
if (this.config.mode === "launcher") {
|
|
this.isOpen = false;
|
|
}
|
|
}
|
|
|
|
private handleInput(e: InputEvent) {
|
|
this.inputValue = (e.target as HTMLInputElement).value;
|
|
}
|
|
|
|
private handleKeyDown(e: KeyboardEvent) {
|
|
// Shadow DOM retargets `e.target` to the host element, so page-level
|
|
// handlers that let keys through only for text fields (e.g. ones that
|
|
// block Backspace navigation) would cancel typing here. Escape still
|
|
// propagates so the host page can close its overlays.
|
|
if (e.key !== "Escape") {
|
|
e.stopPropagation();
|
|
}
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
this.sendMessage();
|
|
}
|
|
}
|
|
|
|
private async sendMessage() {
|
|
const message = this.inputValue.trim();
|
|
if (!message || this.isLoading || this.isStreaming) return;
|
|
|
|
// Clear input immediately
|
|
this.inputValue = "";
|
|
|
|
// Add user message
|
|
const userMessage: ChatMessage = {
|
|
id: `msg-${Date.now()}`,
|
|
role: "user",
|
|
content: message,
|
|
timestamp: Date.now(),
|
|
};
|
|
this.messages = [...this.messages, userMessage];
|
|
|
|
try {
|
|
this.isStreaming = true;
|
|
this.error = undefined;
|
|
|
|
// Create session if needed
|
|
if (!this.chatSessionId) {
|
|
this.isLoading = true;
|
|
this.chatSessionId = await this.apiService.createChatSession(
|
|
this.config.agentId
|
|
);
|
|
this.isLoading = false;
|
|
}
|
|
|
|
// Resolved once per send, so every persisted transcript is tagged with
|
|
// the user who actually produced it.
|
|
const identity = await this.currentIdentity();
|
|
|
|
// Get parent message ID (last assistant message with a numeric ID from backend)
|
|
const parentMessage = [...this.messages]
|
|
.reverse()
|
|
.find((m) => m.role === "assistant" && typeof m.id === "number");
|
|
const parentMessageId =
|
|
parentMessage && typeof parentMessage.id === "number"
|
|
? parentMessage.id
|
|
: null;
|
|
|
|
// Stream response
|
|
this.abortController = new AbortController();
|
|
let currentMessage: ChatMessage | null = null;
|
|
let assistantMessageId: number | null = null;
|
|
|
|
for await (const packet of this.apiService.streamMessage({
|
|
message,
|
|
chatSessionId: this.chatSessionId,
|
|
parentMessageId,
|
|
signal: this.abortController.signal,
|
|
includeCitations: this.config.includeCitations,
|
|
})) {
|
|
const result = processPacket(packet, currentMessage);
|
|
|
|
// Capture message IDs from backend and update local messages
|
|
if (result.messageIds) {
|
|
// Update user message ID if we got one
|
|
if (result.messageIds.userMessageId !== null) {
|
|
const userMsgIndex = this.messages.findIndex(
|
|
(m) => m.id === userMessage.id
|
|
);
|
|
if (userMsgIndex >= 0) {
|
|
// Create new array to trigger reactivity
|
|
const updatedMessage = {
|
|
...this.messages[userMsgIndex],
|
|
id: result.messageIds.userMessageId,
|
|
};
|
|
this.messages = [
|
|
...this.messages.slice(0, userMsgIndex),
|
|
updatedMessage,
|
|
...this.messages.slice(userMsgIndex + 1),
|
|
];
|
|
}
|
|
}
|
|
// Store assistant message ID to apply when message is created
|
|
assistantMessageId = result.messageIds.assistantMessageId;
|
|
}
|
|
|
|
// Update status if provided
|
|
if (result.status !== undefined) {
|
|
this.streamingStatus = result.status;
|
|
}
|
|
|
|
// Accumulate document metadata for citation resolution
|
|
if (result.documents) {
|
|
for (const doc of result.documents) {
|
|
this.documentMap.set(doc.document_id, doc);
|
|
}
|
|
}
|
|
|
|
// Accumulate citation mappings for the current message
|
|
if (result.citation) {
|
|
this.citationMap.set(
|
|
result.citation.citation_number,
|
|
result.citation.document_id
|
|
);
|
|
}
|
|
|
|
if (result.message) {
|
|
// Reset per-message citation state when a new message starts
|
|
if (
|
|
result.message.isStreaming &&
|
|
result.message.content === "" &&
|
|
currentMessage === null
|
|
) {
|
|
this.citationMap.clear();
|
|
}
|
|
|
|
currentMessage = result.message;
|
|
|
|
// Apply the backend message ID if we have it and message doesn't have a numeric ID yet
|
|
if (
|
|
assistantMessageId !== null &&
|
|
typeof currentMessage.id !== "number"
|
|
) {
|
|
currentMessage.id = assistantMessageId;
|
|
}
|
|
|
|
// When message is complete, resolve citations and attach to message
|
|
if (!currentMessage.isStreaming && this.citationMap.size > 0) {
|
|
const resolved: ResolvedCitation[] = [];
|
|
for (const [citNum, docId] of this.citationMap) {
|
|
const doc = this.documentMap.get(docId);
|
|
resolved.push({
|
|
citation_number: citNum,
|
|
document_id: docId,
|
|
semantic_identifier: doc?.semantic_identifier,
|
|
link: doc?.link ?? undefined,
|
|
});
|
|
}
|
|
resolved.sort((a, b) => a.citation_number - b.citation_number);
|
|
currentMessage = { ...currentMessage, citations: resolved };
|
|
}
|
|
|
|
// Update or add message
|
|
const existingIndex = this.messages.findIndex(
|
|
(m) => m.id === currentMessage?.id
|
|
);
|
|
if (existingIndex >= 0) {
|
|
this.messages = [
|
|
...this.messages.slice(0, existingIndex),
|
|
currentMessage,
|
|
...this.messages.slice(existingIndex + 1),
|
|
];
|
|
} else {
|
|
this.messages = [...this.messages, currentMessage];
|
|
}
|
|
|
|
// Clear streaming state and persist when message is complete
|
|
if (!currentMessage.isStreaming) {
|
|
this.isStreaming = false;
|
|
this.streamingStatus = "";
|
|
saveSession(this.chatSessionId, this.messages, identity);
|
|
}
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
console.error("Failed to send message:", err);
|
|
if (err.name !== "AbortError") {
|
|
this.error = err.message || "Failed to send message";
|
|
}
|
|
} finally {
|
|
this.isStreaming = false;
|
|
this.isLoading = false;
|
|
this.streamingStatus = "";
|
|
this.abortController = undefined;
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const showContainer = this.config.mode === "inline" || this.isOpen;
|
|
const hasMessages = this.messages.length > 0 || this.isStreaming;
|
|
const isCompactInline =
|
|
this.config.mode === "inline" &&
|
|
!this.config.startExpanded &&
|
|
!hasMessages;
|
|
|
|
return html`
|
|
${this.config.mode === "launcher"
|
|
? html`
|
|
<button
|
|
class="launcher"
|
|
@click=${this.toggleOpen}
|
|
title="Open chat"
|
|
>
|
|
<img
|
|
src="${this.config.logo || DEFAULT_LOGO}"
|
|
alt="Logo"
|
|
style="width: 32px; height: 32px; object-fit: contain;"
|
|
/>
|
|
</button>
|
|
`
|
|
: ""}
|
|
${showContainer
|
|
? html`
|
|
<div
|
|
class="container ${this.config.mode === "inline"
|
|
? "inline"
|
|
: ""} ${isCompactInline ? "compact" : ""}"
|
|
>
|
|
${isCompactInline
|
|
? this.renderCompactInput()
|
|
: html`
|
|
${this.renderHeader()} ${this.renderMessages()}
|
|
${this.renderInput()}
|
|
`}
|
|
</div>
|
|
`
|
|
: ""}
|
|
`;
|
|
}
|
|
|
|
private renderHeader() {
|
|
return html`
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<div class="avatar">
|
|
<img
|
|
src="${this.config.logo || DEFAULT_LOGO}"
|
|
alt="Logo"
|
|
style="width: 100%; height: 100%; object-fit: contain;"
|
|
/>
|
|
</div>
|
|
<div class="header-title">
|
|
${this.config.agentName || "Assistant"}
|
|
</div>
|
|
</div>
|
|
<div class="header-right">
|
|
<button
|
|
class="icon-button"
|
|
@click=${this.resetConversation}
|
|
title="Reset conversation"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
d="M14.448 3.10983V6.77746M14.448 6.77746H10.7803M14.448 6.77746L11.6117 4.11231C10.9547 3.45502 10.142 2.97486 9.24923 2.71664C8.35651 2.45842 7.41292 2.43055 6.50651 2.63564C5.6001 2.84072 4.76042 3.27208 4.06581 3.88945C3.3712 4.50683 2.84431 5.2901 2.53429 6.16618M1 12.8902V9.22254M1 9.22254H4.66763M1 9.22254L3.8363 11.8877C4.49326 12.545 5.30603 13.0251 6.19875 13.2834C7.09147 13.5416 8.03506 13.5694 8.94147 13.3644C9.84787 13.1593 10.6876 12.7279 11.3822 12.1105C12.0768 11.4932 12.6037 10.7099 12.9137 9.83381"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
${this.config.mode === "launcher"
|
|
? html`
|
|
<button class="icon-button" @click=${this.close} title="Close">
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 28 28"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
d="M21 7L7 21M7 7L21 21"
|
|
stroke-width="2"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
`
|
|
: ""}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderMessages() {
|
|
// Check if there's a streaming message with content
|
|
const hasStreamingContent = this.messages.some(
|
|
(m) => m.role === "assistant" && m.isStreaming && m.content.length > 0
|
|
);
|
|
// Show ellipsis only when: streaming AND (has status text OR no content yet)
|
|
const showEllipsis =
|
|
this.isStreaming && (this.streamingStatus || !hasStreamingContent);
|
|
|
|
return html`
|
|
<div class="disclaimer">
|
|
Responses are generated by AI and may be inaccurate
|
|
</div>
|
|
<div class="messages">
|
|
${this.error ? html` <div class="error">${this.error}</div> ` : ""}
|
|
${this.messages.map(
|
|
(msg) => html`
|
|
<div class="message ${msg.role}">
|
|
<div class="message-bubble">
|
|
${msg.role === "assistant"
|
|
? html`${this.renderMarkdown(
|
|
msg.content,
|
|
msg.citations
|
|
)}${this.renderCitations(msg.citations)}`
|
|
: msg.content}
|
|
</div>
|
|
</div>
|
|
`
|
|
)}
|
|
${showEllipsis
|
|
? html`
|
|
<div class="message assistant">
|
|
<div class="message-bubble">
|
|
<div class="status-container">
|
|
<div class="typing-indicator">
|
|
<div class="typing-dot"></div>
|
|
<div class="typing-dot"></div>
|
|
<div class="typing-dot"></div>
|
|
</div>
|
|
${this.streamingStatus
|
|
? html`
|
|
<span class="status-text"
|
|
>${this.streamingStatus}</span
|
|
>
|
|
`
|
|
: ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderInput() {
|
|
return html`
|
|
<div class="input-wrapper">
|
|
<div class="input-container">
|
|
<input
|
|
class="input"
|
|
type="text"
|
|
.value=${this.inputValue}
|
|
@input=${this.handleInput}
|
|
@keydown=${this.handleKeyDown}
|
|
placeholder="Type your message..."
|
|
?disabled=${this.isLoading || this.isStreaming}
|
|
/>
|
|
<button
|
|
class="send-button"
|
|
@click=${this.sendMessage}
|
|
?disabled=${!this.inputValue.trim() ||
|
|
this.isLoading ||
|
|
this.isStreaming}
|
|
title="Send message"
|
|
>
|
|
<svg
|
|
width="20"
|
|
height="20"
|
|
viewBox="0 0 18 18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
d="M8 2.6665V13.3335M8 2.6665L4 6.6665M8 2.6665L12 6.6665"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="powered-by">
|
|
Powered by
|
|
<a
|
|
href="https://onyx.app"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style="text-decoration: underline;"
|
|
>Onyx</a
|
|
>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderCompactInput() {
|
|
return html`
|
|
<div class="compact-input-container">
|
|
<div class="compact-avatar">
|
|
<img
|
|
src="${this.config.logo || DEFAULT_LOGO}"
|
|
alt="Logo"
|
|
style="width: 100%; height: 100%; object-fit: contain;"
|
|
/>
|
|
</div>
|
|
<input
|
|
class="compact-input"
|
|
type="text"
|
|
.value=${this.inputValue}
|
|
@input=${this.handleInput}
|
|
@keydown=${this.handleKeyDown}
|
|
placeholder="Ask ${this.config.agentName || "Assistant"} anything..."
|
|
?disabled=${this.isLoading || this.isStreaming}
|
|
/>
|
|
<button
|
|
class="send-button"
|
|
@click=${this.sendMessage}
|
|
?disabled=${!this.inputValue.trim() ||
|
|
this.isLoading ||
|
|
this.isStreaming}
|
|
title="Send message"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 18 18"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
d="M8 2.6665V13.3335M8 2.6665L4 6.6665M8 2.6665L12 6.6665"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"onyx-chat-widget": OnyxChatWidget;
|
|
}
|
|
}
|