diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni index 6d90ed50b2..b8ac0113bd 100644 --- a/config/gni/devtools_grd_files.gni +++ b/config/gni/devtools_grd_files.gni @@ -64,6 +64,7 @@ grd_files_bundled_sources = [ "front_end/Images/breakpoint-circle.svg", "front_end/Images/breakpoint-crossed-filled.svg", "front_end/Images/breakpoint-crossed.svg", + "front_end/Images/browser-operator-logo.svg", "front_end/Images/browser-operator-logo.png", "front_end/Images/brush-2.svg", "front_end/Images/brush-filled.svg", @@ -646,6 +647,7 @@ grd_files_bundled_sources = [ "front_end/panels/ai_assistance/ai_assistance.js", "front_end/panels/ai_chat/ui/AIChatPanel.js", "front_end/panels/ai_chat/ui/ChatView.js", + "front_end/panels/ai_chat/ui/ConnectorsView.js", "front_end/panels/ai_chat/ui/LiveAgentSessionComponent.js", "front_end/panels/ai_chat/ui/ToolCallComponent.js", "front_end/panels/ai_chat/ui/ToolResultComponent.js", @@ -867,7 +869,8 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/agent_framework/implementation/agents/ScrollActionAgent.js", "front_end/panels/ai_chat/agent_framework/implementation/agents/WebTaskAgent.js", "front_end/panels/ai_chat/agent_framework/implementation/agents/SearchAgent.js", - "front_end/panels/ai_chat/agent_framework/implementation/agents/ActionAgentV0.js", + "front_end/panels/ai_chat/agent_framework/implementation/agents/ActionAgent.js", + "front_end/panels/ai_chat/agent_framework/implementation/agents/ActionAgentV1.js", "front_end/panels/ai_chat/agent_framework/implementation/agents/ActionAgentV2.js", "front_end/panels/ai_chat/common/MarkdownViewerUtil.js", "front_end/panels/ai_chat/evaluation/runner/VisionAgentEvaluationRunner.js", diff --git a/config/gni/devtools_image_files.gni b/config/gni/devtools_image_files.gni index 3b6d896ee6..c6ac21c8cd 100644 --- a/config/gni/devtools_image_files.gni +++ b/config/gni/devtools_image_files.gni @@ -20,12 +20,13 @@ devtools_image_files = [ "touchCursor.png", "gdp-logo-light.png", "gdp-logo-dark.png", - "browser-operator-logo.png", "demo.gif", + "browser-operator-logo.png", ] devtools_svg_sources = [ "3d-center.svg", + "browser-operator-logo.svg", "3d-pan.svg", "3d-rotate.svg", "accelerometer-back.svg", diff --git a/front_end/Images/browser-operator-logo.png b/front_end/Images/browser-operator-logo.png index a97b47b597..1c692214fb 100644 Binary files a/front_end/Images/browser-operator-logo.png and b/front_end/Images/browser-operator-logo.png differ diff --git a/front_end/Images/src/browser-operator-logo.svg b/front_end/Images/src/browser-operator-logo.svg new file mode 100644 index 0000000000..30bfe4be3f --- /dev/null +++ b/front_end/Images/src/browser-operator-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/front_end/entrypoints/main/main-meta.ts b/front_end/entrypoints/main/main-meta.ts index a5de738e73..33f9fc460d 100644 --- a/front_end/entrypoints/main/main-meta.ts +++ b/front_end/entrypoints/main/main-meta.ts @@ -727,7 +727,7 @@ Common.Settings.registerSettingExtension({ category: Common.Settings.SettingCategory.GLOBAL, settingName: 'currentDockState', settingType: Common.Settings.SettingType.ENUM, - defaultValue: 'right', + defaultValue: 'left', options: [ { value: 'right', diff --git a/front_end/panels/ai_chat/BUILD.gn b/front_end/panels/ai_chat/BUILD.gn index 2b994b4212..6bbc943f96 100644 --- a/front_end/panels/ai_chat/BUILD.gn +++ b/front_end/panels/ai_chat/BUILD.gn @@ -22,6 +22,9 @@ devtools_module("ai_chat") { "core/AgentTestRunner.ts", "ui/AIChatPanel.ts", "ui/ChatView.ts", + "ui/ConnectorsView.ts", + "ui/HistoryView.ts", + "ui/EvaluationsView.ts", "ui/message/MessageList.ts", "ui/message/UserMessage.ts", "ui/message/ModelMessage.ts", @@ -34,6 +37,13 @@ devtools_module("ai_chat") { "ui/input/InputBar.ts", "ui/oauth/OAuthConnectPanel.ts", "ui/version/VersionBanner.ts", + "ui/SidebarNav.ts", + "ui/ConnectorsDropdown.ts", + "ui/AgentDropdown.ts", + "ui/common/Switch.ts", + "ui/common/Button.ts", + "ui/common/SearchInput.ts", + "ui/common/Dropdown.ts", "ui/LiveAgentSessionComponent.ts", "ui/ToolCallComponent.ts", "ui/ToolResultComponent.ts", @@ -306,6 +316,9 @@ _ai_chat_sources = [ "core/AgentTestRunner.ts", "ui/AIChatPanel.ts", "ui/ChatView.ts", + "ui/ConnectorsView.ts", + "ui/HistoryView.ts", + "ui/EvaluationsView.ts", "ui/message/MessageList.ts", "ui/message/UserMessage.ts", "ui/message/ModelMessage.ts", @@ -318,6 +331,13 @@ _ai_chat_sources = [ "ui/input/InputBar.ts", "ui/oauth/OAuthConnectPanel.ts", "ui/version/VersionBanner.ts", + "ui/SidebarNav.ts", + "ui/ConnectorsDropdown.ts", + "ui/AgentDropdown.ts", + "ui/common/Switch.ts", + "ui/common/Button.ts", + "ui/common/SearchInput.ts", + "ui/common/Dropdown.ts", "ui/LiveAgentSessionComponent.ts", "ui/ToolCallComponent.ts", "ui/ToolResultComponent.ts", diff --git a/front_end/panels/ai_chat/core/Version.ts b/front_end/panels/ai_chat/core/Version.ts index 90df2138ad..b53777d346 100644 --- a/front_end/panels/ai_chat/core/Version.ts +++ b/front_end/panels/ai_chat/core/Version.ts @@ -9,3 +9,4 @@ export const VERSION_INFO = { } as const; export const CURRENT_VERSION = VERSION_INFO.version; +export const RELEASE_URL = `https://github.com/tysonthomas9/browser-operator-devtools-frontend/releases/tag/v${CURRENT_VERSION}`; diff --git a/front_end/panels/ai_chat/ui/AIChatPanel.ts b/front_end/panels/ai_chat/ui/AIChatPanel.ts index 644bc2679a..6392d00724 100644 --- a/front_end/panels/ai_chat/ui/AIChatPanel.ts +++ b/front_end/panels/ai_chat/ui/AIChatPanel.ts @@ -25,6 +25,7 @@ import { isEvaluationEnabled, getEvaluationConfig } from '../common/EvaluationCo import { EvaluationAgent } from '../evaluation/remote/EvaluationAgent.js'; import { BUILD_CONFIG } from '../core/BuildConfig.js'; import { OnboardingDialog, createSetupRequiredBanner } from './OnboardingDialog.js'; +import { HistoryViewEvents } from './HistoryView.js'; // Import of LiveAgentSessionComponent is not required here; the element is // registered by ChatView where it is used. @@ -84,6 +85,7 @@ localStorage.removeItem = (key: string) => { import chatViewStyles from './chatView.css.js'; import { ChatView } from './ChatView.js'; +import type { SidebarNavItem } from './SidebarNav.js'; import { type ChatMessage, ChatMessageEntity, type ImageInputData, type ModelChatMessage, State as ChatViewState } from '../models/ChatTypes.js'; import { HelpDialog } from './HelpDialog.js'; import { SettingsDialog } from './SettingsDialog.js'; @@ -167,7 +169,7 @@ const UIStrings = { /** * @description Default text shown in the chat input */ - inputPlaceholder: 'Ask a question...', + inputPlaceholder: 'Ask me anything...', /** * @description Placeholder when OpenAI API key is missing */ @@ -509,7 +511,7 @@ export class AIChatPanel extends UI.Panel.Panel { this.#toolbarContainer = document.createElement('div'); this.#toolbarContainer.classList.add('toolbar-container'); this.#toolbarContainer.setAttribute('role', 'toolbar'); - this.#toolbarContainer.style.cssText = 'display: flex; justify-content: space-between; width: 100%; padding: 0 4px; box-sizing: border-box; margin: 0 0 10px 0;'; + this.#toolbarContainer.style.cssText = 'display: flex; justify-content: space-between; width: calc(100% - 36px); margin-left: 36px; padding: 0 4px; box-sizing: border-box; margin-top: 0; margin-bottom: 0; background: white;'; this.contentElement.appendChild(this.#toolbarContainer); // Create left toolbar using DOM method (not constructor) @@ -519,6 +521,9 @@ export class AIChatPanel extends UI.Panel.Panel { this.#rightToolbar = this.#toolbarContainer.createChild('devtools-toolbar', 'ai-chat-right-toolbar') as UI.Toolbar.Toolbar; this.#rightToolbar.style.cssText = 'overflow: visible;'; + // Note: "What's new" pill is now rendered inside ChatView's centered content (per Figma design) + // Removed duplicate toolbar pill to match Figma specifications + // Create toolbar buttons ONCE this.#newChatButton = new UI.Toolbar.ToolbarButton( i18nString(UIStrings.newChat), @@ -544,8 +549,6 @@ export class AIChatPanel extends UI.Panel.Panel { this ); - this.#settingsMenuButton = this.#createSettingsMenuButton(); - this.#closeButton = new UI.Toolbar.ToolbarButton( 'Close Chat Window', 'cross', @@ -560,7 +563,6 @@ export class AIChatPanel extends UI.Panel.Panel { // Add buttons to toolbars ONCE (order matters for right toolbar) this.#leftToolbar.appendToolbarItem(this.#newChatButton); - this.#rightToolbar.appendToolbarItem(this.#settingsMenuButton); this.#rightToolbar.appendToolbarItem(this.#closeButton); // Create container for the chat view @@ -577,6 +579,13 @@ export class AIChatPanel extends UI.Panel.Panel { // Add event listener for manual setup requests from ChatView this.#chatView.addEventListener('manual-setup-requested', this.#handleManualSetupRequest.bind(this)); + // Wire sidebar navigation actions + this.#chatView.addEventListener('sidebar-nav', this.#handleSidebarNavEvent.bind(this)); + + // Wire HistoryView events + this.#chatView.addEventListener(HistoryViewEvents.REQUEST_DATA, this.#handleHistoryRequestData.bind(this)); + this.#chatView.addEventListener(HistoryViewEvents.LOAD_CONVERSATION, this.#handleHistoryLoadConversation.bind(this)); + this.#chatView.addEventListener(HistoryViewEvents.DELETE_CONVERSATION, this.#handleHistoryDeleteConversation.bind(this)); } /** @@ -1210,6 +1219,37 @@ export class AIChatPanel extends UI.Panel.Panel { this.#onSettingsClick(); } + #handleSidebarNavEvent(event: Event): void { + const detail = (event as CustomEvent<{item: SidebarNavItem}>).detail; + if (!detail?.item) { + return; + } + this.#handleSidebarNavigation(detail.item); + } + + #handleSidebarNavigation(item: SidebarNavItem): void { + // Only show new chat button in chat mode + const showNewChatButton = item === 'chat'; + this.#newChatButton.setVisible(showNewChatButton); + + switch (item) { + case 'help': + this.#onHelpClick(); + break; + case 'agents': + this.#onAgentStudioClick(); + break; + case 'chat': + case 'connectors': // Let ChatView handle routing to ConnectorsView + case 'settings': // Let ChatView handle routing to SettingsDialog + case 'history': // Let ChatView handle routing to HistoryView + case 'evaluations': // Let ChatView handle routing to EvaluationsView + default: + // ChatView will handle these via its internal routing + break; + } + } + /** * Update the settings button highlight based on credentials state */ @@ -1731,6 +1771,29 @@ export class AIChatPanel extends UI.Panel.Panel { onOAuthLogin: this.#handleOAuthLogin.bind(this), // Add example prompt model switching onExamplePromptModelSwitch: this.#handleExamplePromptModelSwitch.bind(this), + // Settings callbacks for inline SettingsDialog + onSettingsSaved: this.refreshCredentials.bind(this), + fetchLiteLLMModels: this.#fetchLiteLLMModels.bind(this), + updateModelOptions: (models: Array<{value: string, label: string}>, hadWildcard: boolean) => { + // Convert to ModelOption format if needed + const modelOptions: ModelOption[] = models.map(m => ({ + value: m.value, + label: m.label, + type: (localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai') as ProviderType + })); + AIChatPanel.updateModelOptions(modelOptions, hadWildcard); + this.performUpdate(); + }, + getModelOptions: () => getModelOptions(), + addCustomModelOption: (model: {value: string, label: string}) => { + const currentProvider = (localStorage.getItem(PROVIDER_SELECTION_KEY) || 'openai') as ProviderType; + AIChatPanel.addCustomModelOption(model.value, currentProvider); + this.performUpdate(); + }, + removeCustomModelOption: (modelValue: string) => { + AIChatPanel.removeCustomModelOption(modelValue); + this.performUpdate(); + }, }; } catch (error) { logger.error('Error updating ChatView state:', error); @@ -1941,6 +2004,45 @@ export class AIChatPanel extends UI.Panel.Panel { logger.info('Conversation history dialog opened'); } + /** + * Handle request for history data from HistoryView + */ + async #handleHistoryRequestData(): Promise { + const conversations = await this.#agentService.listConversations(); + const currentId = this.#agentService.getCurrentConversationId(); + + // Find the history view and set its data + const historyView = this.#chatView.querySelector('ai-history-view') as any; + if (historyView) { + historyView.conversations = conversations; + historyView.currentConversationId = currentId; + } + } + + /** + * Handle loading a conversation from HistoryView + */ + #handleHistoryLoadConversation(event: Event): void { + const customEvent = event as CustomEvent<{conversationId: string}>; + const conversationId = customEvent.detail?.conversationId; + if (conversationId) { + this.#loadConversation(conversationId); + } + } + + /** + * Handle deleting a conversation from HistoryView + */ + #handleHistoryDeleteConversation(event: Event): void { + const customEvent = event as CustomEvent<{conversationId: string}>; + const conversationId = customEvent.detail?.conversationId; + if (conversationId) { + this.#deleteConversation(conversationId); + // Refresh the history view data after deletion + void this.#handleHistoryRequestData(); + } + } + #onHelpClick(): void { // Open external getting started docs in a new tab UI.UIUtils.openInNewTab('https://browseroperator.io/docs/getting-started/'); @@ -1971,22 +2073,12 @@ export class AIChatPanel extends UI.Panel.Panel { } /** - * Handles the settings button click event and shows the settings dialog + * Handles the settings button click event - navigates to settings view */ #onSettingsClick(): void { - SettingsDialog.show( - this.#selectedModel, - this.#miniModel, - this.#nanoModel, - async () => { - await this.#handleSettingsChanged(); - }, - this.#fetchLiteLLMModels.bind(this), - (providerModels, hadWildcard) => { AIChatPanel.updateModelOptions(providerModels, hadWildcard); }, - AIChatPanel.getModelOptions, - AIChatPanel.addCustomModelOption, - AIChatPanel.removeCustomModelOption - ); + // Navigate to settings view + this.#handleSidebarNavigation('settings'); + this.#chatView.setActiveSidebarItem('settings'); } /** diff --git a/front_end/panels/ai_chat/ui/AgentDropdown.ts b/front_end/panels/ai_chat/ui/AgentDropdown.ts new file mode 100644 index 0000000000..58e8d3b42c --- /dev/null +++ b/front_end/panels/ai_chat/ui/AgentDropdown.ts @@ -0,0 +1,394 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Lit from '../../../ui/lit/lit.js'; + +const {html, Decorators} = Lit; +const {customElement} = Decorators as any; + +export interface Agent { + id: string; + name: string; + icon: string; // SVG path or emoji + description?: string; +} + +export interface AgentDropdownProps { + visible: boolean; + agents: Agent[]; + selectedAgentId?: string; + onSelect: (agent: Agent) => void; + onAddAgent?: () => void; + onClose: () => void; + position?: {left: number; top: number}; +} + +@customElement('ai-agent-dropdown') +export class AgentDropdown extends HTMLElement { + static readonly litTagName = Lit.StaticHtml.literal`ai-agent-dropdown`; + + #visible = false; + #agents: Agent[] = [ + { id: 'sales', name: 'Sales Agent', icon: 'arrow-right' }, + { id: 'scraper', name: 'Scraper Agent', icon: 'network' }, + { id: 'assistant', name: 'Assistant Agent', icon: 'check-square' }, + ]; + #addAgentIcon = 'smile'; + #selectedAgentId?: string; + #onSelect?: (agent: Agent) => void; + #onAddAgent?: () => void; + #onClose?: () => void; + #position?: {left: number; top: number}; + #searchQuery = ''; + + set visible(v: boolean) { this.#visible = v; this.#render(); } + set agents(v: Agent[]) { this.#agents = v; this.#render(); } + set selectedAgentId(v: string | undefined) { this.#selectedAgentId = v; this.#render(); } + set onSelect(fn: (agent: Agent) => void) { this.#onSelect = fn; } + set onAddAgent(fn: () => void) { this.#onAddAgent = fn; } + set onClose(fn: () => void) { this.#onClose = fn; } + set position(v: {left: number; top: number} | undefined) { this.#position = v; this.#render(); } + + connectedCallback(): void { + this.#render(); + } + + #handleSelect(agent: Agent): void { + if (this.#onSelect) { + this.#onSelect(agent); + } + if (this.#onClose) { + this.#onClose(); + } + } + + #handleAddAgent(): void { + if (this.#onAddAgent) { + this.#onAddAgent(); + } + if (this.#onClose) { + this.#onClose(); + } + } + + #handleSearchInput(e: Event): void { + this.#searchQuery = (e.target as HTMLInputElement).value; + this.#render(); + } + + #getFilteredAgents(): Agent[] { + if (!this.#searchQuery.trim()) { + return this.#agents; + } + const query = this.#searchQuery.toLowerCase(); + return this.#agents.filter(agent => + agent.name.toLowerCase().includes(query) + ); + } + + #renderIcon(iconType: string): Lit.TemplateResult { + // Icons matching lucide-react style (10px for compact dropdown) + switch (iconType) { + case 'arrow-right': + // Lucide ArrowRight icon + return html` + + + + + `; + case 'network': + // Lucide Network icon + return html` + + + + + + + + `; + case 'check-square': + // Lucide CheckSquare icon + return html` + + + + + `; + case 'smile': + // Lucide Smile icon + return html` + + + + + + + `; + case 'plus': + // Lucide Plus icon + return html` + + + + + `; + default: + // If it's an emoji or unknown, just render it as text + return html`${iconType}`; + } + } + + #render(): void { + // Keep host footprint zero; render dropdown as a fixed overlay tied to the trigger. + if (!this.#visible) { + Lit.render(html``, this, {host: this}); + return; + } + + const fallbackPosition = (() => { + const root = this.getRootNode() as Document | ShadowRoot; + const trigger = root.querySelector?.('#agent-dropdown-button') as HTMLElement | null; + if (!trigger) { + return undefined; + } + const rect = trigger.getBoundingClientRect(); + return { + left: rect.left + rect.width / 2 + window.scrollX, + top: rect.top + window.scrollY, + }; + })(); + + const effectivePosition = this.#position ?? fallbackPosition; + const surfaceStyle = effectivePosition ? + `left:${effectivePosition.left}px; top:${effectivePosition.top}px;` : ''; + + Lit.render(html` + + + + `, this, {host: this}); + } +} + +declare global { + interface HTMLElementTagNameMap { 'ai-agent-dropdown': AgentDropdown; } +} diff --git a/front_end/panels/ai_chat/ui/AgentSessionHeaderComponent.ts b/front_end/panels/ai_chat/ui/AgentSessionHeaderComponent.ts index a07150ddbb..f3cdd4b83a 100644 --- a/front_end/panels/ai_chat/ui/AgentSessionHeaderComponent.ts +++ b/front_end/panels/ai_chat/ui/AgentSessionHeaderComponent.ts @@ -14,7 +14,9 @@ export type SessionStatus = 'running' | 'completed' | 'error'; @customElement('agent-session-header') export class AgentSessionHeaderComponent extends HTMLElement { static readonly litTagName = Lit.StaticHtml.literal`agent-session-header`; - private readonly shadow = this.attachShadow({mode: 'open'}); + // Use Light DOM + // private readonly shadow = this.attachShadow({mode: 'open'}); + private readonly shadow = this; private session: AgentSession | null = null; private isExpanded = true; @@ -80,7 +82,7 @@ export class AgentSessionHeaderComponent extends HTMLElement { Lit.render(html` `; + // Build a set of nested child session IDs present in the current message set. // Include both nestedSessions[].sessionId and any handoff anchors in messages that // have a concrete nestedSessionId (ignore pending-* placeholders). Also build @@ -832,75 +905,151 @@ export class ChatView extends HTMLElement { // Determine which view to render based on the first message state if (this.#isFirstMessageView) { - // Render centered first message view - const welcomeMessage = this.#messages.length > 0 ? this.#messages[0] : null; + // Render first message view with routing support + const renderFirstMessageContent = () => { + switch (this.#activeSidebarItem) { + case 'connectors': + return html``; + case 'settings': + return html``; + case 'history': + return html``; + case 'evaluations': + return html``; + case 'chat': + default: + // Render centered welcome UI + const suggestions = this.#renderExampleSuggestions(); + return html` + ${this.#renderVersionBanner()} + + + + What's new in v${CURRENT_VERSION} + +
+
+
+ Browser Operator logo +
+
How can I help you today?
+
+ + ${this.#showOAuthLogin ? html` + + ` : this.#renderInputBar(true)} + + ${suggestions} +
+ `; + } + }; - const suggestions = this.#renderExampleSuggestions(); Lit.render(html` + ${stylesTemplate}
- ${this.#renderVersionBanner()} -
- ${welcomeMessage ? this.#renderMessage(welcomeMessage, 0) : Lit.nothing} - - ${suggestions} - - ${this.#showOAuthLogin ? html` - - ` : this.#renderInputBar(true)} + + +
+ ${renderFirstMessageContent()}
`, this.#shadow, {host: this}); } else { // Render normal expanded view for conversation - Lit.render(html` -
- ${this.#renderVersionBanner()} - - ${Lit.Directives.repeat( - combinedMessages || [], - (m, i) => this.#messageKey(m, i), - (m, i) => this.#renderMessage(m, i) - )} - - ${showGeneralLoading ? html` -
-
-
- - - - - + const renderMainContent = () => { + // Render different views based on active sidebar item + switch (this.#activeSidebarItem) { + case 'connectors': + return html``; + case 'settings': + return html``; + case 'history': + return html``; + case 'evaluations': + return html``; + case 'chat': + default: + // Render chat view + return html` + ${this.#renderVersionBanner()} + + ${Lit.Directives.repeat( + combinedMessages || [], + (m, i) => this.#messageKey(m, i), + (m, i) => this.#renderMessage(m, i) + )} + + ${showGeneralLoading ? html` +
+
+
+ + + + + +
+
-
-
- ` : Lit.nothing} - - - ${showActionsRow ? renderGlobalActionsRow({ - textToCopy: lastModelAnswer || '', - onCopy: () => this.#copyToClipboard(lastModelAnswer || ''), - onThumbsUp: () => this.dispatchEvent(new CustomEvent('feedback', { bubbles: true, detail: { value: 'up' } })), - onThumbsDown: () => this.dispatchEvent(new CustomEvent('feedback', { bubbles: true, detail: { value: 'down' } })), - onRetry: () => this.dispatchEvent(new CustomEvent('retry', { bubbles: true })) - }) : Lit.nothing} - - - - ${this.#renderInputBar(false)} + ` : Lit.nothing} + + + ${showActionsRow ? renderGlobalActionsRow({ + textToCopy: lastModelAnswer || '', + onCopy: () => this.#copyToClipboard(lastModelAnswer || ''), + onThumbsUp: () => this.dispatchEvent(new CustomEvent('feedback', { bubbles: true, detail: { value: 'up' } })), + onThumbsDown: () => this.dispatchEvent(new CustomEvent('feedback', { bubbles: true, detail: { value: 'down' } })), + onRetry: () => this.dispatchEvent(new CustomEvent('retry', { bubbles: true })) + }) : Lit.nothing} + + + + ${this.#renderInputBar(false)} + `; + } + }; + Lit.render(html` + ${stylesTemplate} +
+ + +
+ ${renderMainContent()} +
`, this.#shadow, {host: this}); } @@ -920,13 +1069,12 @@ export class ChatView extends HTMLElement { } return html` -
-
Try one of these
-
+
+
${examples.map(ex => { const tooltipText = ex.promptText || ex.displayText; return html` - + `; })}
@@ -1001,6 +1149,18 @@ export class ChatView extends HTMLElement { void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender); } + #dismissWelcome(): void { + this.#isFirstMessageView = false; + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender); + } + + #handleToggleConnectorsDropdown(event: Event): void { + const customEvent = event as CustomEvent; + this.#showConnectorsDropdown = customEvent.detail.show; + this.#connectorsDropdownPosition = customEvent.detail.position; + void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender); + } + // Get current inspected page URL (if available) #getCurrentPageURL(): string | null { try { @@ -1018,55 +1178,13 @@ export class ChatView extends HTMLElement { // Build example suggestions (generic + page-specific if URL is present) #getExampleSuggestions(): ExamplePromptConfig[] { - const generic: ExamplePromptConfig[] = [ - EXAMPLE_PROMPTS.SUMMARIZE_PAGE, - EXAMPLE_PROMPTS.EXTRACT_LINKS, + return [ + EXAMPLE_PROMPTS.SUMMARIZE_NEWS, + EXAMPLE_PROMPTS.FIND_AND_COMPARE_PRICES, + EXAMPLE_PROMPTS.TRENDING_TOPICS, + EXAMPLE_PROMPTS.RESEARCH_SUMMARY, + EXAMPLE_PROMPTS.FIND_BEST_DEALS, ]; - - const url = this.#getCurrentPageURL(); - if (!url) { - // Show a smaller set to avoid overcrowding - return generic.slice(0, 4); - } - - let hostname = ''; - try { hostname = new URL(url).hostname; } catch {} - - // Detect all Chrome internal pages - const isChromeInternalPage = url.startsWith('chrome://'); - - if (isChromeInternalPage) { - // Provide mixed examples for all Chrome internal pages - return [ - EXAMPLE_PROMPTS.DEEP_RESEARCH_AI_AGENTS, - EXAMPLE_PROMPTS.FIND_CONTENT_WRITERS, - EXAMPLE_PROMPTS.APPLE_STOCKS_ANALYSIS, - EXAMPLE_PROMPTS.SUMMARIZE_NEWS, - EXAMPLE_PROMPTS.STAR_BROWSER_OPERATOR_REPO, - ]; - } - - // Detect common search engines - const isSearchSite = /(^|\.)((google|bing|duckduckgo|yahoo|yandex|baidu)\.(com|co\.[a-z]+|[a-z]+))$/i.test(hostname); - - if (isSearchSite) { - // Provide deep-research oriented examples and pre-select the deep research agent on click - return [ - EXAMPLE_PROMPTS.DEEP_RESEARCH_AI_AGENTS, - EXAMPLE_PROMPTS.IPHONE_REVIEWS, - EXAMPLE_PROMPTS.APPLE_STOCKS_ANALYSIS, - EXAMPLE_PROMPTS.SUMMARIZE_NEWS, - ]; - } - - const specific: ExamplePromptConfig[] = [ - { displayText: `What do you think about ${hostname ? hostname + ' ' : ''}page?` }, - ]; - - // Merge, de-duplicate by displayText, cap to concise set - const map = new Map(); - [...specific, ...generic].forEach(item => { if (!map.has(item.displayText)) map.set(item.displayText, item); }); - return Array.from(map.values()).slice(0, 6); } // Helper method to format JSON with syntax highlighting diff --git a/front_end/panels/ai_chat/ui/ConnectorsDropdown.ts b/front_end/panels/ai_chat/ui/ConnectorsDropdown.ts new file mode 100644 index 0000000000..5845375e59 --- /dev/null +++ b/front_end/panels/ai_chat/ui/ConnectorsDropdown.ts @@ -0,0 +1,369 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Lit from '../../../ui/lit/lit.js'; + +const {html, Decorators} = Lit; +const {customElement} = Decorators as any; + +export interface Connector { + id: string; + name: string; + enabled: boolean; + icon: string; +} + +export interface ConnectorsDropdownProps { + visible: boolean; + connectors: Connector[]; + onToggle: (id: string, enabled: boolean) => void; + onManage: () => void; + onClose: () => void; + position?: {left: number; top: number}; +} + +@customElement('ai-connectors-dropdown') +export class ConnectorsDropdown extends HTMLElement { + static readonly litTagName = Lit.StaticHtml.literal`ai-connectors-dropdown`; + + #visible = false; + #connectors: Connector[] = [ + { id: 'web-search', name: 'Web Search', enabled: true, icon: '🔍' }, + { id: 'notion', name: 'Notion', enabled: false, icon: '📝' }, + { id: 'gmail', name: 'Gmail', enabled: false, icon: '📧' }, + { id: 'gdrive', name: 'Google Drive', enabled: false, icon: '📁' }, + ]; + #onToggle?: (id: string, enabled: boolean) => void; + #onManage?: () => void; + #onClose?: () => void; + #searchQuery = ''; + #position?: {left: number; top: number}; + + set visible(v: boolean) { this.#visible = v; this.#render(); } + set connectors(v: Connector[]) { this.#connectors = v; this.#render(); } + set onToggle(fn: (id: string, enabled: boolean) => void) { this.#onToggle = fn; } + set onManage(fn: () => void) { this.#onManage = fn; } + set onClose(fn: () => void) { this.#onClose = fn; } + set position(v: {left: number; top: number} | undefined) { this.#position = v; this.#render(); } + + connectedCallback(): void { + this.#render(); + } + + #handleToggle(id: string, enabled: boolean): void { + if (this.#onToggle) { + this.#onToggle(id, enabled); + } + // Update local state optimistically + const connector = this.#connectors.find(c => c.id === id); + if (connector) { + connector.enabled = enabled; + this.#render(); + } + } + + #handleSearchInput(e: Event): void { + const input = e.target as HTMLInputElement; + this.#searchQuery = input.value.toLowerCase(); + this.#render(); + } + + #handleManage(): void { + if (this.#onManage) { + this.#onManage(); + } + if (this.#onClose) { + this.#onClose(); + } + } + + #render(): void { + // Keep host footprint zero to avoid affecting layout; we render the surface in a fixed + // positioned div so it overlays the input instead of expanding it. + this.style.display = this.#visible ? 'block' : 'none'; + this.style.position = 'relative'; + this.style.width = '0'; + this.style.height = '0'; + this.style.overflow = 'visible'; + + const filteredConnectors = this.#connectors.filter(c => + c.name.toLowerCase().includes(this.#searchQuery) + ); + + const fallbackPosition = (() => { + const root = this.getRootNode() as Document | ShadowRoot; + const trigger = root.querySelector?.('#connectors-button') as HTMLElement | null; + if (!trigger) { + return undefined; + } + const rect = trigger.getBoundingClientRect(); + return { + left: rect.left + rect.width / 2 + window.scrollX, + top: rect.top + window.scrollY, + }; + })(); + + const effectivePosition = this.#position ?? fallbackPosition; + + const surfaceStyle = effectivePosition ? + `left:${effectivePosition.left}px; top:${effectivePosition.top}px;` : ''; + + Lit.render(html` + + + + `, this, {host: this}); + } +} + +declare global { + interface HTMLElementTagNameMap { 'ai-connectors-dropdown': ConnectorsDropdown; } +} diff --git a/front_end/panels/ai_chat/ui/ConnectorsView.ts b/front_end/panels/ai_chat/ui/ConnectorsView.ts new file mode 100644 index 0000000000..effb697eba --- /dev/null +++ b/front_end/panels/ai_chat/ui/ConnectorsView.ts @@ -0,0 +1,521 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Lit from '../../../ui/lit/lit.js'; + +const {html, Decorators} = Lit; +const {customElement} = Decorators as any; + +export interface Connector { + id: string; + name: string; + category: string; + description: string; + icon: string; + enabled: boolean; +} + +export interface ConnectorsViewProps { + connectors?: Connector[]; + onToggle?: (id: string, enabled: boolean) => void; + onManageConnections?: () => void; +} + +@customElement('ai-connectors-view') +export class ConnectorsView extends HTMLElement { + static readonly litTagName = Lit.StaticHtml.literal`ai-connectors-view`; + + #connectors: Connector[] = [ + // Media + { id: 'invideo', name: 'invideo', category: 'Media', description: 'Build video creation capabilities into your applications', icon: '🎬', enabled: false }, + + // Development + { id: 'sentry', name: 'Sentry', category: 'Development', description: 'Error monitoring & debugging production issues', icon: '🐛', enabled: false }, + + // Project Management + { id: 'linear', name: 'Linear', category: 'Project Management', description: 'Issue tracking & project management', icon: '📈', enabled: false }, + + // Documentation + { id: 'notion', name: 'Notion', category: 'Documentation', description: 'Document & knowledge management', icon: '📝', enabled: false }, + + // Communication + { id: 'intercom', name: 'Intercom', category: 'Communication', description: 'Customer support & conversations', icon: '💬', enabled: false }, + + // AI / ML + { id: 'huggingface', name: 'Hugging Face', category: 'AI / ML', description: 'AI models & machine learning hub', icon: '🤗', enabled: false }, + + // Design + { id: 'canva', name: 'Canva', category: 'Design', description: 'Browse, summarize, autofill, and even generate new Canva designs', icon: '🎨', enabled: false }, + + // Debugging + { id: 'jam', name: 'Jam', category: 'Debugging', description: 'Debug faster with AI agents that can access Jam recordings', icon: '🔧', enabled: false }, + ]; + + #searchQuery = ''; + #expandedCategories = new Set(); // Start collapsed per Figma design + #onToggle?: (id: string, enabled: boolean) => void; + #onManageConnections?: () => void; + + set connectors(v: Connector[]) { this.#connectors = v; this.#render(); } + set onToggle(fn: (id: string, enabled: boolean) => void) { this.#onToggle = fn; } + set onManageConnections(fn: () => void) { this.#onManageConnections = fn; } + + connectedCallback(): void { this.#render(); } + + #handleToggle(id: string, enabled: boolean): void { + if (this.#onToggle) { + this.#onToggle(id, enabled); + } + const connector = this.#connectors.find(c => c.id === id); + if (connector) { + connector.enabled = enabled; + this.#render(); + } + } + + #handleSearchInput(e: Event): void { + const input = e.target as HTMLInputElement; + this.#searchQuery = input.value.toLowerCase(); + this.#render(); + } + + #toggleCategory(category: string): void { + if (this.#expandedCategories.has(category)) { + this.#expandedCategories.delete(category); + } else { + this.#expandedCategories.add(category); + } + this.#render(); + } + + #getConnectedCount(): number { + return this.#connectors.filter(c => c.enabled).length; + } + + #getTotalCount(): number { + return this.#connectors.length; + } + + #groupByCategory(): Map { + const groups = new Map(); + const filtered = this.#connectors.filter(c => + c.name.toLowerCase().includes(this.#searchQuery) || + c.description.toLowerCase().includes(this.#searchQuery) || + c.category.toLowerCase().includes(this.#searchQuery) + ); + + filtered.forEach(connector => { + const category = connector.category; + if (!groups.has(category)) { + groups.set(category, []); + } + groups.get(category)!.push(connector); + }); + + return groups; + } + + #render(): void { + const groupedConnectors = this.#groupByCategory(); + const connectedCount = this.#getConnectedCount(); + const totalCount = this.#getTotalCount(); + + Lit.render(html` + + +
+
+

MCP Connectors

+ +
+ + + + + +
+ +
+ +
+ + + ${connectedCount} of ${totalCount} Connected + + this.#onManageConnections?.()}> + Manage Connections + +
+ +
+
+ ${Array.from(groupedConnectors.entries()).map(([category, connectors]) => { + const isExpanded = this.#expandedCategories.has(category); + const enabledCount = connectors.filter(c => c.enabled).length; + const hasEnabled = enabledCount > 0; + return html` +
+
this.#toggleCategory(category)}> + ${category} +
+ ${hasEnabled ? enabledCount : connectors.length} + + + +
+
+ +
+ ${connectors.map(connector => html` +
+
${connector.icon}
+
+
${connector.name}
+
${connector.description}
+
+
{ + e.stopPropagation(); + this.#handleToggle(connector.id, !connector.enabled); + }} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.#handleToggle(connector.id, !connector.enabled); + } + }} + > +
+
+
+ `)} +
+
+ `; + })} +
+
+
+ `, this, {host: this}); + } +} + +declare global { + interface HTMLElementTagNameMap { 'ai-connectors-view': ConnectorsView; } +} diff --git a/front_end/panels/ai_chat/ui/ConversationHistoryList.ts b/front_end/panels/ai_chat/ui/ConversationHistoryList.ts index 1b95475dd4..e3c28ec9ef 100644 --- a/front_end/panels/ai_chat/ui/ConversationHistoryList.ts +++ b/front_end/panels/ai_chat/ui/ConversationHistoryList.ts @@ -49,7 +49,9 @@ interface GroupedConversations { */ export class ConversationHistoryList extends HTMLElement { static readonly litTagName = Lit.StaticHtml.literal`ai-conversation-history-list`; - readonly #shadow = this.attachShadow({mode: 'open'}); + // Use Light DOM + // readonly #shadow = this.attachShadow({mode: 'open'}); + readonly #shadow = this; readonly #boundRender = this.#render.bind(this); #conversations: ConversationMetadata[] = []; @@ -256,7 +258,7 @@ export class ConversationHistoryList extends HTMLElement { Lit.render( html`
diff --git a/front_end/panels/ai_chat/ui/EvaluationsView.ts b/front_end/panels/ai_chat/ui/EvaluationsView.ts new file mode 100644 index 0000000000..24f905e3fe --- /dev/null +++ b/front_end/panels/ai_chat/ui/EvaluationsView.ts @@ -0,0 +1,695 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Lit from '../../../ui/lit/lit.js'; +import { EvaluationRunner } from '../evaluation/runner/EvaluationRunner.js'; +import { VisionAgentEvaluationRunner } from '../evaluation/runner/VisionAgentEvaluationRunner.js'; +import { schemaExtractorTests } from '../evaluation/test-cases/schema-extractor-tests.js'; +import { streamlinedSchemaExtractorTests } from '../evaluation/test-cases/streamlined-schema-extractor-tests.js'; +import { htmlToMarkdownTests } from '../evaluation/test-cases/html-to-markdown-tests.js'; +import { researchAgentTests } from '../evaluation/test-cases/research-agent-tests.js'; +import { actionAgentTests } from '../evaluation/test-cases/action-agent-tests.js'; +import { webTaskAgentTests } from '../evaluation/test-cases/web-task-agent-tests.js'; +import type { TestResult } from '../evaluation/framework/types.js'; +import { createLogger } from '../core/Logger.js'; +import { AIChatPanel } from './AIChatPanel.js'; +import './common/Dropdown.js'; + +const logger = createLogger('EvaluationsView'); + +const {html, Decorators} = Lit; +const {customElement} = Decorators as any; + +const JUDGE_MODEL_STORAGE_KEY = 'ai_chat_judge_model'; + +const TOOL_TEST_MAPPING: Record = { + 'extract_schema': { tests: schemaExtractorTests, displayName: 'Original Schema Extractor' }, + 'extract_schema_streamlined': { tests: streamlinedSchemaExtractorTests, displayName: 'Streamlined Schema Extractor' }, + 'html_to_markdown': { tests: htmlToMarkdownTests, displayName: 'HTML to Markdown' }, +}; + +const AGENT_TEST_MAPPING: Record = { + 'research_agent': { tests: researchAgentTests, displayName: 'Research Agent' }, + 'action_agent': { tests: actionAgentTests, displayName: 'Action Agent' }, + 'web_task_agent': { tests: webTaskAgentTests, displayName: 'Web Task Agent' }, +}; + +interface EvaluationsViewState { + isRunning: boolean; + testResults: Map; + currentRunningTest?: string; + totalTests: number; + completedTests: number; + startTime?: number; + activeTab: 'tool-tests' | 'agents'; + agentType: string; + visionEnabled?: boolean; + selectedTests: Set; + bottomPanelView: 'summary' | 'logs'; + testLogs: string[]; + toolType: string; + judgeModel: string; +} + +/** + * Inline view for evaluation tests + * Design matches ConnectorsView patterns + */ +@customElement('ai-evaluations-view') +export class EvaluationsView extends HTMLElement { + static readonly litTagName = Lit.StaticHtml.literal`ai-evaluations-view`; + + #state: EvaluationsViewState = { + isRunning: false, + testResults: new Map(), + totalTests: 0, + completedTests: 0, + activeTab: 'tool-tests', + agentType: 'research_agent', + visionEnabled: false, + selectedTests: new Set(), + bottomPanelView: 'summary', + testLogs: [], + toolType: 'extract_schema', + judgeModel: 'gpt-4.1-mini', + }; + + #evaluationRunner?: EvaluationRunner; + #agentEvaluationRunner?: VisionAgentEvaluationRunner; + + connectedCallback(): void { + this.#initializeJudgeModel(); + this.#initializeRunners(); + this.#render(); + } + + #initializeJudgeModel(): void { + try { + const currentProvider = AIChatPanel.getCurrentProvider(); + const providerModels = AIChatPanel.getModelOptions(currentProvider); + + const savedJudgeModel = localStorage.getItem(JUDGE_MODEL_STORAGE_KEY); + if (savedJudgeModel && providerModels.find(m => m.value === savedJudgeModel)) { + this.#state.judgeModel = savedJudgeModel; + return; + } + + const modelPatterns = [ + { pattern: /^(.*\/)?gpt-4\.1-mini(-.*)?$/i }, + { pattern: /^(.*\/)?gpt-4\.1(-.*)?$/i }, + { pattern: /^(.*\/)?gpt-4(-.*)?$/i } + ]; + + for (const { pattern } of modelPatterns) { + const foundModel = providerModels.find(model => pattern.test(model.value)); + if (foundModel) { + this.#state.judgeModel = foundModel.value; + localStorage.setItem(JUDGE_MODEL_STORAGE_KEY, this.#state.judgeModel); + return; + } + } + + if (providerModels.length > 0) { + this.#state.judgeModel = providerModels[0].value; + localStorage.setItem(JUDGE_MODEL_STORAGE_KEY, this.#state.judgeModel); + } + } catch (error) { + logger.error('Failed to initialize judge model:', error); + } + } + + #initializeRunners(): void { + try { + this.#evaluationRunner = new EvaluationRunner({ + judgeModel: this.#state.judgeModel, + mainModel: AIChatPanel.instance().getSelectedModel(), + miniModel: AIChatPanel.getMiniModel(), + nanoModel: AIChatPanel.getNanoModel(), + }); + } catch (error) { + logger.error('Failed to initialize evaluation runner:', error); + } + + try { + this.#agentEvaluationRunner = new VisionAgentEvaluationRunner({ + visionEnabled: this.#state.visionEnabled, + judgeModel: this.#state.judgeModel, + mainModel: AIChatPanel.instance().getSelectedModel(), + miniModel: AIChatPanel.getMiniModel(), + nanoModel: AIChatPanel.getNanoModel(), + }); + } catch (error) { + logger.error('Failed to initialize agent evaluation runner:', error); + } + } + + #addLog(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info'): void { + const timestamp = new Date().toLocaleTimeString(); + this.#state.testLogs.push(`[${timestamp}] [${type.toUpperCase()}] ${message}`); + this.#render(); + } + + #getTests(): any[] { + if (this.#state.activeTab === 'tool-tests') { + const toolMapping = TOOL_TEST_MAPPING[this.#state.toolType]; + return toolMapping ? toolMapping.tests : []; + } else { + const agentMapping = AGENT_TEST_MAPPING[this.#state.agentType]; + return agentMapping ? agentMapping.tests : []; + } + } + + async #runSelectedTests(): Promise { + if (this.#state.selectedTests.size === 0) return; + const testCases = this.#getTests(); + const selectedTestCases = testCases.filter((tc: any) => this.#state.selectedTests.has(tc.id)); + await this.#runTests(selectedTestCases); + } + + async #runAllTests(): Promise { + const testCases = this.#getTests(); + await this.#runTests(testCases); + } + + async #runTests(testCases: any[]): Promise { + if (this.#state.isRunning) return; + + this.#state.isRunning = true; + this.#state.testResults.clear(); + this.#state.totalTests = testCases.length; + this.#state.completedTests = 0; + this.#state.startTime = Date.now(); + this.#state.testLogs = []; + this.#render(); + + this.#addLog(`Starting ${testCases.length} tests...`); + + for (const testCase of testCases) { + this.#state.currentRunningTest = testCase.id; + this.#render(); + + this.#addLog(`Running: ${testCase.name}`); + + try { + let result: TestResult; + if (this.#state.activeTab === 'agents' && this.#agentEvaluationRunner) { + result = await this.#agentEvaluationRunner.runSingleTest(testCase); + } else if (this.#evaluationRunner) { + result = await this.#evaluationRunner.runSingleTest(testCase); + } else { + throw new Error('No evaluation runner available'); + } + + this.#state.testResults.set(testCase.id, result); + this.#addLog( + `${testCase.name}: ${result.status.toUpperCase()}`, + result.status === 'passed' ? 'success' : result.status === 'failed' ? 'warning' : 'error' + ); + } catch (error) { + const errorResult: TestResult = { + testId: testCase.id, + status: 'error', + error: error instanceof Error ? error.message : String(error), + duration: 0, + timestamp: Date.now(), + }; + this.#state.testResults.set(testCase.id, errorResult); + this.#addLog(`${testCase.name}: ERROR - ${errorResult.error}`, 'error'); + } + + this.#state.completedTests++; + this.#render(); + } + + this.#state.isRunning = false; + this.#state.currentRunningTest = undefined; + + const results = Array.from(this.#state.testResults.values()); + const passed = results.filter(r => r.status === 'passed').length; + const failed = results.filter(r => r.status === 'failed').length; + const errors = results.filter(r => r.status === 'error').length; + + this.#addLog(`Tests completed: ${passed} passed, ${failed} failed, ${errors} errors`, 'info'); + this.#render(); + } + + #clearResults(): void { + this.#state.testResults.clear(); + this.#state.testLogs = []; + this.#state.completedTests = 0; + this.#state.totalTests = 0; + this.#render(); + } + + #toggleTestSelection(testId: string): void { + if (this.#state.selectedTests.has(testId)) { + this.#state.selectedTests.delete(testId); + } else { + this.#state.selectedTests.add(testId); + } + this.#render(); + } + + #render(): void { + const testCases = this.#getTests(); + const results = Array.from(this.#state.testResults.values()); + const passed = results.filter(r => r.status === 'passed').length; + const failed = results.filter(r => r.status === 'failed').length; + const errors = results.filter(r => r.status === 'error').length; + + const progress = this.#state.totalTests > 0 + ? (this.#state.completedTests / this.#state.totalTests) * 100 + : 0; + + Lit.render(html` + + +
+
+
+

Evaluation Tests

+
+ ${this.#state.isRunning ? html` + ${this.#state.completedTests}/${this.#state.totalTests} tests +
+
+
+ ` : results.length > 0 ? html` + ✓ ${passed} + ⚠ ${failed} + ✗ ${errors} + ` : ''} +
+
+
+ +
+ + +
+ +
+
+ ${this.#state.activeTab === 'tool-tests' ? html` + + ({ value: key, label: val.displayName }))} + .selectedValue=${this.#state.toolType} + .placeholder=${'Select Tool'} + .onChange=${(value: string) => { this.#state.toolType = value; this.#state.testResults.clear(); this.#state.selectedTests.clear(); this.#render(); }} + > + ` : html` + + ({ value: key, label: val.displayName }))} + .selectedValue=${this.#state.agentType} + .placeholder=${'Select Agent'} + .onChange=${(value: string) => { this.#state.agentType = value; this.#state.testResults.clear(); this.#state.selectedTests.clear(); this.#render(); }} + > + `} +
+ + ${this.#state.selectedTests.size > 0 + ? `${this.#state.selectedTests.size} tests selected` + : 'Click tests to select'} + +
+ +
+ ${testCases.map((tc: any) => { + const result = this.#state.testResults.get(tc.id); + const isRunning = this.#state.currentRunningTest === tc.id; + const isSelected = this.#state.selectedTests.has(tc.id); + let statusClass = ''; + if (isRunning) statusClass = 'running'; + else if (result?.status === 'passed') statusClass = 'passed'; + else if (result?.status === 'failed') statusClass = 'failed'; + else if (result?.status === 'error') statusClass = 'error'; + + return html` +
this.#toggleTestSelection(tc.id)}> +
+
${tc.name}
+
${tc.description}
+
+ ${result || isRunning ? html` + + ${isRunning ? 'Running' : result?.status} + + ` : ''} +
+ `; + })} +
+ +
+
+ + +
+
+ ${this.#state.bottomPanelView === 'summary' ? html` +
Total: ${testCases.length} | Passed: ${passed} | Failed: ${failed} | Errors: ${errors}
+ ${results.length > 0 ? html` +
Pass Rate: ${((passed / results.length) * 100).toFixed(1)}%
+ ` : ''} + ` : html` + ${this.#state.testLogs.length === 0 + ? 'No logs yet. Run tests to see logs.' + : this.#state.testLogs.join('\n')} + `} +
+
+ +
+ + ${this.#state.selectedTests.size > 0 ? html` + + + ` : html` + + `} +
+
+ `, this, {host: this}); + } +} + +declare global { + interface HTMLElementTagNameMap { 'ai-evaluations-view': EvaluationsView; } +} diff --git a/front_end/panels/ai_chat/ui/FileListDisplay.ts b/front_end/panels/ai_chat/ui/FileListDisplay.ts index 0a1f77c072..2db81d6bf6 100644 --- a/front_end/panels/ai_chat/ui/FileListDisplay.ts +++ b/front_end/panels/ai_chat/ui/FileListDisplay.ts @@ -17,7 +17,9 @@ const {html, nothing} = Lit; */ export class FileListDisplay extends HTMLElement { static readonly litTagName = Lit.StaticHtml.literal`ai-file-list-display`; - readonly #shadow = this.attachShadow({mode: 'open'}); + // Use Light DOM + // readonly #shadow = this.attachShadow({mode: 'open'}); + readonly #shadow = this; readonly #boundRender = this.#render.bind(this); #files: FileSummary[] = []; @@ -219,7 +221,7 @@ export class FileListDisplay extends HTMLElement { Lit.render(html` + +
+
+

Chat History

+ +
+ + + + + +
+
+ +
+ + + ${totalCount} Conversations + +
+ +
+ ${!hasConversations + ? html` +
+ + + + +

${this.#searchQuery ? 'No matching conversations' : 'No conversations yet'}

+

${this.#searchQuery ? 'Try a different search term' : 'Start a new chat to begin'}

+
+ ` + : html` + ${this.#renderDateGroup('today', grouped.today)} + ${this.#renderDateGroup('yesterday', grouped.yesterday)} + ${this.#renderDateGroup('thisWeek', grouped.thisWeek)} + ${this.#renderDateGroup('thisMonth', grouped.thisMonth)} + ${this.#renderDateGroup('earlier', grouped.earlier)} + `} +
+
+ `, + this, + {host: this}, + ); + } +} + +customElements.define('ai-history-view', HistoryView); + +declare global { + interface HTMLElementTagNameMap { + 'ai-history-view': HistoryView; + } +} diff --git a/front_end/panels/ai_chat/ui/LiveAgentSessionComponent.ts b/front_end/panels/ai_chat/ui/LiveAgentSessionComponent.ts index 92b5ecc1d5..f087ed47aa 100644 --- a/front_end/panels/ai_chat/ui/LiveAgentSessionComponent.ts +++ b/front_end/panels/ai_chat/ui/LiveAgentSessionComponent.ts @@ -16,7 +16,9 @@ const {customElement} = Decorators; @customElement('live-agent-session') export class LiveAgentSessionComponent extends HTMLElement { static readonly litTagName = Lit.StaticHtml.literal`live-agent-session`; - private readonly shadow = this.attachShadow({mode: 'open'}); + // Use Light DOM + // private readonly shadow = this.attachShadow({mode: 'open'}); + private readonly shadow = this; private _session: AgentSession | null = null; private _variant: 'full'|'nested' = 'full'; @@ -122,7 +124,8 @@ export class LiveAgentSessionComponent extends HTMLElement { this.shadow.innerHTML = ` + +
+
+

${i18nString(UIStrings.settings)}

+
+ +
+ +
+
${i18nString(UIStrings.providerLabel)}
+
${i18nString(UIStrings.providerHint)}
+ this.#handleProviderChange(value)} + > + +
+ + + ${PROVIDER_REGISTRY.map(provider => html` +
{ + if (el) { + this.#providerContainerRefs.set(provider.id, el as HTMLElement); + if (!this.#initialized) { + setTimeout(() => { + this.#initializeProviderSettings(); + this.#initialized = true; + }, 0); + } + } + })} + >
+ `)} + + +
{ + if (el) { + this.#customProviderContainerRef = el as HTMLElement; + } + })} + >
+ + +
{ + if (el) { + this.#mcpContainerRef = el as HTMLElement; + if (!this.#mcpSettings) { + setTimeout(() => this.#initializeMCPAndMemory(), 0); + } + } + })} + >
+ + +
{ + if (el) { + this.#memoryContainerRef = el as HTMLElement; + if (!this.#memorySettings) { + setTimeout(() => this.#initializeMCPAndMemory(), 0); + } + } + })} + >
+ + +
+
+ + Show Browsing History, Vector DB, Tracing, Evaluation +
+ +
+ + + ${this.#showAdvancedSettings ? html` +
{ + if (el) { + this.#historyContainerRef = el as HTMLElement; + setTimeout(() => this.#initializeAdvancedSettings(), 0); + } + })} + >
+ +
{ + if (el) { + this.#vectorDBContainerRef = el as HTMLElement; + setTimeout(() => this.#initializeAdvancedSettings(), 0); + } + })} + >
+ +
{ + if (el) { + this.#tracingContainerRef = el as HTMLElement; + setTimeout(() => this.#initializeAdvancedSettings(), 0); + } + })} + >
+ +
{ + if (el) { + this.#evaluationContainerRef = el as HTMLElement; + setTimeout(() => this.#initializeAdvancedSettings(), 0); + } + })} + >
+ + +
+

Panel Visibility

+
+
+
Show only AI Chat panel
+
+ When disabled, shows all standard DevTools panels (Elements, Console, etc.). Requires DevTools reload. +
+
+
+
+
+ ` : nothing} + + +
+

${i18nString(UIStrings.importantNotice)}

+

+ Beta Version: This is a beta version of the Browser Operator - AI Assistant feature. +

+

+ Data Sharing: When using this feature, your browser data and conversation content will be sent to the AI model for processing. +

+

+ Provider Support: We currently support OpenAI, Groq and OpenRouter providers directly. And we support LiteLLM as a proxy to access 100+ other models. +

+

+ By using this feature, you acknowledge that your data will be processed according to Model Provider's privacy policy and terms of service. +

+
+
+ + + +
+ `, this, {host: this}); } /** @@ -738,15 +1168,19 @@ export class SettingsDialog { * Called from AIChatPanel when OAuth credentials are available */ static updateOpenRouterModels(openrouterModels: Array<{id: string, name?: string}>): void { - // Convert OpenRouter models to ModelOption format const modelOptions: ModelOption[] = openrouterModels.map(model => ({ value: model.id, label: model.name || model.id, type: 'openrouter' as const })); - // Store in localStorage with timestamp localStorage.setItem('openrouter_models_cache', JSON.stringify(modelOptions)); localStorage.setItem('openrouter_models_cache_timestamp', Date.now().toString()); } } + +declare global { + interface HTMLElementTagNameMap { + 'ai-settings-dialog': SettingsDialog; + } +} diff --git a/front_end/panels/ai_chat/ui/SettingsView.ts b/front_end/panels/ai_chat/ui/SettingsView.ts new file mode 100644 index 0000000000..d9308a30e6 --- /dev/null +++ b/front_end/panels/ai_chat/ui/SettingsView.ts @@ -0,0 +1,407 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Lit from '../../../ui/lit/lit.js'; +import './common/Dropdown.js'; + +const {html, Decorators} = Lit; +const {customElement} = Decorators as any; + +export interface ModelConfig { + tier: 'main' | 'mini' | 'nano'; + label: string; + description: string; + selectedModel?: string; + apiKey?: string; + modelOptions: Array<{value: string, label: string}>; +} + +export interface SettingsViewProps { + modelConfigs?: ModelConfig[]; + onModelChange?: (tier: string, model: string) => void; + onApiKeyChange?: (tier: string, apiKey: string) => void; + onSave?: () => void; + onCancel?: () => void; + showAdvancedSettings?: boolean; + onToggleAdvancedSettings?: (enabled: boolean) => void; +} + +@customElement('ai-settings-view') +export class SettingsView extends HTMLElement { + static readonly litTagName = Lit.StaticHtml.literal`ai-settings-view`; + + #modelConfigs: ModelConfig[] = [ + { + tier: 'main', + label: 'Main Model', + description: 'Primary model for complex tasks', + selectedModel: 'claude-3.5-sonnet', + apiKey: '', + modelOptions: [ + { value: 'gpt-4', label: 'GPT-4' }, + { value: 'gpt-4-turbo', label: 'GPT-4 Turbo' }, + { value: 'gpt-4o', label: 'GPT-4o' }, + { value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, + { value: 'gpt-4.1', label: 'GPT-4.1' }, + { value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' }, + { value: 'gpt-4.1-nano', label: 'GPT-4.1 Nano' }, + { value: 'o1', label: 'o1' }, + { value: 'o1-mini', label: 'o1 Mini' }, + { value: 'o1-pro', label: 'o1 Pro' }, + { value: 'o3', label: 'o3' }, + { value: 'o3-mini', label: 'o3 Mini' }, + { value: 'claude-3-opus', label: 'Claude 3 Opus' }, + { value: 'claude-3-sonnet', label: 'Claude 3 Sonnet' }, + { value: 'claude-3-haiku', label: 'Claude 3 Haiku' }, + { value: 'claude-3.5-sonnet', label: 'Claude 3.5 Sonnet' }, + { value: 'claude-3.5-haiku', label: 'Claude 3.5 Haiku' }, + { value: 'claude-4-opus', label: 'Claude 4 Opus' }, + { value: 'claude-4-sonnet', label: 'Claude 4 Sonnet' }, + { value: 'gemini-pro', label: 'Gemini Pro' }, + { value: 'gemini-ultra', label: 'Gemini Ultra' }, + { value: 'gemini-flash', label: 'Gemini Flash' }, + { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'llama-3.1-70b', label: 'Llama 3.1 70B' }, + { value: 'llama-3.1-405b', label: 'Llama 3.1 405B' }, + { value: 'llama-3.3-70b', label: 'Llama 3.3 70B' }, + { value: 'mistral-large', label: 'Mistral Large' }, + { value: 'mistral-medium', label: 'Mistral Medium' }, + { value: 'deepseek-v3', label: 'DeepSeek V3' }, + ], + }, + { + tier: 'mini', + label: 'Mini Model', + description: 'Fast model for simple tasks', + selectedModel: 'gpt-3.5-turbo', + apiKey: '', + modelOptions: [ + { value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo' }, + { value: 'claude-3-sonnet', label: 'Claude 3 Sonnet' }, + ], + }, + { + tier: 'nano', + label: 'Nano Model', + description: 'Lightweight model for quick responses', + selectedModel: 'gemini-flash', + apiKey: '', + modelOptions: [ + { value: 'gemini-flash', label: 'Gemini Flash' }, + { value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo' }, + ], + }, + ]; + + #showAdvancedSettings = false; + #onModelChange?: (tier: string, model: string) => void; + #onApiKeyChange?: (tier: string, apiKey: string) => void; + #onSave?: () => void; + #onCancel?: () => void; + #onToggleAdvancedSettings?: (enabled: boolean) => void; + + set modelConfigs(v: ModelConfig[]) { this.#modelConfigs = v; this.#render(); } + set showAdvancedSettings(v: boolean) { this.#showAdvancedSettings = v; this.#render(); } + set onModelChange(fn: (tier: string, model: string) => void) { this.#onModelChange = fn; } + set onApiKeyChange(fn: (tier: string, apiKey: string) => void) { this.#onApiKeyChange = fn; } + set onSave(fn: () => void) { this.#onSave = fn; } + set onCancel(fn: () => void) { this.#onCancel = fn; } + set onToggleAdvancedSettings(fn: (enabled: boolean) => void) { this.#onToggleAdvancedSettings = fn; } + + connectedCallback(): void { this.#render(); } + + #handleModelSelect(tier: string, model: string): void { + if (this.#onModelChange) { + this.#onModelChange(tier, model); + } + const config = this.#modelConfigs.find(c => c.tier === tier); + if (config) { + config.selectedModel = model; + this.#render(); + } + } + + #handleApiKeyChange(tier: string, value: string): void { + if (this.#onApiKeyChange) { + this.#onApiKeyChange(tier, value); + } + const config = this.#modelConfigs.find(c => c.tier === tier); + if (config) { + config.apiKey = value; + this.#render(); + } + } + + #toggleAdvancedSettings(): void { + this.#showAdvancedSettings = !this.#showAdvancedSettings; + if (this.#onToggleAdvancedSettings) { + this.#onToggleAdvancedSettings(this.#showAdvancedSettings); + } + this.#render(); + } + + #render(): void { + Lit.render(html` + + +
+

Settings

+ +
+ ${this.#modelConfigs.map(config => html` +
+
+
${config.label}
+
${config.description}
+
+ + this.#handleModelSelect(config.tier, value)} + > + + this.#handleApiKeyChange(config.tier, (e.target as HTMLInputElement).value)} + /> +
+ `)} +
+ +
+
+
Advanced Settings
+
Show additional configuration options
+
+
this.#toggleAdvancedSettings()} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.#toggleAdvancedSettings(); + } + }} + > +
+
+
+ +
+ + +
+
+ `, this, {host: this}); + } +} + +declare global { + interface HTMLElementTagNameMap { 'ai-settings-view': SettingsView; } +} diff --git a/front_end/panels/ai_chat/ui/SidebarNav.ts b/front_end/panels/ai_chat/ui/SidebarNav.ts new file mode 100644 index 0000000000..297261e552 --- /dev/null +++ b/front_end/panels/ai_chat/ui/SidebarNav.ts @@ -0,0 +1,195 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Lit from '../../../ui/lit/lit.js'; + +const {html, Decorators} = Lit; +const {customElement} = Decorators as any; + +export type SidebarNavItem = 'chat' | 'agents' | 'connectors' | 'settings' | 'history' | 'help' | 'evaluations'; + +export interface SidebarNavProps { + activeItem?: SidebarNavItem; + onItemClick?: (item: SidebarNavItem) => void; +} + +@customElement('ai-sidebar-nav') +export class SidebarNav extends HTMLElement { + static readonly litTagName = Lit.StaticHtml.literal`ai-sidebar-nav`; + + #activeItem: SidebarNavItem = 'chat'; + #onItemClick?: (item: SidebarNavItem) => void; + + set activeItem(v: SidebarNavItem) { this.#activeItem = v; this.#render(); } + set onItemClick(fn: (item: SidebarNavItem) => void | undefined) { this.#onItemClick = fn; this.#render(); } + + connectedCallback(): void { this.#render(); } + + #handleClick(item: SidebarNavItem): void { + if (this.#onItemClick) { + this.#onItemClick(item); + } + this.#activeItem = item; + this.#render(); + } + + #render(): void { + const navItems = [ + { id: 'chat' as const, icon: this.#getChatIcon(), label: 'Chat' }, + { id: 'agents' as const, icon: this.#getAgentsIcon(), label: 'Agents' }, + { id: 'connectors' as const, icon: this.#getConnectorsIcon(), label: 'Connectors' }, + { id: 'settings' as const, icon: this.#getSettingsIcon(), label: 'Settings' }, + { id: 'history' as const, icon: this.#getHistoryIcon(), label: 'History' }, + { id: 'help' as const, icon: this.#getHelpIcon(), label: 'Help' }, + { id: 'evaluations' as const, icon: this.#getEvaluationsIcon(), label: 'Evaluations' }, + ]; + + Lit.render(html` + + + ${navItems.map(item => html` + + `)} + `, this, {host: this}); + } + + #getChatIcon() { + return html` + + + + `; + } + + #getAgentsIcon() { + return html` + + + + + `; + } + + #getConnectorsIcon() { + return html` + + + + `; + } + + #getSettingsIcon() { + return html` + + + + `; + } + + #getHistoryIcon() { + return html` + + + + `; + } + + #getHelpIcon() { + return html` + + + + `; + } + + #getEvaluationsIcon() { + return html` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { 'ai-sidebar-nav': SidebarNav; } +} diff --git a/front_end/panels/ai_chat/ui/TabSelector.ts b/front_end/panels/ai_chat/ui/TabSelector.ts new file mode 100644 index 0000000000..ec6e6b4120 --- /dev/null +++ b/front_end/panels/ai_chat/ui/TabSelector.ts @@ -0,0 +1,643 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Host from '../../../core/host/host.js'; +import * as i18n from '../../../core/i18n/i18n.js'; +import * as SDK from '../../../core/sdk/sdk.js'; +import type * as Protocol from '../../../generated/protocol.js'; +import * as UI from '../../../ui/legacy/legacy.js'; +import { createLogger } from '../core/Logger.js'; + +const logger = createLogger('TabSelector'); + +const UIStrings = { + /** + * @description Tooltip for the tab selector button + */ + selectTab: 'Select Browser Tab', + /** + * @description Text shown when no tabs are available + */ + noTabsAvailable: 'No browser tabs available', + /** + * @description Format for tab menu item showing URL + * @example {https://example.com} PH1 + */ + tabItem: '{PH1}', +} as const; + +const str_ = i18n.i18n.registerUIStrings('panels/ai_chat/ui/TabSelector.ts', UIStrings); +const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); + +/** + * TabSelector provides a UI component to select different browser tabs (page targets) + * and interact with them. Currently used for testing multi-tab functionality. + */ +export class TabSelector implements SDK.TargetManager.Observer { + readonly #menuButton: UI.Toolbar.ToolbarMenuButton; + readonly #availableTargets: Map = new Map(); + readonly #discoveredTargetInfos: Map = new Map(); + readonly #webSocketUrls: Map = new Map(); // targetId -> webSocketDebuggerUrl + #hasBrowserTarget: boolean = false; + #cdpPort: number | null = null; + static readonly CDP_PORTS = [9222, 9223, 9224, 9229]; // Common CDP ports + static readonly CDP_PORT_STORAGE_KEY = 'ai_chat_cdp_port'; + + constructor() { + // Create menu button with tab icon + this.#menuButton = new UI.Toolbar.ToolbarMenuButton( + this.#populateMenu.bind(this), + /* isIconDropdown */ true, // Must be true for icon buttons with tooltips + /* useSoftMenu */ true, + /* jslogContext */ 'ai-chat.tab-selector', + /* iconName */ 'select-element' + ); + this.#menuButton.setTitle(i18nString(UIStrings.selectTab)); + + // Observe targets to keep the list updated + SDK.TargetManager.TargetManager.instance().observeTargets(this); + + // Initial population of targets (async) + void this.#updateAvailableTargets(); + } + + /** + * Get the toolbar item for this selector + */ + item(): UI.Toolbar.ToolbarItem { + return this.#menuButton; + } + + /** + * Try to enumerate tabs via CDP HTTP endpoint (http://localhost:PORT/json/list) + * This works even without browserTarget if browser was launched with --remote-debugging-port + */ + async #tryEnumerateViaHTTP(): Promise { + logger.info('HTTP enumeration', 'Attempting to enumerate tabs via CDP HTTP endpoint...'); + + // Try saved port first + const savedPort = localStorage.getItem(TabSelector.CDP_PORT_STORAGE_KEY); + if (savedPort) { + const port = parseInt(savedPort, 10); + logger.info('Saved port', `Trying saved CDP port: ${port}`); + if (await this.#fetchTabsFromCDPPort(port)) { + this.#cdpPort = port; // Set instance variable for later use (e.g., opening DevTools) + logger.info('Port confirmed', `Using saved CDP port ${port}`); + return true; + } + } + + // Try common CDP ports + for (const port of TabSelector.CDP_PORTS) { + logger.info('Port scan', `Trying CDP port: ${port}`); + if (await this.#fetchTabsFromCDPPort(port)) { + // Save working port + this.#cdpPort = port; + localStorage.setItem(TabSelector.CDP_PORT_STORAGE_KEY, port.toString()); + logger.info('Port found', `CDP port ${port} works - saved for future use`); + return true; + } + } + + logger.info('HTTP failed', 'Could not connect to any CDP HTTP endpoint'); + return false; + } + + /** + * Fetch tabs from a specific CDP port + */ + async #fetchTabsFromCDPPort(port: number): Promise { + try { + const url = `http://localhost:${port}/json/list`; + logger.debug('Fetching', url); + + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + }); + + if (!response.ok) { + logger.debug('HTTP error', `${response.status} ${response.statusText}`); + return false; + } + + const targets = await response.json(); + logger.info('HTTP response', `Received ${targets.length} targets from CDP`); + + // Log all target types for debugging + const typeCount = targets.reduce((acc: any, t: any) => { + acc[t.type] = (acc[t.type] || 0) + 1; + return acc; + }, {}); + logger.info('Target types', JSON.stringify(typeCount)); + + // Filter for page targets + const pageTabs = targets.filter((t: any) => t.type === 'page'); + logger.info('Page tabs', `Found ${pageTabs.length} page targets out of ${targets.length} total`); + + // Convert to Protocol.Target.TargetInfo format + for (const tab of pageTabs) { + const targetInfo: Protocol.Target.TargetInfo = { + targetId: tab.id as Protocol.Target.TargetID, + type: tab.type, + title: tab.title, + url: tab.url, + attached: false, // HTTP endpoint shows unattached targets + canAccessOpener: false, + }; + + this.#discoveredTargetInfos.set(tab.id, targetInfo); + + // Store WebSocket URL for direct CDP access + if (tab.webSocketDebuggerUrl) { + this.#webSocketUrls.set(tab.id, tab.webSocketDebuggerUrl); + logger.debug('Added to discovered', `[${tab.id}] ${tab.title} - ${tab.url} (wsUrl: ${tab.webSocketDebuggerUrl})`); + } else { + logger.debug('Added to discovered', `[${tab.id}] ${tab.title} - ${tab.url} (no wsUrl)`); + } + + // Try to find matching Target object in TargetManager + const targetManager = SDK.TargetManager.TargetManager.instance(); + const target = targetManager.targetById(tab.id); + if (target) { + this.#availableTargets.set(tab.id, target); + logger.debug('Matched target', `${tab.title} (${tab.url})`); + } else { + logger.debug('HTTP-only target', `${tab.title} (${tab.url}) - no Target object`); + } + } + + logger.info('HTTP enumeration complete', `Added ${this.#discoveredTargetInfos.size} targets to discovered map`); + + return pageTabs.length > 0; + + } catch (error) { + logger.debug('Fetch failed', `Port ${port}: ${error}`); + return false; + } + } + + /** + * Update the list of available targets using hybrid approach: + * 1. Try Target.getTargets if browserTarget available (browser-level CDP access) + * 2. Try CDP HTTP endpoint (/json/list) if no browserTarget (requires --remote-debugging-port) + * 3. Fall back to TargetManager.targets() (local DevTools session only) + */ + async #updateAvailableTargets(): Promise { + this.#availableTargets.clear(); + this.#discoveredTargetInfos.clear(); + + const targetManager = SDK.TargetManager.TargetManager.instance(); + const browserTarget = targetManager.browserTarget(); + + logger.info('Tab enumeration', 'Starting tab enumeration...'); + + // APPROACH 1: Use browser-level Target.getTargets (best - via CDP protocol) + if (browserTarget) { + this.#hasBrowserTarget = true; + logger.info('Browser target', 'Browser target available - using Target.getTargets for all tabs'); + + try { + // Get all targets from the browser via CDP + const response = await browserTarget.targetAgent().invoke_getTargets({ + filter: [{ type: 'page' }] // Filter for page targets only + }); + + logger.info('CDP response', `Target.getTargets returned ${response.targetInfos.length} page targets`); + + // Store discovered target infos + for (const targetInfo of response.targetInfos) { + this.#discoveredTargetInfos.set(targetInfo.targetId, targetInfo); + + // Try to find existing Target object in TargetManager + const target = targetManager.targetById(targetInfo.targetId); + if (target && target.type() === SDK.Target.Type.FRAME) { + this.#availableTargets.set(targetInfo.targetId, target); + logger.debug('Matched target', `${targetInfo.title} (${targetInfo.url})`); + } else { + logger.debug('Unattached target', `${targetInfo.title} (${targetInfo.url}) - no Target object`); + } + } + + logger.info('Enumeration result', `Found ${this.#availableTargets.size} attached tabs, ${this.#discoveredTargetInfos.size} total tabs`); + + } catch (error) { + logger.error('CDP enumeration failed', error); + logger.info('Fallback', 'Falling back to HTTP endpoint'); + + // Try HTTP fallback + const httpSuccess = await this.#tryEnumerateViaHTTP(); + if (!httpSuccess) { + logger.info('Final fallback', 'Falling back to TargetManager.targets()'); + this.#fallbackToLocalTargets(); + } + } + } else { + // APPROACH 2: Try CDP HTTP endpoint (good - via HTTP) + this.#hasBrowserTarget = false; + logger.info('No browser target', 'DevTools not in browserConnection mode - trying CDP HTTP endpoint'); + + const httpSuccess = await this.#tryEnumerateViaHTTP(); + + if (!httpSuccess) { + // APPROACH 3: Fall back to local targets (limited - only inspected tab) + logger.info('HTTP failed', 'Falling back to local targets only'); + this.#fallbackToLocalTargets(); + } + } + + logger.info('Final count', `Total available targets: ${this.#availableTargets.size}, discovered: ${this.#discoveredTargetInfos.size}`); + } + + /** + * Fallback to showing only targets in the current DevTools session + * (inspected tab and its children like iframes) + */ + #fallbackToLocalTargets(): void { + const allTargets = SDK.TargetManager.TargetManager.instance().targets(); + + // Filter for outermost FRAME targets + for (const target of allTargets) { + if (target.type() === SDK.Target.Type.FRAME && target.outermostTarget() === target) { + this.#availableTargets.set(target.id(), target); + + const targetInfo = target.targetInfo(); + if (targetInfo) { + this.#discoveredTargetInfos.set(targetInfo.targetId, targetInfo); + } + } + } + + logger.info('Local targets', `Found ${this.#availableTargets.size} local targets`); + } + + /** + * Populate the context menu with available tabs + * Note: This is synchronous, so we use cached data from the last async update + */ + #populateMenu(contextMenu: UI.ContextMenu.ContextMenu): void { + logger.info('Menu populate', `Showing menu with ${this.#discoveredTargetInfos.size} discovered targets`); + + if (this.#discoveredTargetInfos.size === 0) { + contextMenu.defaultSection().appendItem( + i18nString(UIStrings.noTabsAvailable), + () => {}, + {disabled: true} + ); + + // Show diagnostic info + if (!this.#hasBrowserTarget) { + contextMenu.defaultSection().appendItem( + 'ℹ️ No browser target - can only see inspected tab', + () => {}, + {disabled: true} + ); + } + return; + } + + // Add header showing mode + if (this.#hasBrowserTarget) { + contextMenu.defaultSection().appendItem( + `📋 All Browser Tabs (${this.#discoveredTargetInfos.size} found)`, + () => {}, + {disabled: true} + ); + } else { + contextMenu.defaultSection().appendItem( + `📋 Inspected Tab Only (no browser target)`, + () => {}, + {disabled: true} + ); + } + + contextMenu.defaultSection().appendSeparator(); + + // Add a menu item for each discovered tab + for (const [targetId, targetInfo] of this.#discoveredTargetInfos) { + const url = targetInfo.url || 'about:blank'; + const title = targetInfo.title || 'Untitled'; + + // Check if we have a Target object for this + const target = this.#availableTargets.get(targetId); + + // Check if we have WebSocket URL for this + const hasWebSocket = this.#webSocketUrls.has(targetId); + + let label: string; + let disabled = false; + + if (target) { + // Has Target object - best option + label = `${title}\n${url}\n✓ Read Page + Open DevTools`; + } else if (hasWebSocket) { + // Can read via CDP attachment + open DevTools + label = `${title}\n${url}\n⚡ Read Page + Open DevTools`; + } else { + // No way to control this tab + label = `${title}\n${url}\n✗ Not accessible`; + disabled = true; + } + + contextMenu.defaultSection().appendItem( + label, + () => { + if (target) { + void this.#onTabSelected(target); + } else if (hasWebSocket) { + void this.#onTabSelected(targetId); + } + }, + {disabled} + ); + } + } + + /** + * Open DevTools via primary page target's agent + * This uses the SDK's targetAgent to send Target.openDevTools + */ + async #openDevToolsViaExistingConnection(targetId: string): Promise { + logger.info('Existing connection', `Sending Target.openDevTools for ${targetId} via primaryPageTarget`); + + const targetManager = SDK.TargetManager.TargetManager.instance(); + const primaryTarget = targetManager.primaryPageTarget(); + + if (!primaryTarget) { + throw new Error('No primary page target available'); + } + + try { + const targetAgent = primaryTarget.targetAgent(); + logger.info('Using targetAgent', 'Calling invoke_openDevTools() on primary page target agent'); + + const result = await targetAgent.invoke_openDevTools({ + targetId: targetId as Protocol.Target.TargetID, + }); + + if (result.targetId) { + logger.info('DevTools opened', `Native window opened - DevTools target ID: ${result.targetId}`); + } else { + logger.warn('Unexpected result', `openDevTools returned but targetId is: ${result.targetId}`); + throw new Error('Target.openDevTools returned undefined targetId'); + } + } catch (error) { + logger.error('targetAgent.invoke_openDevTools failed', error); + throw error; + } + } + + /** + * Open DevTools for a target + * + * Strategy: + * 1. Try browserTarget.targetAgent().invoke_openDevTools() - for SDK-managed connections + * 2. Try browser-level WebSocket + Target.openDevTools - opens native DevTools window + * 3. Skip gracefully if neither works + */ + async #openDevToolsForTarget(targetId: string): Promise { + logger.info('Opening DevTools', `Target: ${targetId}`); + + const targetManager = SDK.TargetManager.TargetManager.instance(); + const browserTarget = targetManager.browserTarget(); + + // APPROACH 1: Use browserTarget.targetAgent() (if available) + if (browserTarget) { + try { + const targetAgent = browserTarget.targetAgent(); + logger.info('Approach 1', 'Using browserTarget.targetAgent().invoke_openDevTools()...'); + + const result = await targetAgent.invoke_openDevTools({ + targetId: targetId as Protocol.Target.TargetID, + }); + + logger.info('DevTools opened', `Via browserTarget - DevTools target ID: ${result.targetId}`); + return; + } catch (error) { + logger.error('browserTarget approach failed', error); + // Fall through to browser WebSocket approach + } + } + + // APPROACH 2: Use DevTools' existing CDP connection (works without browserTarget, avoids 403) + try { + logger.info('Approach 2', 'Using DevTools existing CDP connection...'); + await this.#openDevToolsViaExistingConnection(targetId); + return; + } catch (error) { + logger.error('Existing connection approach failed', error); + // Fall through to graceful degradation + } + + // APPROACH 3: Graceful degradation + logger.warn('DevTools not opened', + 'Unable to open DevTools. Ensure browser is running with --remote-debugging-port flag. ' + + 'Navigation still works.'); + + // Don't throw - let the caller continue with other actions + } + + /** + * Read page content from a tab via CDP Target.attachToTarget + * Attaches to the target, reads content, then detaches + */ + async #readPageViaAttachment(targetId: string): Promise { + logger.info('CDP attachment', `Attaching to target ${targetId} to read page content`); + + // Get the primary page target (the tab being inspected) + const targetManager = SDK.TargetManager.TargetManager.instance(); + const primaryTarget = targetManager.primaryPageTarget(); + + if (!primaryTarget) { + throw new Error('No primary page target available'); + } + + const targetAgent = primaryTarget.targetAgent(); + let sessionId: string | null = null; + + try { + // Attach to the target - this auto-creates a Target object in TargetManager! + logger.info('Attaching', 'Calling Target.attachToTarget...'); + const response = await targetAgent.invoke_attachToTarget({ + targetId: targetId as Protocol.Target.TargetID, + flatten: true, // Flat mode for easier session management + }); + + sessionId = response.sessionId; + logger.info('Attached', `Session ID: ${sessionId}`); + + // Wait a moment for the Target object to be registered in TargetManager + await new Promise(resolve => setTimeout(resolve, 100)); + + // Now get the auto-created Target object + const attachedTarget = targetManager.targetById(targetId); + + if (!attachedTarget) { + throw new Error('Target object not created after attachment'); + } + + logger.info('Target found', `Using auto-created Target object for ${targetId}`); + + // Read page content using Runtime.evaluate + const runtimeAgent = attachedTarget.runtimeAgent(); + + // Get page title + const titleResult = await runtimeAgent.invoke_evaluate({ + expression: 'document.title', + returnByValue: true, + }); + + // Get page URL + const urlResult = await runtimeAgent.invoke_evaluate({ + expression: 'window.location.href', + returnByValue: true, + }); + + // Get page text preview (first 500 chars) + const textResult = await runtimeAgent.invoke_evaluate({ + expression: 'document.body ? document.body.innerText.substring(0, 500) : "No body content"', + returnByValue: true, + }); + + // Log the results + logger.info('📄 Page Title', titleResult.result.value || 'No title'); + logger.info('🔗 Page URL', urlResult.result.value || 'No URL'); + logger.info('📝 Content Preview', textResult.result.value || 'No content'); + + logger.info('Read complete', 'Successfully read page content!'); + + } catch (error) { + logger.error('Attachment/reading failed', error); + throw error; + + } finally { + // Always detach when done + if (sessionId) { + try { + await targetAgent.invoke_detachFromTarget({sessionId: sessionId as Protocol.Target.SessionID}); + logger.info('Detached', 'Detached from target'); + } catch (detachError) { + logger.error('Detach failed', detachError); + } + } + } + } + + /** + * Read page content from a target that's already attached + */ + async #readPageContent(target: SDK.Target.Target): Promise { + logger.info('Reading page', `Reading content from ${target.id()}`); + + try { + const runtimeAgent = target.runtimeAgent(); + + // Get page title + const titleResult = await runtimeAgent.invoke_evaluate({ + expression: 'document.title', + returnByValue: true, + }); + + // Get page URL + const urlResult = await runtimeAgent.invoke_evaluate({ + expression: 'window.location.href', + returnByValue: true, + }); + + // Get page text preview + const textResult = await runtimeAgent.invoke_evaluate({ + expression: 'document.body ? document.body.innerText.substring(0, 500) : "No body content"', + returnByValue: true, + }); + + // Log the results + logger.info('📄 Page Title', titleResult.result.value || 'No title'); + logger.info('🔗 Page URL', urlResult.result.value || 'No URL'); + logger.info('📝 Content Preview', textResult.result.value || 'No content'); + + logger.info('Read complete', 'Successfully read page content!'); + } catch (error) { + logger.error('Reading failed', error); + throw error; + } + } + + /** + * Handle tab selection - read page content AND open DevTools + * Supports both attached targets (via Target object) and HTTP-discovered targets (via CDP attachment) + */ + async #onTabSelected(targetOrId: SDK.Target.Target | string): Promise { + let targetId: string; + let targetInfo: Protocol.Target.TargetInfo | undefined; + + // Get target ID and info + if (typeof targetOrId === 'string') { + targetId = targetOrId; + targetInfo = this.#discoveredTargetInfos.get(targetId); + } else { + const target = targetOrId; + targetId = target.id(); + targetInfo = target.targetInfo(); + } + + if (!targetInfo) { + logger.error('Action failed', 'Target info not found'); + return; + } + + logger.info('Tab selected', `${targetInfo.title} (${targetInfo.url})`); + logger.info('Actions', 'Will read page content AND open DevTools'); + + // ACTION 1: Read page content + // Try local SDK access first (fast path), fall back to CDP attachment if needed + try { + const targetManager = SDK.TargetManager.TargetManager.instance(); + const localTarget = targetManager.targetById(targetId); + + if (localTarget) { + // FAST PATH: Use local SDK access (no attachment needed) + logger.info('Reading', 'Using local Target object (fast path)...'); + await this.#readPageContent(localTarget); + } else { + // FALLBACK: Use CDP attachment (remote access) + logger.info('Reading', 'No local Target - using CDP attachment (fallback)...'); + await this.#readPageViaAttachment(targetId); + } + } catch (readError) { + logger.error('Reading failed', readError); + // Continue to try opening DevTools even if reading fails + } + + // ACTION 2: Open DevTools + try { + logger.info('DevTools action', 'Opening DevTools for tab...'); + await this.#openDevToolsForTarget(targetId); + } catch (devtoolsError) { + logger.error('DevTools opening failed', devtoolsError); + // Reading already completed, so this is not critical + } + + logger.info('Actions complete', 'Both actions attempted'); + } + + /** + * Called when a target is added to the browser + */ + targetAdded(target: SDK.Target.Target): void { + if (target.type() === SDK.Target.Type.FRAME && target.outermostTarget() === target) { + logger.debug(`Target added: ${target.name()}`); + this.#updateAvailableTargets(); + } + } + + /** + * Called when a target is removed from the browser + */ + targetRemoved(target: SDK.Target.Target): void { + if (target.type() === SDK.Target.Type.FRAME && target.outermostTarget() === target) { + logger.debug(`Target removed: ${target.name()}`); + this.#updateAvailableTargets(); + } + } +} diff --git a/front_end/panels/ai_chat/ui/TodoListDisplay.ts b/front_end/panels/ai_chat/ui/TodoListDisplay.ts index 5bdfb2f264..e7690ee749 100644 --- a/front_end/panels/ai_chat/ui/TodoListDisplay.ts +++ b/front_end/panels/ai_chat/ui/TodoListDisplay.ts @@ -24,7 +24,9 @@ interface ParsedTodos { @customElement('ai-todo-list') export class TodoListDisplay extends HTMLElement { static readonly litTagName = Lit.StaticHtml.literal`ai-todo-list`; - readonly #shadow = this.attachShadow({mode: 'open'}); + // Use Light DOM + // readonly #shadow = this.attachShadow({mode: 'open'}); + readonly #shadow = this; #collapsed = false; #todos = ''; @@ -105,7 +107,7 @@ export class TodoListDisplay extends HTMLElement { render(html` + + + `, this, {host: this}); + // clang-format on + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-button': Button; + } +} + diff --git a/front_end/panels/ai_chat/ui/common/Dropdown.ts b/front_end/panels/ai_chat/ui/common/Dropdown.ts new file mode 100644 index 0000000000..834e117956 --- /dev/null +++ b/front_end/panels/ai_chat/ui/common/Dropdown.ts @@ -0,0 +1,387 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Lit from '../../../../ui/lit/lit.js'; + +const {html, Decorators} = Lit; +const {customElement} = Decorators as any; + +export interface DropdownOption { + value: string; + label: string; +} + +export interface DropdownProps { + options: DropdownOption[]; + selectedValue: string; + placeholder?: string; + onChange: (value: string) => void; +} + +@customElement('ai-dropdown') +export class Dropdown extends HTMLElement { + static readonly litTagName = Lit.StaticHtml.literal`ai-dropdown`; + + #options: DropdownOption[] = []; + #selectedValue = ''; + #placeholder = 'Select...'; + #isOpen = false; + #onChange?: (value: string) => void; + #menuPosition = { top: 0, left: 0, width: 0 }; + #menuElement: HTMLDivElement | null = null; + + // Search functionality + #searchQuery = ''; + #highlightedIndex = 0; + #searchInput: HTMLInputElement | null = null; + #optionElements: HTMLDivElement[] = []; + + set options(v: DropdownOption[]) { this.#options = v; this.#render(); } + set selectedValue(v: string) { this.#selectedValue = v; this.#render(); } + set placeholder(v: string) { this.#placeholder = v; this.#render(); } + set onChange(fn: (value: string) => void) { this.#onChange = fn; } + + connectedCallback(): void { + this.#render(); + // Close dropdown when clicking outside + document.addEventListener('click', this.#handleOutsideClick); + } + + disconnectedCallback(): void { + document.removeEventListener('click', this.#handleOutsideClick); + this.#removeMenu(); + } + + #handleOutsideClick = (e: Event): void => { + if (!this.contains(e.target as Node) && !this.#menuElement?.contains(e.target as Node)) { + this.#closeDropdown(); + } + }; + + #closeDropdown(): void { + this.#isOpen = false; + this.#searchQuery = ''; + this.#highlightedIndex = 0; + this.#removeMenu(); + this.#render(); + } + + #removeMenu(): void { + if (this.#menuElement) { + this.#menuElement.remove(); + this.#menuElement = null; + this.#searchInput = null; + this.#optionElements = []; + } + } + + #toggleDropdown(): void { + this.#isOpen = !this.#isOpen; + if (this.#isOpen) { + // Calculate position for fixed menu + const row = this.querySelector('.dropdown-row') as HTMLElement; + if (row) { + const rect = row.getBoundingClientRect(); + this.#menuPosition = { + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + }; + } + this.#searchQuery = ''; + this.#highlightedIndex = 0; + this.#createMenu(); + } else { + this.#closeDropdown(); + } + this.#render(); + } + + get #isSearchable(): boolean { + return this.#options.length > 10; + } + + #filteredOptions(): DropdownOption[] { + if (!this.#searchQuery) { + return this.#options; + } + const q = this.#searchQuery.toLowerCase(); + return this.#options.filter(o => + o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q) + ); + } + + #createMenu(): void { + this.#removeMenu(); + + this.#menuElement = document.createElement('div'); + this.#menuElement.className = 'ai-dropdown-menu-portal'; + this.#menuElement.style.cssText = ` + position: fixed; + top: ${this.#menuPosition.top}px; + left: ${this.#menuPosition.left}px; + width: ${this.#menuPosition.width}px; + background: white; + border: 1px solid var(--slate-200, #e2e8f0); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 999999; + overflow: hidden; + display: flex; + flex-direction: column; + `; + + // Add search input if searchable + if (this.#isSearchable) { + this.#searchInput = document.createElement('input'); + this.#searchInput.type = 'text'; + this.#searchInput.placeholder = 'Search...'; + this.#searchInput.style.cssText = ` + padding: 10px 12px; + border: none; + border-bottom: 1px solid var(--slate-200, #e2e8f0); + font-size: 12px; + outline: none; + color: var(--slate-800, #1e293b); + background: white; + `; + this.#searchInput.addEventListener('input', this.#handleSearchInput); + this.#searchInput.addEventListener('keydown', this.#handleKeydown); + this.#menuElement.appendChild(this.#searchInput); + + // Focus the search input after appending to DOM + requestAnimationFrame(() => { + this.#searchInput?.focus(); + }); + } + + // Create options container + const optionsContainer = document.createElement('div'); + optionsContainer.className = 'ai-dropdown-options-container'; + optionsContainer.style.cssText = ` + max-height: 200px; + overflow-y: auto; + `; + this.#renderOptions(optionsContainer); + this.#menuElement.appendChild(optionsContainer); + + this.#menuElement.addEventListener('click', (e) => e.stopPropagation()); + document.body.appendChild(this.#menuElement); + + // Auto-scroll to selected option after DOM is ready + requestAnimationFrame(() => { + this.#scrollToSelected(optionsContainer); + }); + } + + #scrollToSelected(container: HTMLDivElement): void { + const selectedIndex = this.#options.findIndex(o => o.value === this.#selectedValue); + if (selectedIndex >= 0 && this.#optionElements[selectedIndex]) { + const selectedEl = this.#optionElements[selectedIndex]; + // Scroll so selected item is roughly centered in the container + const containerHeight = container.clientHeight; + const scrollTop = selectedEl.offsetTop - (containerHeight / 2) + (selectedEl.offsetHeight / 2); + container.scrollTop = Math.max(0, scrollTop); + } + } + + #renderOptions(container: HTMLDivElement): void { + container.innerHTML = ''; + this.#optionElements = []; + + const filtered = this.#filteredOptions(); + + if (filtered.length === 0) { + const noResults = document.createElement('div'); + noResults.textContent = 'No results found'; + noResults.style.cssText = ` + padding: 10px 12px; + font-size: 12px; + color: var(--slate-500, #64748b); + text-align: center; + `; + container.appendChild(noResults); + return; + } + + filtered.forEach((option, index) => { + const optionEl = document.createElement('div'); + optionEl.className = 'ai-dropdown-option'; + if (option.value === this.#selectedValue) { + optionEl.className += ' selected'; + } + if (index === this.#highlightedIndex) { + optionEl.className += ' highlighted'; + } + optionEl.textContent = option.label; + this.#applyOptionStyles(optionEl, option.value === this.#selectedValue, index === this.#highlightedIndex); + + optionEl.addEventListener('mouseenter', () => { + this.#highlightedIndex = index; + this.#updateHighlight(); + }); + optionEl.addEventListener('click', (e) => { + e.stopPropagation(); + this.#selectOption(option.value); + }); + + container.appendChild(optionEl); + this.#optionElements.push(optionEl); + }); + } + + #applyOptionStyles(el: HTMLDivElement, isSelected: boolean, isHighlighted: boolean): void { + let styles = ` + padding: 10px 12px; + font-size: 12px; + color: var(--slate-800, #1e293b); + cursor: pointer; + transition: background-color 0.1s ease; + `; + if (isSelected) { + styles += 'background: rgba(16, 147, 244, 0.08); color: var(--blue, #1093F4); font-weight: 500;'; + } else if (isHighlighted) { + styles += 'background: #F7F9FC;'; + } + el.style.cssText = styles; + } + + #updateHighlight(): void { + const filtered = this.#filteredOptions(); + this.#optionElements.forEach((el, index) => { + const option = filtered[index]; + if (option) { + this.#applyOptionStyles(el, option.value === this.#selectedValue, index === this.#highlightedIndex); + } + }); + + // Scroll highlighted option into view + const highlightedEl = this.#optionElements[this.#highlightedIndex]; + if (highlightedEl) { + highlightedEl.scrollIntoView({ block: 'nearest' }); + } + } + + #handleSearchInput = (e: Event): void => { + this.#searchQuery = (e.target as HTMLInputElement).value; + this.#highlightedIndex = 0; + + // Re-render options + const optionsContainer = this.#menuElement?.querySelector('div:last-child') as HTMLDivElement; + if (optionsContainer) { + this.#renderOptions(optionsContainer); + } + }; + + #handleKeydown = (e: KeyboardEvent): void => { + const filtered = this.#filteredOptions(); + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this.#highlightedIndex = Math.min(this.#highlightedIndex + 1, filtered.length - 1); + this.#updateHighlight(); + break; + case 'ArrowUp': + e.preventDefault(); + this.#highlightedIndex = Math.max(this.#highlightedIndex - 1, 0); + this.#updateHighlight(); + break; + case 'Enter': + e.preventDefault(); + const option = filtered[this.#highlightedIndex]; + if (option) { + this.#selectOption(option.value); + } + break; + case 'Escape': + e.preventDefault(); + this.#closeDropdown(); + break; + } + }; + + #selectOption(value: string): void { + this.#selectedValue = value; + this.#closeDropdown(); + if (this.#onChange) { + this.#onChange(value); + } + } + + #getSelectedLabel(): string { + const selected = this.#options.find(opt => opt.value === this.#selectedValue); + return selected?.label || this.#placeholder; + } + + #render(): void { + Lit.render(html` + + + + `, this, {host: this}); + } +} + +declare global { + interface HTMLElementTagNameMap { 'ai-dropdown': Dropdown; } +} diff --git a/front_end/panels/ai_chat/ui/common/SearchInput.ts b/front_end/panels/ai_chat/ui/common/SearchInput.ts new file mode 100644 index 0000000000..bdf1aaa2d8 --- /dev/null +++ b/front_end/panels/ai_chat/ui/common/SearchInput.ts @@ -0,0 +1,112 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Lit from '../../../../ui/lit/lit.js'; + +const {html, Decorators} = Lit; +const {customElement, property} = Decorators; + +@customElement('ai-search-input') +export class SearchInput extends HTMLElement { + static readonly litTagName = Lit.StaticHtml.literal`ai-search-input`; + + @property({type: String}) placeholder = 'Search...'; + @property({type: String}) value = ''; + @property({type: Boolean}) disabled = false; + + connectedCallback(): void { + this.#render(); + } + + #handleInput(e: Event): void { + const input = e.target as HTMLInputElement; + this.value = input.value; + + this.dispatchEvent(new CustomEvent('input', { + detail: { value: this.value }, + bubbles: true, + composed: true + })); + } + + #render(): void { + // clang-format off + Lit.render(html` + + +
+ + + + + +
+ `, this, {host: this}); + // clang-format on + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-search-input': SearchInput; + } +} + diff --git a/front_end/panels/ai_chat/ui/common/Switch.ts b/front_end/panels/ai_chat/ui/common/Switch.ts new file mode 100644 index 0000000000..893ee888e1 --- /dev/null +++ b/front_end/panels/ai_chat/ui/common/Switch.ts @@ -0,0 +1,130 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Lit from '../../../../ui/lit/lit.js'; + +const {html, Decorators, nothing} = Lit; +const {customElement, property} = Decorators; + +export interface SwitchChangeEvent { + checked: boolean; +} + +@customElement('ai-switch') +export class Switch extends HTMLElement { + static readonly litTagName = Lit.StaticHtml.literal`ai-switch`; + + @property({type: Boolean}) checked = false; + @property({type: Boolean}) disabled = false; + + #onChange?: (event: SwitchChangeEvent) => void; + + set onChange(fn: (event: SwitchChangeEvent) => void | undefined) { + this.#onChange = fn; + } + + connectedCallback(): void { + this.#render(); + } + + #handleClick(): void { + if (this.disabled) { + return; + } + + this.checked = !this.checked; + + if (this.#onChange) { + this.#onChange({checked: this.checked}); + } + + this.dispatchEvent(new CustomEvent('change', { + detail: {checked: this.checked}, + bubbles: true, + composed: true + })); + + this.#render(); + } + + #render(): void { + // clang-format off + Lit.render(html` + + +
{ + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + this.#handleClick(); + } + }} + > + +
+ `, this, {host: this}); + // clang-format on + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-switch': Switch; + } +} + diff --git a/front_end/panels/ai_chat/ui/input/ChatInput.ts b/front_end/panels/ai_chat/ui/input/ChatInput.ts index b5240c1e07..4315d08290 100644 --- a/front_end/panels/ai_chat/ui/input/ChatInput.ts +++ b/front_end/panels/ai_chat/ui/input/ChatInput.ts @@ -20,7 +20,12 @@ export class ChatInput extends HTMLElement { get placeholder(): string { return this.#placeholder; } set placeholder(v: string) { this.#placeholder = v ?? ''; this.#render(); } get value(): string { return this.#value; } - set value(v: string) { this.#value = v ?? ''; this.#render(); } + set value(v: string) { + this.#value = v ?? ''; + this.#render(); + // Use requestAnimationFrame to ensure DOM is ready before syncing value and autosize + requestAnimationFrame(() => this.#syncDomValue()); + } connectedCallback(): void { this.#render(); } focusInput(): void { (this.querySelector('textarea') as HTMLTextAreaElement | null)?.focus(); } diff --git a/front_end/panels/ai_chat/ui/input/InputBar.ts b/front_end/panels/ai_chat/ui/input/InputBar.ts index aafde41f54..fda59c1b4a 100644 --- a/front_end/panels/ai_chat/ui/input/InputBar.ts +++ b/front_end/panels/ai_chat/ui/input/InputBar.ts @@ -4,10 +4,10 @@ import * as Lit from '../../../../ui/lit/lit.js'; import type { ImageInputData } from '../../models/ChatTypes.js'; -import * as BaseOrchestratorAgent from '../../core/BaseOrchestratorAgent.js'; - import '../model_selector/ModelSelector.js'; import './ChatInput.js'; +import '../ConnectorsDropdown.js'; +import '../AgentDropdown.js'; const {html, Decorators} = Lit; const {customElement} = Decorators as any; @@ -28,6 +28,12 @@ export class InputBar extends HTMLElement { #selectedPromptType?: string|null; #agentButtonsHandler: (event: Event) => void = () => {}; #centered = false; + #showAgentMenu = false; + #showConnectorsDropdown = false; + #showAgentDropdown = false; + #selectedAgentId?: string; + #connectorsDropdownPosition?: {left: number; top: number}; + #agentDropdownPosition?: {left: number; top: number}; set placeholder(v: string) { this.#placeholder = v || ''; this.#render(); } set disabled(v: boolean) { this.#disabled = !!v; this.#render(); } @@ -96,6 +102,100 @@ export class InputBar extends HTMLElement { } } + #toggleAgentMenu(e: Event): void { + e.stopPropagation(); + this.#showAgentMenu = !this.#showAgentMenu; + this.#showConnectorsDropdown = false; // Close connectors when opening agents + this.#render(); + + if (this.#showAgentMenu) { + // Close menu when clicking outside + const closeMenu = () => { + this.#showAgentMenu = false; + this.#render(); + window.removeEventListener('click', closeMenu); + }; + // Delay listener attachment to avoid immediate close + setTimeout(() => window.addEventListener('click', closeMenu), 0); + } + } + + #toggleConnectorsDropdown(e: Event): void { + e.stopPropagation(); + this.#showConnectorsDropdown = !this.#showConnectorsDropdown; + this.#showAgentMenu = false; // Close agents when opening connectors + this.#showAgentDropdown = false; // Close agent dropdown + + if (this.#showConnectorsDropdown) { + const btn = this.querySelector('#connectors-button') as HTMLElement | null; + if (btn) { + const rect = btn.getBoundingClientRect(); + this.#connectorsDropdownPosition = { + left: rect.left + rect.width / 2 + window.scrollX, + top: rect.top + window.scrollY, + }; + } else { + this.#connectorsDropdownPosition = undefined; + } + } else { + this.#connectorsDropdownPosition = undefined; + } + + this.#render(); + + if (this.#showConnectorsDropdown) { + // Close dropdown when clicking outside + const closeDropdown = () => { + this.#showConnectorsDropdown = false; + this.#render(); + window.removeEventListener('click', closeDropdown); + }; + // Delay listener attachment to avoid immediate close + setTimeout(() => window.addEventListener('click', closeDropdown), 0); + } + } + + #toggleAgentDropdown(e: Event): void { + e.stopPropagation(); + this.#showAgentDropdown = !this.#showAgentDropdown; + this.#showAgentMenu = false; + this.#showConnectorsDropdown = false; + if (this.#showAgentDropdown) { + const btn = this.querySelector('#agent-dropdown-button') as HTMLElement | null; + if (btn) { + const rect = btn.getBoundingClientRect(); + this.#agentDropdownPosition = { + left: rect.left + rect.width / 2 + window.scrollX, + top: rect.top + window.scrollY, + }; + } else { + this.#agentDropdownPosition = undefined; + } + } else { + this.#agentDropdownPosition = undefined; + } + this.#render(); + + if (this.#showAgentDropdown) { + const closeDropdown = () => { + this.#showAgentDropdown = false; + this.#agentDropdownPosition = undefined; + this.#render(); + window.removeEventListener('click', closeDropdown); + }; + setTimeout(() => window.addEventListener('click', closeDropdown), 0); + } + } + + #handleAgentSelect(agent: { id: string; name: string }): void { + this.#selectedAgentId = agent.id; + this.dispatchEvent(new CustomEvent('agent-changed', { + bubbles: true, + detail: { agentId: agent.id, agentName: agent.name } + })); + this.#render(); + } + #render(): void { const imagePreview = this.#imageInput ? html`
@@ -106,8 +206,6 @@ export class InputBar extends HTMLElement {
` : Lit.nothing; - const agentButtons = BaseOrchestratorAgent.renderAgentTypeButtons(this.#selectedPromptType ?? null, this.#agentButtonsHandler, this.#centered); - const modelSelector = ( this.#currentProvider !== 'browseroperator' && this.#modelOptions && @@ -129,38 +227,116 @@ export class InputBar extends HTMLElement { > ` : Lit.nothing; + // SVG icons matching demo-builder.io / lucide-react style + const inlineActions = html` +
+ + + +
+ + ${this.#showConnectorsDropdown ? html` + e.stopPropagation()} + > + ` : Lit.nothing} +
+ +
+ + ${this.#showAgentDropdown ? html` + this.#handleAgentSelect(agent)} + .onClose=${() => { this.#showAgentDropdown = false; this.#render(); }} + @click=${(e: Event) => e.stopPropagation()} + > + ` : Lit.nothing} +
+
+ `; + + const micButton = html` + + `; + Lit.render(html` -
+
${imagePreview} -
+
this.#emitSendAndClear((e as CustomEvent).detail)} @inputchange=${(e: Event) => this.dispatchEvent(new CustomEvent('inputchange', { bubbles: true, detail: (e as CustomEvent).detail }))} > -
-
- ${agentButtons} -
- ${modelSelector} - + +
+
+ ${inlineActions} +
+
+
+ ${modelSelector} + ${micButton} + +
+
diff --git a/front_end/panels/ai_chat/ui/mcp/MCPConnectorsCatalogDialog.ts b/front_end/panels/ai_chat/ui/mcp/MCPConnectorsCatalogDialog.ts index 2fdb23b95d..c5450d2f16 100644 --- a/front_end/panels/ai_chat/ui/mcp/MCPConnectorsCatalogDialog.ts +++ b/front_end/panels/ai_chat/ui/mcp/MCPConnectorsCatalogDialog.ts @@ -11,19 +11,19 @@ import mcpConnectorsCatalogDialogStyles from './mcpConnectorsCatalogDialog.css.j const logger = createLogger('MCPConnectorsCatalogDialog'); const LOGO_URLS = { - sentry: '/bundled/Images/sentry-mcp.svg', - atlassian: '/bundled/Images/atlassian-mcp.svg', - linear: '/bundled/Images/linear-mcp.svg', - notion: '/bundled/Images/notion-mcp.svg', - slack: '/bundled/Images/slack-mcp.svg', - github: '/bundled/Images/github-mcp.svg', - asana: '/bundled/Images/asana-mcp.svg', - intercom: '/bundled/Images/intercom-mcp.svg', - 'google-drive': '/bundled/Images/google-drive-mcp.svg', - huggingface: '/bundled/Images/huggingface-mcp.svg', - 'google-sheets': '/bundled/Images/google-sheets-mcp.svg', - socket: '/bundled/Images/socket-mcp.svg', - invideo: '/bundled/Images/invideo-mcp.svg', + sentry: new URL('../../../../Images/sentry-mcp.svg', import.meta.url).toString(), + atlassian: new URL('../../../../Images/atlassian-mcp.svg', import.meta.url).toString(), + linear: new URL('../../../../Images/linear-mcp.svg', import.meta.url).toString(), + notion: new URL('../../../../Images/notion-mcp.svg', import.meta.url).toString(), + slack: new URL('../../../../Images/slack-mcp.svg', import.meta.url).toString(), + github: new URL('../../../../Images/github-mcp.svg', import.meta.url).toString(), + asana: new URL('../../../../Images/asana-mcp.svg', import.meta.url).toString(), + intercom: new URL('../../../../Images/intercom-mcp.svg', import.meta.url).toString(), + 'google-drive': new URL('../../../../Images/google-drive-mcp.svg', import.meta.url).toString(), + huggingface: new URL('../../../../Images/huggingface-mcp.svg', import.meta.url).toString(), + 'google-sheets': new URL('../../../../Images/google-sheets-mcp.svg', import.meta.url).toString(), + socket: new URL('../../../../Images/socket-mcp.svg', import.meta.url).toString(), + invideo: new URL('../../../../Images/invideo-mcp.svg', import.meta.url).toString(), } as const; type MCPConnectorLogoId = keyof typeof LOGO_URLS; diff --git a/front_end/panels/ai_chat/ui/message/MessageList.ts b/front_end/panels/ai_chat/ui/message/MessageList.ts index 4bb3d7afc9..d722b5ecd8 100644 --- a/front_end/panels/ai_chat/ui/message/MessageList.ts +++ b/front_end/panels/ai_chat/ui/message/MessageList.ts @@ -11,7 +11,9 @@ const {customElement} = Decorators as any; @customElement('ai-message-list') export class MessageList extends HTMLElement { static readonly litTagName = Lit.StaticHtml.literal`ai-message-list`; - readonly #shadow = this.attachShadow({mode: 'open'}); + // Use Light DOM + // readonly #shadow = this.attachShadow({mode: 'open'}); + readonly #shadow = this; // Public API properties (no decorators; manual setters trigger render) #messages: ChatMessage[] = []; @@ -27,11 +29,18 @@ export class MessageList extends HTMLElement { // Internal state #pinToBottom = true; - #container?: HTMLElement; #resizeObserver = new ResizeObserver(() => { if (this.#pinToBottom) this.#scrollToBottom(); }); - connectedCallback(): void { this.#render(); } - disconnectedCallback(): void { this.#resizeObserver.disconnect(); } + connectedCallback(): void { + this.#render(); + this.addEventListener('scroll', this.#onScroll); + this.#resizeObserver.observe(this); + } + + disconnectedCallback(): void { + this.#resizeObserver.disconnect(); + this.removeEventListener('scroll', this.#onScroll); + } #onScroll = (e: Event) => { const el = e.target as HTMLElement; @@ -39,27 +48,28 @@ export class MessageList extends HTMLElement { this.#pinToBottom = el.scrollTop + el.clientHeight + SCROLL_ROUNDING_OFFSET >= el.scrollHeight; }; - #scrollToBottom(): void { if (this.#container) this.#container.scrollTop = this.#container.scrollHeight; } + #scrollToBottom(): void { this.scrollTop = this.scrollHeight; } #render(): void { - const refFn = (el?: Element) => { - if (this.#container) { this.#resizeObserver.unobserve(this.#container); } - this.#container = el as HTMLElement | undefined; - if (this.#container) { - this.#resizeObserver.observe(this.#container); - this.#scrollToBottom(); - } else { - this.#pinToBottom = true; - } - }; - - // Container mode: project messages via slot from parent. - Lit.render(html` - -
- -
- `, this.#shadow, {host: this}); + ai-message-list::-webkit-scrollbar { width: 4px; } + ai-message-list::-webkit-scrollbar-track { background: transparent; } + ai-message-list::-webkit-scrollbar-thumb { background-color: var(--color-scrollbar); border-radius: 4px; } + `; + this.prepend(style); + } } } diff --git a/front_end/panels/ai_chat/ui/model_selector/ModelSelector.ts b/front_end/panels/ai_chat/ui/model_selector/ModelSelector.ts index fdfbd73b50..9e196a03f2 100644 --- a/front_end/panels/ai_chat/ui/model_selector/ModelSelector.ts +++ b/front_end/panels/ai_chat/ui/model_selector/ModelSelector.ts @@ -3,6 +3,7 @@ // found in the LICENSE file. import * as Lit from '../../../../ui/lit/lit.js'; +import '../common/Dropdown.js'; const {html, Decorators} = Lit; const {customElement} = Decorators as any; @@ -16,11 +17,6 @@ export class ModelSelector extends HTMLElement { #options: ModelOption[] = []; #selected: string | undefined; #disabled = false; - #open = false; - #query = ''; - #highlighted = 0; - #preferAbove = false; - #forceSearchable = false; get options(): ModelOption[] { return this.#options; } set options(v: ModelOption[]) { this.#options = v || []; this.#render(); } @@ -28,10 +24,6 @@ export class ModelSelector extends HTMLElement { set selected(v: string | undefined) { this.#selected = v; this.#render(); } get disabled(): boolean { return this.#disabled; } set disabled(v: boolean) { this.#disabled = !!v; this.#render(); } - get preferAbove(): boolean { return this.#preferAbove; } - set preferAbove(v: boolean) { this.#preferAbove = !!v; this.#render(); } - get forceSearchable(): boolean { return this.#forceSearchable; } - set forceSearchable(v: boolean) { this.#forceSearchable = !!v; this.#render(); } connectedCallback(): void { this.#render(); } @@ -39,68 +31,45 @@ export class ModelSelector extends HTMLElement { this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value }})); } - #toggle = (e: Event) => { - e.preventDefault(); - if (this.#disabled) return; - const wasOpen = this.#open; - this.#open = !this.#open; + #handleChange = (value: string): void => { + this.#selected = value; + this.#emitChange(value); this.#render(); - if (!wasOpen && this.#open) { - // Notify host that the selector opened (used to lazily refresh models) - this.dispatchEvent(new CustomEvent('model-selector-focus', {bubbles: true})); - } }; - #onSearch = (e: Event) => { this.#query = (e.target as HTMLInputElement).value; this.#highlighted = 0; this.#render(); }; - #onKeydown = (e: KeyboardEvent) => { - const filtered = this.#filtered(); - if (e.key === 'ArrowDown') { e.preventDefault(); this.#highlighted = Math.min(this.#highlighted + 1, filtered.length - 1); this.#render(); } - if (e.key === 'ArrowUp') { e.preventDefault(); this.#highlighted = Math.max(this.#highlighted - 1, 0); this.#render(); } - if (e.key === 'Enter') { e.preventDefault(); const opt = filtered[this.#highlighted]; if (opt) { this.#selected = opt.value; this.#open = false; this.#emitChange(opt.value); this.#render(); } } - if (e.key === 'Escape') { e.preventDefault(); this.#open = false; this.#render(); } - }; - - #filtered(): ModelOption[] { - if (!this.#query) return this.#options; - const q = this.#query.toLowerCase(); - return this.#options.filter(o => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)); - } - #isSearchable(): boolean { return this.#forceSearchable || (this.#options?.length || 0) >= 20; } + #handleFocus = (): void => { + // Notify host that the selector opened (used to lazily refresh models) + this.dispatchEvent(new CustomEvent('model-selector-focus', {bubbles: true})); + }; #render(): void { - const selectedLabel = this.#options.find(o => o.value === this.#selected)?.label || this.#selected || 'Select Model'; - if (!this.#isSearchable()) { - Lit.render(html` -
- -
- `, this, {host: this}); - return; - } - - const filtered = this.#filtered(); Lit.render(html` -
- - ${this.#open ? html` -
e.stopPropagation()}> - -
- ${filtered.map((o, i) => html` -
{ this.#selected = o.value; this.#open = false; this.#emitChange(o.value); this.#render(); }} - @mouseenter=${() => this.#highlighted = i} - >${o.label}
- `)} - ${filtered.length === 0 ? html`
No matching models found
` : ''} -
-
- ` : ''} + + +
+
`, this, {host: this}); } diff --git a/front_end/panels/ai_chat/ui/settings/advanced/EvaluationSettings.ts b/front_end/panels/ai_chat/ui/settings/advanced/EvaluationSettings.ts index 4be7e44ff9..231e6e585a 100644 --- a/front_end/panels/ai_chat/ui/settings/advanced/EvaluationSettings.ts +++ b/front_end/panels/ai_chat/ui/settings/advanced/EvaluationSettings.ts @@ -24,9 +24,11 @@ const logger = createLogger('EvaluationSettings'); export class EvaluationSettings { private container: HTMLElement; private statusUpdateInterval: number | null = null; - private evaluationEnabledCheckbox: HTMLInputElement | null = null; + private isEnabled: boolean = false; + private toggleElement: HTMLDivElement | null = null; private evaluationEndpointInput: HTMLInputElement | null = null; private evaluationSecretKeyInput: HTMLInputElement | null = null; + private configContainer: HTMLDivElement | null = null; constructor(container: HTMLElement) { this.container = container; @@ -37,99 +39,53 @@ export class EvaluationSettings { this.container.innerHTML = ''; this.container.className = 'settings-section evaluation-section'; - // Title - const evaluationSectionTitle = document.createElement('h3'); - evaluationSectionTitle.className = 'settings-subtitle'; - evaluationSectionTitle.textContent = i18nString(UIStrings.evaluationSection); - this.container.appendChild(evaluationSectionTitle); - // Get current evaluation configuration const currentEvaluationConfig = getEvaluationConfig(); - - // Evaluation enabled checkbox - const evaluationEnabledContainer = document.createElement('div'); - evaluationEnabledContainer.className = 'evaluation-enabled-container'; - this.container.appendChild(evaluationEnabledContainer); - - this.evaluationEnabledCheckbox = document.createElement('input'); - this.evaluationEnabledCheckbox.type = 'checkbox'; - this.evaluationEnabledCheckbox.id = 'evaluation-enabled'; - this.evaluationEnabledCheckbox.className = 'evaluation-checkbox'; - this.evaluationEnabledCheckbox.checked = isEvaluationEnabled(); - evaluationEnabledContainer.appendChild(this.evaluationEnabledCheckbox); - - const evaluationEnabledLabel = document.createElement('label'); - evaluationEnabledLabel.htmlFor = 'evaluation-enabled'; - evaluationEnabledLabel.className = 'evaluation-label'; - evaluationEnabledLabel.textContent = i18nString(UIStrings.evaluationEnabled); - evaluationEnabledContainer.appendChild(evaluationEnabledLabel); - - const evaluationEnabledHint = document.createElement('div'); - evaluationEnabledHint.className = 'settings-hint'; - evaluationEnabledHint.textContent = i18nString(UIStrings.evaluationEnabledHint); - this.container.appendChild(evaluationEnabledHint); - - // Connection status indicator - const connectionStatusContainer = document.createElement('div'); - connectionStatusContainer.className = 'connection-status-container'; - connectionStatusContainer.style.display = 'flex'; - connectionStatusContainer.style.alignItems = 'center'; - connectionStatusContainer.style.gap = '8px'; - connectionStatusContainer.style.marginTop = '8px'; - connectionStatusContainer.style.fontSize = '13px'; - this.container.appendChild(connectionStatusContainer); - - const connectionStatusDot = document.createElement('div'); - connectionStatusDot.className = 'connection-status-dot'; - connectionStatusDot.style.width = '8px'; - connectionStatusDot.style.height = '8px'; - connectionStatusDot.style.borderRadius = '50%'; - connectionStatusDot.style.flexShrink = '0'; - connectionStatusContainer.appendChild(connectionStatusDot); - - const connectionStatusText = document.createElement('span'); - connectionStatusText.className = 'connection-status-text'; - connectionStatusContainer.appendChild(connectionStatusText); - - // Function to update connection status - const updateConnectionStatus = () => { - const isConnected = isEvaluationConnected(); - - logger.debug('Updating connection status', { isConnected }); - - if (isConnected) { - connectionStatusDot.style.backgroundColor = 'var(--color-accent-green)'; - connectionStatusText.textContent = 'Connected to evaluation server'; - connectionStatusText.style.color = 'var(--color-accent-green)'; - } else { - connectionStatusDot.style.backgroundColor = 'var(--color-text-disabled)'; - connectionStatusText.textContent = 'Not connected'; - connectionStatusText.style.color = 'var(--color-text-disabled)'; - } - }; - - // Update status initially and when evaluation is enabled/disabled - updateConnectionStatus(); - - // Set up periodic status updates every 2 seconds - this.statusUpdateInterval = setInterval(updateConnectionStatus, 2000); - - // Evaluation configuration container (shown when enabled) - const evaluationConfigContainer = document.createElement('div'); - evaluationConfigContainer.className = 'evaluation-config-container'; - evaluationConfigContainer.style.display = this.evaluationEnabledCheckbox.checked ? 'block' : 'none'; - this.container.appendChild(evaluationConfigContainer); + this.isEnabled = isEvaluationEnabled(); + + // Header with toggle + const headerContainer = document.createElement('div'); + headerContainer.className = 'settings-toggle-container'; + this.container.appendChild(headerContainer); + + const infoContainer = document.createElement('div'); + infoContainer.className = 'settings-toggle-info'; + headerContainer.appendChild(infoContainer); + + const title = document.createElement('div'); + title.className = 'settings-toggle-title'; + title.textContent = i18nString(UIStrings.evaluationSection); + infoContainer.appendChild(title); + + const description = document.createElement('div'); + description.className = 'settings-toggle-description'; + description.textContent = i18nString(UIStrings.evaluationEnabledHint); + infoContainer.appendChild(description); + + // Toggle switch + this.toggleElement = document.createElement('div'); + this.toggleElement.className = 'settings-toggle'; + if (this.isEnabled) { + this.toggleElement.classList.add('active'); + } + this.toggleElement.addEventListener('click', () => this.handleToggle()); + headerContainer.appendChild(this.toggleElement); + + // Configuration container (shown when enabled) + this.configContainer = document.createElement('div'); + this.configContainer.className = 'evaluation-config-container'; + this.configContainer.style.display = this.isEnabled ? 'flex' : 'none'; + this.configContainer.style.flexDirection = 'column'; + this.configContainer.style.gap = '20px'; + this.configContainer.style.marginTop = '20px'; + this.container.appendChild(this.configContainer); // Client ID display (read-only) - const clientIdLabel = document.createElement('div'); - clientIdLabel.className = 'settings-label'; - clientIdLabel.textContent = 'Client ID'; - evaluationConfigContainer.appendChild(clientIdLabel); - - const clientIdHint = document.createElement('div'); - clientIdHint.className = 'settings-hint'; - clientIdHint.textContent = 'Unique identifier for this DevTools instance'; - evaluationConfigContainer.appendChild(clientIdHint); + const clientIdGroup = this.createFieldGroup( + 'Client ID', + 'Unique identifier for this DevTools instance' + ); + this.configContainer.appendChild(clientIdGroup.container); const clientIdInput = document.createElement('input'); clientIdInput.type = 'text'; @@ -138,143 +94,155 @@ export class EvaluationSettings { clientIdInput.readOnly = true; clientIdInput.style.backgroundColor = 'var(--color-background-elevation-1)'; clientIdInput.style.cursor = 'default'; - evaluationConfigContainer.appendChild(clientIdInput); + clientIdGroup.container.appendChild(clientIdInput); // Evaluation endpoint - const evaluationEndpointLabel = document.createElement('div'); - evaluationEndpointLabel.className = 'settings-label'; - evaluationEndpointLabel.textContent = i18nString(UIStrings.evaluationEndpoint); - evaluationConfigContainer.appendChild(evaluationEndpointLabel); - - const evaluationEndpointHint = document.createElement('div'); - evaluationEndpointHint.className = 'settings-hint'; - evaluationEndpointHint.textContent = i18nString(UIStrings.evaluationEndpointHint); - evaluationConfigContainer.appendChild(evaluationEndpointHint); + const endpointGroup = this.createFieldGroup( + i18nString(UIStrings.evaluationEndpoint), + i18nString(UIStrings.evaluationEndpointHint) + ); + this.configContainer.appendChild(endpointGroup.container); this.evaluationEndpointInput = document.createElement('input'); this.evaluationEndpointInput.type = 'text'; this.evaluationEndpointInput.className = 'settings-input'; this.evaluationEndpointInput.placeholder = 'ws://localhost:8080'; this.evaluationEndpointInput.value = currentEvaluationConfig.endpoint || 'ws://localhost:8080'; - evaluationConfigContainer.appendChild(this.evaluationEndpointInput); + endpointGroup.container.appendChild(this.evaluationEndpointInput); // Evaluation secret key - const evaluationSecretKeyLabel = document.createElement('div'); - evaluationSecretKeyLabel.className = 'settings-label'; - evaluationSecretKeyLabel.textContent = i18nString(UIStrings.evaluationSecretKey); - evaluationConfigContainer.appendChild(evaluationSecretKeyLabel); - - const evaluationSecretKeyHint = document.createElement('div'); - evaluationSecretKeyHint.className = 'settings-hint'; - evaluationSecretKeyHint.textContent = i18nString(UIStrings.evaluationSecretKeyHint); - evaluationConfigContainer.appendChild(evaluationSecretKeyHint); + const secretKeyGroup = this.createFieldGroup( + i18nString(UIStrings.evaluationSecretKey), + i18nString(UIStrings.evaluationSecretKeyHint) + ); + this.configContainer.appendChild(secretKeyGroup.container); this.evaluationSecretKeyInput = document.createElement('input'); this.evaluationSecretKeyInput.type = 'password'; this.evaluationSecretKeyInput.className = 'settings-input'; - this.evaluationSecretKeyInput.placeholder = 'Optional secret key'; + this.evaluationSecretKeyInput.placeholder = 'Evaluation secret key (optional)'; this.evaluationSecretKeyInput.value = currentEvaluationConfig.secretKey || ''; - evaluationConfigContainer.appendChild(this.evaluationSecretKeyInput); - - // Connection status message - const connectionStatusMessage = document.createElement('div'); - connectionStatusMessage.className = 'settings-status'; - connectionStatusMessage.style.display = 'none'; - evaluationConfigContainer.appendChild(connectionStatusMessage); - - // Auto-connect when evaluation is enabled/disabled - this.evaluationEnabledCheckbox.addEventListener('change', async () => { - const isEnabled = this.evaluationEnabledCheckbox!.checked; - evaluationConfigContainer.style.display = isEnabled ? 'block' : 'none'; - - // Show connection status - connectionStatusMessage.style.display = 'block'; - - if (isEnabled) { - // Auto-connect when enabled - connectionStatusMessage.textContent = 'Connecting...'; - connectionStatusMessage.style.backgroundColor = 'var(--color-background-elevation-1)'; - connectionStatusMessage.style.color = 'var(--color-text-primary)'; - - try { - const endpoint = this.evaluationEndpointInput!.value.trim() || 'ws://localhost:8080'; - const secretKey = this.evaluationSecretKeyInput!.value.trim(); - - // Update config and connect - setEvaluationConfig({ - enabled: true, - endpoint, - secretKey - }); - - await connectToEvaluationService(); - - // Update client ID display after connection - const clientId = getEvaluationClientId(); - if (clientId) { - clientIdInput.value = clientId; - } - - connectionStatusMessage.textContent = '✓ Connected successfully'; - connectionStatusMessage.style.backgroundColor = 'var(--color-accent-green-background)'; - connectionStatusMessage.style.color = 'var(--color-accent-green)'; - - // Update connection status indicator - setTimeout(updateConnectionStatus, 500); - } catch (error) { - connectionStatusMessage.textContent = `✗ ${error instanceof Error ? error.message : 'Connection failed'}`; - connectionStatusMessage.style.backgroundColor = 'var(--color-accent-red-background)'; - connectionStatusMessage.style.color = 'var(--color-accent-red)'; - - // Uncheck the checkbox if connection failed - this.evaluationEnabledCheckbox!.checked = false; - evaluationConfigContainer.style.display = 'none'; + secretKeyGroup.container.appendChild(this.evaluationSecretKeyInput); + + // Footer with Test Connection button + const footer = document.createElement('div'); + footer.className = 'settings-section-footer'; + this.configContainer.appendChild(footer); + + const testButton = document.createElement('button'); + testButton.className = 'settings-button primary'; + testButton.textContent = 'Test Connection'; + testButton.addEventListener('click', () => this.testConnection(clientIdInput)); + footer.appendChild(testButton); + } + + private createFieldGroup(label: string, hint: string): { container: HTMLDivElement } { + const container = document.createElement('div'); + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '4px'; + + const labelEl = document.createElement('div'); + labelEl.className = 'settings-label'; + labelEl.textContent = label; + container.appendChild(labelEl); + + const hintEl = document.createElement('div'); + hintEl.className = 'settings-hint'; + hintEl.textContent = hint; + container.appendChild(hintEl); + + return { container }; + } + + private async handleToggle(): Promise { + this.isEnabled = !this.isEnabled; + + if (this.toggleElement) { + this.toggleElement.classList.toggle('active', this.isEnabled); + } + + if (this.configContainer) { + this.configContainer.style.display = this.isEnabled ? 'flex' : 'none'; + } + + if (this.isEnabled) { + // Auto-connect when enabled + try { + const endpoint = this.evaluationEndpointInput?.value.trim() || 'ws://localhost:8080'; + const secretKey = this.evaluationSecretKeyInput?.value.trim() || ''; + + setEvaluationConfig({ + enabled: true, + endpoint, + secretKey + }); + + await connectToEvaluationService(); + } catch (error) { + logger.error('Failed to connect to evaluation service', error); + // Revert toggle on failure + this.isEnabled = false; + if (this.toggleElement) { + this.toggleElement.classList.remove('active'); } - } else { - // Auto-disconnect when disabled - connectionStatusMessage.textContent = 'Disconnecting...'; - connectionStatusMessage.style.backgroundColor = 'var(--color-background-elevation-1)'; - connectionStatusMessage.style.color = 'var(--color-text-primary)'; - - try { - disconnectFromEvaluationService(); - - // Update config - setEvaluationConfig({ - enabled: false, - endpoint: this.evaluationEndpointInput!.value.trim() || 'ws://localhost:8080', - secretKey: this.evaluationSecretKeyInput!.value.trim() - }); - - connectionStatusMessage.textContent = '✓ Disconnected'; - connectionStatusMessage.style.backgroundColor = 'var(--color-background-elevation-1)'; - connectionStatusMessage.style.color = 'var(--color-text-primary)'; - - // Update connection status indicator - updateConnectionStatus(); - } catch (error) { - connectionStatusMessage.textContent = `✗ Disconnect error: ${error instanceof Error ? error.message : 'Unknown error'}`; - connectionStatusMessage.style.backgroundColor = 'var(--color-accent-red-background)'; - connectionStatusMessage.style.color = 'var(--color-accent-red)'; + if (this.configContainer) { + this.configContainer.style.display = 'none'; } } + } else { + // Disconnect when disabled + try { + disconnectFromEvaluationService(); + setEvaluationConfig({ + enabled: false, + endpoint: this.evaluationEndpointInput?.value.trim() || 'ws://localhost:8080', + secretKey: this.evaluationSecretKeyInput?.value.trim() || '' + }); + } catch (error) { + logger.error('Failed to disconnect from evaluation service', error); + } + } + } - // Hide status message after 3 seconds - setTimeout(() => { - connectionStatusMessage.style.display = 'none'; - }, 3000); - }); + private async testConnection(clientIdInput: HTMLInputElement): Promise { + try { + const endpoint = this.evaluationEndpointInput?.value.trim() || 'ws://localhost:8080'; + const secretKey = this.evaluationSecretKeyInput?.value.trim() || ''; + + setEvaluationConfig({ + enabled: true, + endpoint, + secretKey + }); + + await connectToEvaluationService(); + + // Update client ID display after connection + const clientId = getEvaluationClientId(); + if (clientId) { + clientIdInput.value = clientId; + } + + // Update toggle state + this.isEnabled = true; + if (this.toggleElement) { + this.toggleElement.classList.add('active'); + } + + logger.info('Test connection successful'); + } catch (error) { + logger.error('Test connection failed', error); + } } save(): void { - // Evaluation settings are auto-saved on enable/disable toggle - // Final save happens in the checkbox change handler - if (!this.evaluationEnabledCheckbox || !this.evaluationEndpointInput || !this.evaluationSecretKeyInput) { + if (!this.evaluationEndpointInput || !this.evaluationSecretKeyInput) { return; } setEvaluationConfig({ - enabled: this.evaluationEnabledCheckbox.checked, + enabled: this.isEnabled, endpoint: this.evaluationEndpointInput.value.trim() || 'ws://localhost:8080', secretKey: this.evaluationSecretKeyInput.value.trim() }); diff --git a/front_end/panels/ai_chat/ui/settings/advanced/MCPSettings.ts b/front_end/panels/ai_chat/ui/settings/advanced/MCPSettings.ts index cf59c7e913..f11b61e678 100644 --- a/front_end/panels/ai_chat/ui/settings/advanced/MCPSettings.ts +++ b/front_end/panels/ai_chat/ui/settings/advanced/MCPSettings.ts @@ -120,10 +120,10 @@ export class MCPSettings { mcpDisconnectButton.style.backgroundColor = '#fee2e2'; mcpDisconnectButton.style.border = '1px solid #fecaca'; mcpDisconnectButton.style.color = '#dc2626'; - mcpDisconnectButton.style.padding = '6px 12px'; + mcpDisconnectButton.style.padding = '8px 14px'; mcpDisconnectButton.style.borderRadius = '6px'; mcpDisconnectButton.style.cursor = 'pointer'; - mcpDisconnectButton.style.fontSize = '12px'; + mcpDisconnectButton.style.fontSize = '14px'; mcpDisconnectButton.style.fontWeight = '500'; mcpDisconnectButton.addEventListener('click', async () => { try { @@ -143,10 +143,10 @@ export class MCPSettings { mcpManageButton.style.backgroundColor = 'var(--color-background-elevation-1)'; mcpManageButton.style.border = '1px solid var(--color-details-hairline)'; mcpManageButton.style.color = 'var(--color-text-primary)'; - mcpManageButton.style.padding = '6px 12px'; + mcpManageButton.style.padding = '8px 14px'; mcpManageButton.style.borderRadius = '6px'; mcpManageButton.style.cursor = 'pointer'; - mcpManageButton.style.fontSize = '12px'; + mcpManageButton.style.fontSize = '14px'; mcpManageButton.style.fontWeight = '500'; mcpManageButton.addEventListener('click', () => { this.onDialogHide(); @@ -161,10 +161,10 @@ export class MCPSettings { mcpReconnectAllButton.style.backgroundColor = '#dbeafe'; mcpReconnectAllButton.style.border = '1px solid #bfdbfe'; mcpReconnectAllButton.style.color = '#1d4ed8'; - mcpReconnectAllButton.style.padding = '6px 12px'; + mcpReconnectAllButton.style.padding = '8px 14px'; mcpReconnectAllButton.style.borderRadius = '6px'; mcpReconnectAllButton.style.cursor = 'pointer'; - mcpReconnectAllButton.style.fontSize = '12px'; + mcpReconnectAllButton.style.fontSize = '14px'; mcpReconnectAllButton.style.fontWeight = '500'; mcpReconnectAllButton.addEventListener('click', async () => { mcpReconnectAllButton.disabled = true; diff --git a/front_end/panels/ai_chat/ui/settings/advanced/MemorySettings.ts b/front_end/panels/ai_chat/ui/settings/advanced/MemorySettings.ts index fad66956a3..f8e20fdcb8 100644 --- a/front_end/panels/ai_chat/ui/settings/advanced/MemorySettings.ts +++ b/front_end/panels/ai_chat/ui/settings/advanced/MemorySettings.ts @@ -18,7 +18,8 @@ import type { FileSummary } from '../../../tools/FileStorageManager.js'; */ export class MemorySettings { private container: HTMLElement; - private memoryEnabledCheckbox: HTMLInputElement | null = null; + private isEnabled: boolean = false; + private toggleElement: HTMLDivElement | null = null; private blockListContainer: HTMLElement | null = null; private blockManager: MemoryBlockManager; private statusMessageElement: HTMLElement | null = null; @@ -33,42 +34,43 @@ export class MemorySettings { this.container.innerHTML = ''; this.container.className = 'settings-section memory-section'; + // Get current state - default to enabled (true) if not set + const storedValue = localStorage.getItem(MEMORY_ENABLED_KEY); + this.isEnabled = storedValue !== 'false'; + // Title const memoryTitle = document.createElement('h3'); memoryTitle.textContent = i18nString(UIStrings.memoryLabel); memoryTitle.classList.add('settings-subtitle'); this.container.appendChild(memoryTitle); - // Memory enabled checkbox - const memoryEnabledContainer = document.createElement('div'); - memoryEnabledContainer.className = 'tracing-enabled-container'; - this.container.appendChild(memoryEnabledContainer); - - this.memoryEnabledCheckbox = document.createElement('input'); - this.memoryEnabledCheckbox.type = 'checkbox'; - this.memoryEnabledCheckbox.id = 'memory-enabled'; - this.memoryEnabledCheckbox.className = 'tracing-checkbox'; - // Default to enabled (true) if not set - const storedValue = localStorage.getItem(MEMORY_ENABLED_KEY); - this.memoryEnabledCheckbox.checked = storedValue !== 'false'; - memoryEnabledContainer.appendChild(this.memoryEnabledCheckbox); - - const memoryEnabledLabel = document.createElement('label'); - memoryEnabledLabel.htmlFor = 'memory-enabled'; - memoryEnabledLabel.className = 'tracing-label'; - memoryEnabledLabel.textContent = i18nString(UIStrings.memoryEnabled); - memoryEnabledContainer.appendChild(memoryEnabledLabel); - - const memoryEnabledHint = document.createElement('div'); - memoryEnabledHint.className = 'settings-hint'; - memoryEnabledHint.textContent = i18nString(UIStrings.memoryEnabledHint); - this.container.appendChild(memoryEnabledHint); - - // Toggle memory and save to localStorage - this.memoryEnabledCheckbox.addEventListener('change', () => { - localStorage.setItem(MEMORY_ENABLED_KEY, this.memoryEnabledCheckbox!.checked.toString()); - this.updateBlockListVisibility(); - }); + // Header with toggle + const headerContainer = document.createElement('div'); + headerContainer.className = 'settings-toggle-container'; + this.container.appendChild(headerContainer); + + const infoContainer = document.createElement('div'); + infoContainer.className = 'settings-toggle-info'; + headerContainer.appendChild(infoContainer); + + const title = document.createElement('div'); + title.className = 'settings-toggle-title'; + title.textContent = i18nString(UIStrings.memoryEnabled); + infoContainer.appendChild(title); + + const description = document.createElement('div'); + description.className = 'settings-toggle-description'; + description.textContent = i18nString(UIStrings.memoryEnabledHint); + infoContainer.appendChild(description); + + // Toggle switch + this.toggleElement = document.createElement('div'); + this.toggleElement.className = 'settings-toggle'; + if (this.isEnabled) { + this.toggleElement.classList.add('active'); + } + this.toggleElement.addEventListener('click', () => this.handleToggle()); + headerContainer.appendChild(this.toggleElement); // Memory blocks list container this.blockListContainer = document.createElement('div'); @@ -80,12 +82,26 @@ export class MemorySettings { this.renderMemoryBlocks(); } + /** + * Handle toggle click + */ + private handleToggle(): void { + this.isEnabled = !this.isEnabled; + + if (this.toggleElement) { + this.toggleElement.classList.toggle('active', this.isEnabled); + } + + localStorage.setItem(MEMORY_ENABLED_KEY, this.isEnabled.toString()); + this.updateBlockListVisibility(); + } + /** * Update visibility of block list based on memory enabled state */ private updateBlockListVisibility(): void { - if (this.blockListContainer && this.memoryEnabledCheckbox) { - this.blockListContainer.style.display = this.memoryEnabledCheckbox.checked ? 'block' : 'none'; + if (this.blockListContainer) { + this.blockListContainer.style.display = this.isEnabled ? 'block' : 'none'; } } @@ -112,14 +128,39 @@ export class MemorySettings { this.blockListContainer.appendChild(this.statusMessageElement); try { - const blocks = await this.blockManager.getAllBlocks(); + let blocks = await this.blockManager.getAllBlocks(); + // Add dummy data for testing if no blocks exist if (blocks.length === 0) { - const emptyMessage = document.createElement('div'); - emptyMessage.className = 'memory-blocks-empty'; - emptyMessage.textContent = 'No memory blocks stored yet. Memory will be extracted from conversations automatically.'; - this.blockListContainer.appendChild(emptyMessage); - return; + blocks = [ + { + filename: 'memory_user.md', + type: 'user' as const, + label: 'User Preferences', + description: 'Personal preferences and settings', + content: '# User Preferences\n\n- Prefers dark mode\n- Uses TypeScript for all projects\n- Likes concise code comments', + charLimit: 10000, + updatedAt: Date.now() - 86400000, // yesterday + }, + { + filename: 'memory_facts.md', + type: 'facts' as const, + label: 'Session Facts', + description: 'Facts learned from conversations', + content: '# Session Facts\n\n- Working on Browser Operator project\n- Uses React and Lit for UI components\n- DevTools frontend codebase', + charLimit: 10000, + updatedAt: Date.now() - 3600000, // 1 hour ago + }, + { + filename: 'memory_project_browser-operator.md', + type: 'project' as const, + label: 'Browser Operator Project', + description: 'Project-specific memory', + content: '# Browser Operator\n\nAI-native browser with multi-agent framework.\n\n## Key Files\n- SettingsDialog.ts\n- MemorySettings.ts', + charLimit: 10000, + updatedAt: Date.now(), // now + }, + ]; } // Create block list diff --git a/front_end/panels/ai_chat/ui/settings/advanced/TracingSettings.ts b/front_end/panels/ai_chat/ui/settings/advanced/TracingSettings.ts index a9ffd5bd70..ff4f82affc 100644 --- a/front_end/panels/ai_chat/ui/settings/advanced/TracingSettings.ts +++ b/front_end/panels/ai_chat/ui/settings/advanced/TracingSettings.ts @@ -12,7 +12,9 @@ import { getTracingConfig, setTracingConfig, isTracingEnabled } from '../../../t */ export class TracingSettings { private container: HTMLElement; - private tracingEnabledCheckbox: HTMLInputElement | null = null; + private isEnabled: boolean = false; + private toggleElement: HTMLDivElement | null = null; + private configContainer: HTMLDivElement | null = null; private endpointInput: HTMLInputElement | null = null; private publicKeyInput: HTMLInputElement | null = null; private secretKeyInput: HTMLInputElement | null = null; @@ -26,182 +28,203 @@ export class TracingSettings { this.container.innerHTML = ''; this.container.className = 'settings-section tracing-section'; - // Title - const tracingSectionTitle = document.createElement('h3'); - tracingSectionTitle.className = 'settings-subtitle'; - tracingSectionTitle.textContent = i18nString(UIStrings.tracingSection); - this.container.appendChild(tracingSectionTitle); - // Get current tracing configuration const currentTracingConfig = getTracingConfig(); - - // Tracing enabled checkbox - const tracingEnabledContainer = document.createElement('div'); - tracingEnabledContainer.className = 'tracing-enabled-container'; - this.container.appendChild(tracingEnabledContainer); - - this.tracingEnabledCheckbox = document.createElement('input'); - this.tracingEnabledCheckbox.type = 'checkbox'; - this.tracingEnabledCheckbox.id = 'tracing-enabled'; - this.tracingEnabledCheckbox.className = 'tracing-checkbox'; - this.tracingEnabledCheckbox.checked = isTracingEnabled(); - tracingEnabledContainer.appendChild(this.tracingEnabledCheckbox); - - const tracingEnabledLabel = document.createElement('label'); - tracingEnabledLabel.htmlFor = 'tracing-enabled'; - tracingEnabledLabel.className = 'tracing-label'; - tracingEnabledLabel.textContent = i18nString(UIStrings.tracingEnabled); - tracingEnabledContainer.appendChild(tracingEnabledLabel); - - const tracingEnabledHint = document.createElement('div'); - tracingEnabledHint.className = 'settings-hint'; - tracingEnabledHint.textContent = i18nString(UIStrings.tracingEnabledHint); - this.container.appendChild(tracingEnabledHint); - - // Tracing configuration container (shown when enabled) - const tracingConfigContainer = document.createElement('div'); - tracingConfigContainer.className = 'tracing-config-container'; - tracingConfigContainer.style.display = this.tracingEnabledCheckbox.checked ? 'block' : 'none'; - this.container.appendChild(tracingConfigContainer); + this.isEnabled = isTracingEnabled(); + + // Header with toggle + const headerContainer = document.createElement('div'); + headerContainer.className = 'settings-toggle-container'; + this.container.appendChild(headerContainer); + + const infoContainer = document.createElement('div'); + infoContainer.className = 'settings-toggle-info'; + headerContainer.appendChild(infoContainer); + + const title = document.createElement('div'); + title.className = 'settings-toggle-title'; + title.textContent = i18nString(UIStrings.tracingSection); + infoContainer.appendChild(title); + + const description = document.createElement('div'); + description.className = 'settings-toggle-description'; + description.textContent = i18nString(UIStrings.tracingEnabledHint); + infoContainer.appendChild(description); + + // Toggle switch + this.toggleElement = document.createElement('div'); + this.toggleElement.className = 'settings-toggle'; + if (this.isEnabled) { + this.toggleElement.classList.add('active'); + } + this.toggleElement.addEventListener('click', () => this.handleToggle()); + headerContainer.appendChild(this.toggleElement); + + // Configuration container (shown when enabled) + this.configContainer = document.createElement('div'); + this.configContainer.className = 'tracing-config-container'; + this.configContainer.style.display = this.isEnabled ? 'flex' : 'none'; + this.configContainer.style.flexDirection = 'column'; + this.configContainer.style.gap = '20px'; + this.configContainer.style.marginTop = '20px'; + this.container.appendChild(this.configContainer); // Langfuse endpoint - const endpointLabel = document.createElement('div'); - endpointLabel.className = 'settings-label'; - endpointLabel.textContent = i18nString(UIStrings.langfuseEndpoint); - tracingConfigContainer.appendChild(endpointLabel); - - const endpointHint = document.createElement('div'); - endpointHint.className = 'settings-hint'; - endpointHint.textContent = i18nString(UIStrings.langfuseEndpointHint); - tracingConfigContainer.appendChild(endpointHint); + const endpointGroup = this.createFieldGroup( + i18nString(UIStrings.langfuseEndpoint), + i18nString(UIStrings.langfuseEndpointHint) + ); + this.configContainer.appendChild(endpointGroup.container); this.endpointInput = document.createElement('input'); this.endpointInput.className = 'settings-input'; this.endpointInput.type = 'text'; - this.endpointInput.placeholder = 'http://localhost:3000'; - this.endpointInput.value = currentTracingConfig.endpoint || 'http://localhost:3000'; - tracingConfigContainer.appendChild(this.endpointInput); + this.endpointInput.placeholder = 'Enter URL'; + this.endpointInput.value = currentTracingConfig.endpoint || ''; + endpointGroup.container.appendChild(this.endpointInput); // Langfuse public key - const publicKeyLabel = document.createElement('div'); - publicKeyLabel.className = 'settings-label'; - publicKeyLabel.textContent = i18nString(UIStrings.langfusePublicKey); - tracingConfigContainer.appendChild(publicKeyLabel); - - const publicKeyHint = document.createElement('div'); - publicKeyHint.className = 'settings-hint'; - publicKeyHint.textContent = i18nString(UIStrings.langfusePublicKeyHint); - tracingConfigContainer.appendChild(publicKeyHint); + const publicKeyGroup = this.createFieldGroup( + i18nString(UIStrings.langfusePublicKey), + i18nString(UIStrings.langfusePublicKeyHint) + ); + this.configContainer.appendChild(publicKeyGroup.container); this.publicKeyInput = document.createElement('input'); this.publicKeyInput.className = 'settings-input'; this.publicKeyInput.type = 'text'; - this.publicKeyInput.placeholder = 'pk-lf-...'; + this.publicKeyInput.placeholder = 'Enter public key'; this.publicKeyInput.value = currentTracingConfig.publicKey || ''; - tracingConfigContainer.appendChild(this.publicKeyInput); + publicKeyGroup.container.appendChild(this.publicKeyInput); // Langfuse secret key - const secretKeyLabel = document.createElement('div'); - secretKeyLabel.className = 'settings-label'; - secretKeyLabel.textContent = i18nString(UIStrings.langfuseSecretKey); - tracingConfigContainer.appendChild(secretKeyLabel); - - const secretKeyHint = document.createElement('div'); - secretKeyHint.className = 'settings-hint'; - secretKeyHint.textContent = i18nString(UIStrings.langfuseSecretKeyHint); - tracingConfigContainer.appendChild(secretKeyHint); + const secretKeyGroup = this.createFieldGroup( + i18nString(UIStrings.langfuseSecretKey), + i18nString(UIStrings.langfuseSecretKeyHint) + ); + this.configContainer.appendChild(secretKeyGroup.container); this.secretKeyInput = document.createElement('input'); this.secretKeyInput.className = 'settings-input'; this.secretKeyInput.type = 'password'; - this.secretKeyInput.placeholder = 'sk-lf-...'; + this.secretKeyInput.placeholder = 'Enter secret key'; this.secretKeyInput.value = currentTracingConfig.secretKey || ''; - tracingConfigContainer.appendChild(this.secretKeyInput); - - // Test connection button - const testTracingButton = document.createElement('button'); - testTracingButton.className = 'settings-button test-button'; - testTracingButton.textContent = i18nString(UIStrings.testTracing); - tracingConfigContainer.appendChild(testTracingButton); - - // Test status message - const testTracingStatus = document.createElement('div'); - testTracingStatus.className = 'settings-status'; - testTracingStatus.style.display = 'none'; - tracingConfigContainer.appendChild(testTracingStatus); - - // Toggle tracing config visibility - this.tracingEnabledCheckbox.addEventListener('change', () => { - tracingConfigContainer.style.display = this.tracingEnabledCheckbox!.checked ? 'block' : 'none'; - }); - - // Test tracing connection - testTracingButton.addEventListener('click', async () => { - testTracingButton.disabled = true; - testTracingStatus.style.display = 'block'; - testTracingStatus.textContent = 'Testing connection...'; - testTracingStatus.style.backgroundColor = 'var(--color-background-elevation-1)'; - testTracingStatus.style.color = 'var(--color-text-primary)'; - - try { - const endpoint = this.endpointInput!.value.trim(); - const publicKey = this.publicKeyInput!.value.trim(); - const secretKey = this.secretKeyInput!.value.trim(); - - if (!endpoint || !publicKey || !secretKey) { - throw new Error('All fields are required for testing'); - } + secretKeyGroup.container.appendChild(this.secretKeyInput); + + // Footer with Test Connection button + const footer = document.createElement('div'); + footer.className = 'settings-section-footer'; + this.configContainer.appendChild(footer); + + const testButton = document.createElement('button'); + testButton.className = 'settings-button primary'; + testButton.textContent = 'Test Connection'; + testButton.addEventListener('click', () => this.testConnection(testButton)); + footer.appendChild(testButton); + } - // Test the connection with a simple trace - const testPayload = { - batch: [{ - id: `test-${Date.now()}`, - timestamp: new Date().toISOString(), - type: 'trace-create', - body: { - id: `trace-test-${Date.now()}`, - name: 'Connection Test', - timestamp: new Date().toISOString() - } - }] - }; - - const response = await fetch(`${endpoint}/api/public/ingestion`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Basic ' + btoa(`${publicKey}:${secretKey}`) - }, - body: JSON.stringify(testPayload) - }); + private createFieldGroup(label: string, hint: string): { container: HTMLDivElement } { + const container = document.createElement('div'); + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '4px'; + + const labelEl = document.createElement('div'); + labelEl.className = 'settings-label'; + labelEl.textContent = label; + container.appendChild(labelEl); + + const hintEl = document.createElement('div'); + hintEl.className = 'settings-hint'; + hintEl.textContent = hint; + container.appendChild(hintEl); + + return { container }; + } + + private handleToggle(): void { + this.isEnabled = !this.isEnabled; - if (response.ok) { - testTracingStatus.textContent = '✓ Connection successful'; - testTracingStatus.style.backgroundColor = 'var(--color-accent-green-background)'; - testTracingStatus.style.color = 'var(--color-accent-green)'; - } else { - const errorText = await response.text(); - throw new Error(`HTTP ${response.status}: ${errorText}`); + if (this.toggleElement) { + this.toggleElement.classList.toggle('active', this.isEnabled); + } + + if (this.configContainer) { + this.configContainer.style.display = this.isEnabled ? 'flex' : 'none'; + } + + // Auto-save toggle state + if (!this.isEnabled) { + setTracingConfig({ provider: 'disabled' }); + } + } + + private async testConnection(testButton: HTMLButtonElement): Promise { + testButton.disabled = true; + testButton.textContent = 'Testing...'; + + try { + const endpoint = this.endpointInput?.value.trim() || ''; + const publicKey = this.publicKeyInput?.value.trim() || ''; + const secretKey = this.secretKeyInput?.value.trim() || ''; + + if (!endpoint || !publicKey || !secretKey) { + throw new Error('All fields are required for testing'); + } + + // Test the connection with a simple trace + const testPayload = { + batch: [{ + id: `test-${Date.now()}`, + timestamp: new Date().toISOString(), + type: 'trace-create', + body: { + id: `trace-test-${Date.now()}`, + name: 'Connection Test', + timestamp: new Date().toISOString() + } + }] + }; + + const response = await fetch(`${endpoint}/api/public/ingestion`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Basic ' + btoa(`${publicKey}:${secretKey}`) + }, + body: JSON.stringify(testPayload) + }); + + if (response.ok) { + testButton.textContent = 'Connected!'; + // Enable toggle if not already + if (!this.isEnabled) { + this.isEnabled = true; + if (this.toggleElement) { + this.toggleElement.classList.add('active'); + } } - } catch (error) { - testTracingStatus.textContent = `✗ ${error instanceof Error ? error.message : 'Connection failed'}`; - testTracingStatus.style.backgroundColor = 'var(--color-accent-red-background)'; - testTracingStatus.style.color = 'var(--color-accent-red)'; - } finally { - testTracingButton.disabled = false; - setTimeout(() => { - testTracingStatus.style.display = 'none'; - }, 5000); + } else { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); } - }); + } catch (error) { + testButton.textContent = 'Failed'; + console.error('Tracing test failed:', error); + } finally { + setTimeout(() => { + testButton.disabled = false; + testButton.textContent = 'Test Connection'; + }, 2000); + } } save(): void { - if (!this.tracingEnabledCheckbox || !this.endpointInput || !this.publicKeyInput || !this.secretKeyInput) { + if (!this.endpointInput || !this.publicKeyInput || !this.secretKeyInput) { return; } - if (this.tracingEnabledCheckbox.checked) { + if (this.isEnabled) { const endpoint = this.endpointInput.value.trim(); const publicKey = this.publicKeyInput.value.trim(); const secretKey = this.secretKeyInput.value.trim(); diff --git a/front_end/panels/ai_chat/ui/settings/advanced/VectorDBSettings.ts b/front_end/panels/ai_chat/ui/settings/advanced/VectorDBSettings.ts index 104a6c28a6..7dd9b743d1 100644 --- a/front_end/panels/ai_chat/ui/settings/advanced/VectorDBSettings.ts +++ b/front_end/panels/ai_chat/ui/settings/advanced/VectorDBSettings.ts @@ -19,7 +19,9 @@ import { */ export class VectorDBSettings { private container: HTMLElement; - private vectorDBEnabledCheckbox: HTMLInputElement | null = null; + private isEnabled: boolean = false; + private toggleElement: HTMLDivElement | null = null; + private configContainer: HTMLDivElement | null = null; private vectorDBEndpointInput: HTMLInputElement | null = null; private vectorDBApiKeyInput: HTMLInputElement | null = null; private milvusPasswordInput: HTMLInputElement | null = null; @@ -35,253 +37,238 @@ export class VectorDBSettings { this.container.innerHTML = ''; this.container.className = 'settings-section vector-db-section'; - // Title - const vectorDBTitle = document.createElement('h3'); - vectorDBTitle.textContent = i18nString(UIStrings.vectorDBLabel); - vectorDBTitle.classList.add('settings-subtitle'); - this.container.appendChild(vectorDBTitle); - - // Vector DB enabled checkbox - const vectorDBEnabledContainer = document.createElement('div'); - vectorDBEnabledContainer.className = 'tracing-enabled-container'; - this.container.appendChild(vectorDBEnabledContainer); - - this.vectorDBEnabledCheckbox = document.createElement('input'); - this.vectorDBEnabledCheckbox.type = 'checkbox'; - this.vectorDBEnabledCheckbox.id = 'vector-db-enabled'; - this.vectorDBEnabledCheckbox.className = 'tracing-checkbox'; - this.vectorDBEnabledCheckbox.checked = localStorage.getItem(VECTOR_DB_ENABLED_KEY) === 'true'; - vectorDBEnabledContainer.appendChild(this.vectorDBEnabledCheckbox); - - const vectorDBEnabledLabel = document.createElement('label'); - vectorDBEnabledLabel.htmlFor = 'vector-db-enabled'; - vectorDBEnabledLabel.className = 'tracing-label'; - vectorDBEnabledLabel.textContent = i18nString(UIStrings.vectorDBEnabled); - vectorDBEnabledContainer.appendChild(vectorDBEnabledLabel); - - const vectorDBEnabledHint = document.createElement('div'); - vectorDBEnabledHint.className = 'settings-hint'; - vectorDBEnabledHint.textContent = i18nString(UIStrings.vectorDBEnabledHint); - this.container.appendChild(vectorDBEnabledHint); - - // Vector DB configuration container (shown when enabled) - const vectorDBConfigContainer = document.createElement('div'); - vectorDBConfigContainer.className = 'tracing-config-container'; - vectorDBConfigContainer.style.display = this.vectorDBEnabledCheckbox.checked ? 'block' : 'none'; - this.container.appendChild(vectorDBConfigContainer); - - // Vector DB Endpoint - const vectorDBEndpointDiv = document.createElement('div'); - vectorDBEndpointDiv.classList.add('settings-field'); - vectorDBConfigContainer.appendChild(vectorDBEndpointDiv); - - const vectorDBEndpointLabel = document.createElement('label'); - vectorDBEndpointLabel.textContent = i18nString(UIStrings.vectorDBEndpoint); - vectorDBEndpointLabel.classList.add('settings-label'); - vectorDBEndpointDiv.appendChild(vectorDBEndpointLabel); - - const vectorDBEndpointHint = document.createElement('div'); - vectorDBEndpointHint.textContent = i18nString(UIStrings.vectorDBEndpointHint); - vectorDBEndpointHint.classList.add('settings-hint'); - vectorDBEndpointDiv.appendChild(vectorDBEndpointHint); + this.isEnabled = localStorage.getItem(VECTOR_DB_ENABLED_KEY) === 'true'; + + // Header with toggle + const headerContainer = document.createElement('div'); + headerContainer.className = 'settings-toggle-container'; + this.container.appendChild(headerContainer); + + const infoContainer = document.createElement('div'); + infoContainer.className = 'settings-toggle-info'; + headerContainer.appendChild(infoContainer); + + const title = document.createElement('div'); + title.className = 'settings-toggle-title'; + title.textContent = i18nString(UIStrings.vectorDBLabel); + infoContainer.appendChild(title); + + const description = document.createElement('div'); + description.className = 'settings-toggle-description'; + description.textContent = i18nString(UIStrings.vectorDBEnabledHint); + infoContainer.appendChild(description); + + // Toggle switch + this.toggleElement = document.createElement('div'); + this.toggleElement.className = 'settings-toggle'; + if (this.isEnabled) { + this.toggleElement.classList.add('active'); + } + this.toggleElement.addEventListener('click', () => this.handleToggle()); + headerContainer.appendChild(this.toggleElement); + + // Configuration container (shown when enabled) + this.configContainer = document.createElement('div'); + this.configContainer.className = 'vector-db-config-container'; + this.configContainer.style.display = this.isEnabled ? 'flex' : 'none'; + this.configContainer.style.flexDirection = 'column'; + this.configContainer.style.gap = '20px'; + this.configContainer.style.marginTop = '20px'; + this.container.appendChild(this.configContainer); + + // Milvus Endpoint + const endpointGroup = this.createFieldGroup( + i18nString(UIStrings.vectorDBEndpoint), + i18nString(UIStrings.vectorDBEndpointHint) + ); + this.configContainer.appendChild(endpointGroup.container); this.vectorDBEndpointInput = document.createElement('input'); this.vectorDBEndpointInput.classList.add('settings-input'); this.vectorDBEndpointInput.type = 'text'; - this.vectorDBEndpointInput.placeholder = 'http://localhost:19530'; + this.vectorDBEndpointInput.placeholder = 'Enter URL'; this.vectorDBEndpointInput.value = localStorage.getItem(MILVUS_ENDPOINT_KEY) || ''; - vectorDBEndpointDiv.appendChild(this.vectorDBEndpointInput); + this.vectorDBEndpointInput.addEventListener('input', () => this.saveSettings()); + endpointGroup.container.appendChild(this.vectorDBEndpointInput); - // Vector DB API Key (Username) - const vectorDBApiKeyDiv = document.createElement('div'); - vectorDBApiKeyDiv.classList.add('settings-field'); - vectorDBConfigContainer.appendChild(vectorDBApiKeyDiv); - - const vectorDBApiKeyLabel = document.createElement('label'); - vectorDBApiKeyLabel.textContent = i18nString(UIStrings.vectorDBApiKey); - vectorDBApiKeyLabel.classList.add('settings-label'); - vectorDBApiKeyDiv.appendChild(vectorDBApiKeyLabel); - - const vectorDBApiKeyHint = document.createElement('div'); - vectorDBApiKeyHint.textContent = i18nString(UIStrings.vectorDBApiKeyHint); - vectorDBApiKeyHint.classList.add('settings-hint'); - vectorDBApiKeyDiv.appendChild(vectorDBApiKeyHint); + // Milvus Username + const usernameGroup = this.createFieldGroup( + i18nString(UIStrings.vectorDBApiKey), + i18nString(UIStrings.vectorDBApiKeyHint) + ); + this.configContainer.appendChild(usernameGroup.container); this.vectorDBApiKeyInput = document.createElement('input'); this.vectorDBApiKeyInput.classList.add('settings-input'); this.vectorDBApiKeyInput.type = 'text'; - this.vectorDBApiKeyInput.placeholder = 'root'; + this.vectorDBApiKeyInput.placeholder = 'Enter username'; this.vectorDBApiKeyInput.value = localStorage.getItem(MILVUS_USERNAME_KEY) || 'root'; - vectorDBApiKeyDiv.appendChild(this.vectorDBApiKeyInput); - - // Milvus Password - const milvusPasswordDiv = document.createElement('div'); - milvusPasswordDiv.classList.add('settings-field'); - vectorDBConfigContainer.appendChild(milvusPasswordDiv); + this.vectorDBApiKeyInput.addEventListener('input', () => this.saveSettings()); + usernameGroup.container.appendChild(this.vectorDBApiKeyInput); - const milvusPasswordLabel = document.createElement('label'); - milvusPasswordLabel.textContent = i18nString(UIStrings.milvusPassword); - milvusPasswordLabel.classList.add('settings-label'); - milvusPasswordDiv.appendChild(milvusPasswordLabel); - - const milvusPasswordHint = document.createElement('div'); - milvusPasswordHint.textContent = i18nString(UIStrings.milvusPasswordHint); - milvusPasswordHint.classList.add('settings-hint'); - milvusPasswordDiv.appendChild(milvusPasswordHint); + // Milvus Password / API Token + const passwordGroup = this.createFieldGroup( + i18nString(UIStrings.milvusPassword), + i18nString(UIStrings.milvusPasswordHint) + ); + this.configContainer.appendChild(passwordGroup.container); this.milvusPasswordInput = document.createElement('input'); this.milvusPasswordInput.classList.add('settings-input'); this.milvusPasswordInput.type = 'password'; - this.milvusPasswordInput.placeholder = 'Milvus (self-hosted) or API token (cloud)'; - this.milvusPasswordInput.value = localStorage.getItem(MILVUS_PASSWORD_KEY) || 'Milvus'; - milvusPasswordDiv.appendChild(this.milvusPasswordInput); + this.milvusPasswordInput.placeholder = 'Enter password / api token'; + this.milvusPasswordInput.value = localStorage.getItem(MILVUS_PASSWORD_KEY) || ''; + this.milvusPasswordInput.addEventListener('input', () => this.saveSettings()); + passwordGroup.container.appendChild(this.milvusPasswordInput); // OpenAI API Key for embeddings - const milvusOpenAIDiv = document.createElement('div'); - milvusOpenAIDiv.classList.add('settings-field'); - vectorDBConfigContainer.appendChild(milvusOpenAIDiv); - - const milvusOpenAILabel = document.createElement('label'); - milvusOpenAILabel.textContent = i18nString(UIStrings.milvusOpenAIKey); - milvusOpenAILabel.classList.add('settings-label'); - milvusOpenAIDiv.appendChild(milvusOpenAILabel); - - const milvusOpenAIHint = document.createElement('div'); - milvusOpenAIHint.textContent = i18nString(UIStrings.milvusOpenAIKeyHint); - milvusOpenAIHint.classList.add('settings-hint'); - milvusOpenAIDiv.appendChild(milvusOpenAIHint); + const openaiGroup = this.createFieldGroup( + i18nString(UIStrings.milvusOpenAIKey), + i18nString(UIStrings.milvusOpenAIKeyHint) + ); + this.configContainer.appendChild(openaiGroup.container); this.milvusOpenAIInput = document.createElement('input'); this.milvusOpenAIInput.classList.add('settings-input'); this.milvusOpenAIInput.type = 'password'; - this.milvusOpenAIInput.placeholder = 'sk-...'; + this.milvusOpenAIInput.placeholder = 'Enter api key'; this.milvusOpenAIInput.value = localStorage.getItem(MILVUS_OPENAI_KEY) || ''; - milvusOpenAIDiv.appendChild(this.milvusOpenAIInput); + this.milvusOpenAIInput.addEventListener('input', () => this.saveSettings()); + openaiGroup.container.appendChild(this.milvusOpenAIInput); - // Vector DB Collection Name - const vectorDBCollectionDiv = document.createElement('div'); - vectorDBCollectionDiv.classList.add('settings-field'); - vectorDBConfigContainer.appendChild(vectorDBCollectionDiv); - - const vectorDBCollectionLabel = document.createElement('label'); - vectorDBCollectionLabel.textContent = i18nString(UIStrings.vectorDBCollection); - vectorDBCollectionLabel.classList.add('settings-label'); - vectorDBCollectionDiv.appendChild(vectorDBCollectionLabel); - - const vectorDBCollectionHint = document.createElement('div'); - vectorDBCollectionHint.textContent = i18nString(UIStrings.vectorDBCollectionHint); - vectorDBCollectionHint.classList.add('settings-hint'); - vectorDBCollectionDiv.appendChild(vectorDBCollectionHint); + // Collection Name + const collectionGroup = this.createFieldGroup( + i18nString(UIStrings.vectorDBCollection), + i18nString(UIStrings.vectorDBCollectionHint) + ); + this.configContainer.appendChild(collectionGroup.container); this.vectorDBCollectionInput = document.createElement('input'); this.vectorDBCollectionInput.classList.add('settings-input'); this.vectorDBCollectionInput.type = 'text'; - this.vectorDBCollectionInput.placeholder = 'bookmarks'; + this.vectorDBCollectionInput.placeholder = 'Enter collection name'; this.vectorDBCollectionInput.value = localStorage.getItem(MILVUS_COLLECTION_KEY) || 'bookmarks'; - vectorDBCollectionDiv.appendChild(this.vectorDBCollectionInput); - - // Test Vector DB Connection Button - const vectorDBTestDiv = document.createElement('div'); - vectorDBTestDiv.classList.add('settings-field', 'test-connection-field'); - vectorDBConfigContainer.appendChild(vectorDBTestDiv); - - const vectorDBTestButton = document.createElement('button'); - vectorDBTestButton.classList.add('settings-button', 'test-button'); - vectorDBTestButton.setAttribute('type', 'button'); - vectorDBTestButton.textContent = i18nString(UIStrings.testVectorDBConnection); - vectorDBTestDiv.appendChild(vectorDBTestButton); - - const vectorDBTestStatus = document.createElement('div'); - vectorDBTestStatus.classList.add('settings-status'); - vectorDBTestStatus.style.display = 'none'; - vectorDBTestDiv.appendChild(vectorDBTestStatus); - - // Toggle vector DB config visibility - this.vectorDBEnabledCheckbox.addEventListener('change', () => { - vectorDBConfigContainer.style.display = this.vectorDBEnabledCheckbox!.checked ? 'block' : 'none'; - localStorage.setItem(VECTOR_DB_ENABLED_KEY, this.vectorDBEnabledCheckbox!.checked.toString()); - }); - - // Save Vector DB settings on input change - const saveVectorDBSettings = () => { - if (!this.vectorDBEnabledCheckbox || !this.vectorDBEndpointInput || !this.vectorDBApiKeyInput || - !this.milvusPasswordInput || !this.vectorDBCollectionInput || !this.milvusOpenAIInput) { - return; - } + this.vectorDBCollectionInput.addEventListener('input', () => this.saveSettings()); + collectionGroup.container.appendChild(this.vectorDBCollectionInput); + + // Footer with Test Connection button + const footer = document.createElement('div'); + footer.className = 'settings-section-footer'; + this.configContainer.appendChild(footer); + + const testButton = document.createElement('button'); + testButton.className = 'settings-button primary'; + testButton.textContent = 'Test Connection'; + testButton.addEventListener('click', () => this.testConnection(testButton)); + footer.appendChild(testButton); + } - localStorage.setItem(VECTOR_DB_ENABLED_KEY, this.vectorDBEnabledCheckbox.checked.toString()); - localStorage.setItem(MILVUS_ENDPOINT_KEY, this.vectorDBEndpointInput.value); - localStorage.setItem(MILVUS_USERNAME_KEY, this.vectorDBApiKeyInput.value); - localStorage.setItem(MILVUS_PASSWORD_KEY, this.milvusPasswordInput.value); - localStorage.setItem(MILVUS_COLLECTION_KEY, this.vectorDBCollectionInput.value); - localStorage.setItem(MILVUS_OPENAI_KEY, this.milvusOpenAIInput.value); - }; - - this.vectorDBEndpointInput.addEventListener('input', saveVectorDBSettings); - this.vectorDBApiKeyInput.addEventListener('input', saveVectorDBSettings); - this.milvusPasswordInput.addEventListener('input', saveVectorDBSettings); - this.vectorDBCollectionInput.addEventListener('input', saveVectorDBSettings); - this.milvusOpenAIInput.addEventListener('input', saveVectorDBSettings); - - // Test Vector DB connection - vectorDBTestButton.addEventListener('click', async () => { - if (!this.vectorDBEndpointInput || !this.vectorDBApiKeyInput || - !this.milvusPasswordInput || !this.vectorDBCollectionInput || !this.milvusOpenAIInput) { - return; - } + private createFieldGroup(label: string, hint: string): { container: HTMLDivElement } { + const container = document.createElement('div'); + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '4px'; - const endpoint = this.vectorDBEndpointInput.value.trim(); + const labelEl = document.createElement('div'); + labelEl.className = 'settings-label'; + labelEl.textContent = label; + container.appendChild(labelEl); - if (!endpoint) { - vectorDBTestStatus.textContent = 'Please enter an endpoint URL'; - vectorDBTestStatus.style.color = 'var(--color-accent-red)'; - vectorDBTestStatus.style.display = 'block'; - setTimeout(() => { - vectorDBTestStatus.style.display = 'none'; - }, 3000); - return; - } + const hintEl = document.createElement('div'); + hintEl.className = 'settings-hint'; + hintEl.textContent = hint; + container.appendChild(hintEl); + + return { container }; + } + + private handleToggle(): void { + this.isEnabled = !this.isEnabled; + + if (this.toggleElement) { + this.toggleElement.classList.toggle('active', this.isEnabled); + } + + if (this.configContainer) { + this.configContainer.style.display = this.isEnabled ? 'flex' : 'none'; + } + + localStorage.setItem(VECTOR_DB_ENABLED_KEY, this.isEnabled.toString()); + } + + private saveSettings(): void { + if (!this.vectorDBEndpointInput || !this.vectorDBApiKeyInput || + !this.milvusPasswordInput || !this.vectorDBCollectionInput || !this.milvusOpenAIInput) { + return; + } + + localStorage.setItem(VECTOR_DB_ENABLED_KEY, this.isEnabled.toString()); + localStorage.setItem(MILVUS_ENDPOINT_KEY, this.vectorDBEndpointInput.value); + localStorage.setItem(MILVUS_USERNAME_KEY, this.vectorDBApiKeyInput.value); + localStorage.setItem(MILVUS_PASSWORD_KEY, this.milvusPasswordInput.value); + localStorage.setItem(MILVUS_COLLECTION_KEY, this.vectorDBCollectionInput.value); + localStorage.setItem(MILVUS_OPENAI_KEY, this.milvusOpenAIInput.value); + } - vectorDBTestButton.disabled = true; - vectorDBTestStatus.textContent = i18nString(UIStrings.testingVectorDBConnection); - vectorDBTestStatus.style.color = 'var(--color-text-secondary)'; - vectorDBTestStatus.style.display = 'block'; - - try { - // Import and test the Vector DB client - const { VectorDBClient } = await import('../../../tools/VectorDBClient.js'); - const vectorClient = new VectorDBClient({ - endpoint, - username: this.vectorDBApiKeyInput.value || 'root', - password: this.milvusPasswordInput.value || 'Milvus', - collection: this.vectorDBCollectionInput.value || 'bookmarks', - openaiApiKey: this.milvusOpenAIInput.value || undefined - }); - - const testResult = await vectorClient.testConnection(); - - if (testResult.success) { - vectorDBTestStatus.textContent = i18nString(UIStrings.vectorDBConnectionSuccess); - vectorDBTestStatus.style.color = 'var(--color-accent-green)'; - } else { - vectorDBTestStatus.textContent = `${i18nString(UIStrings.vectorDBConnectionFailed)}: ${testResult.error}`; - vectorDBTestStatus.style.color = 'var(--color-accent-red)'; + private async testConnection(testButton: HTMLButtonElement): Promise { + if (!this.vectorDBEndpointInput || !this.vectorDBApiKeyInput || + !this.milvusPasswordInput || !this.vectorDBCollectionInput || !this.milvusOpenAIInput) { + return; + } + + const endpoint = this.vectorDBEndpointInput.value.trim(); + + if (!endpoint) { + testButton.textContent = 'Enter endpoint'; + setTimeout(() => { + testButton.textContent = 'Test Connection'; + }, 2000); + return; + } + + testButton.disabled = true; + testButton.textContent = 'Testing...'; + + try { + // Import and test the Vector DB client + const { VectorDBClient } = await import('../../../tools/VectorDBClient.js'); + const vectorClient = new VectorDBClient({ + endpoint, + username: this.vectorDBApiKeyInput.value || 'root', + password: this.milvusPasswordInput.value || 'Milvus', + collection: this.vectorDBCollectionInput.value || 'bookmarks', + openaiApiKey: this.milvusOpenAIInput.value || undefined + }); + + const testResult = await vectorClient.testConnection(); + + if (testResult.success) { + testButton.textContent = 'Connected!'; + // Enable toggle if not already + if (!this.isEnabled) { + this.isEnabled = true; + if (this.toggleElement) { + this.toggleElement.classList.add('active'); + } + localStorage.setItem(VECTOR_DB_ENABLED_KEY, 'true'); } - } catch (error: any) { - vectorDBTestStatus.textContent = `${i18nString(UIStrings.vectorDBConnectionFailed)}: ${error.message}`; - vectorDBTestStatus.style.color = 'var(--color-accent-red)'; - } finally { - vectorDBTestButton.disabled = false; - setTimeout(() => { - vectorDBTestStatus.style.display = 'none'; - }, 5000); + } else { + testButton.textContent = 'Failed'; + console.error('Vector DB test failed:', testResult.error); } - }); + } catch (error: any) { + testButton.textContent = 'Failed'; + console.error('Vector DB test error:', error.message); + } finally { + setTimeout(() => { + testButton.disabled = false; + testButton.textContent = 'Test Connection'; + }, 2000); + } } save(): void { - // Vector DB settings are auto-saved on input change - // No need to save on dialog save + this.saveSettings(); } cleanup(): void { diff --git a/front_end/panels/ai_chat/ui/settings/utils/styles.ts b/front_end/panels/ai_chat/ui/settings/utils/styles.ts index 3820374053..acabc0a342 100644 --- a/front_end/panels/ai_chat/ui/settings/utils/styles.ts +++ b/front_end/panels/ai_chat/ui/settings/utils/styles.ts @@ -8,55 +8,64 @@ export function getSettingsStyles(): string { return ` .settings-dialog { - color: var(--color-text-primary); - background-color: var(--color-background); + color: hsl(var(--foreground)); + background-color: hsl(var(--background)); } .settings-content { padding: 0; max-width: 100%; + background: hsl(220 25% 97%); /* Slate 50 - #F7F9FC equivalent */ } .settings-header { display: flex; justify-content: space-between; align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--color-details-hairline); + padding: 16px 24px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--background)); } .settings-title { - font-size: 18px; - font-weight: 500; + font-size: 24px; + font-weight: 600; margin: 0; - color: var(--color-text-primary); + color: hsl(var(--foreground)); } .settings-close-button { background: none; border: none; - font-size: 20px; + font-size: 24px; cursor: pointer; - color: var(--color-text-secondary); + color: hsl(var(--muted-foreground)); padding: 4px 8px; + line-height: 1; } .settings-close-button:hover { - color: var(--color-text-primary); + color: hsl(var(--foreground)); } .provider-selection-section { - padding: 16px 20px; - border-bottom: 1px solid var(--color-details-hairline); + padding: 24px; + margin: 16px 24px; + border-radius: 6px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); } .provider-select { - margin-top: 8px; + margin-top: 12px; } .provider-content { - padding: 16px 20px; - border-bottom: 1px solid var(--color-details-hairline); + padding: 24px; + margin: 0 24px 16px; + border-radius: 6px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); } .settings-section { @@ -67,44 +76,44 @@ export function getSettingsStyles(): string { font-size: 16px; font-weight: 500; margin: 0 0 12px 0; - color: var(--color-text-primary); + color: hsl(var(--foreground)); } .settings-label { font-size: 14px; font-weight: 500; margin-bottom: 6px; - color: var(--color-text-primary); + color: hsl(var(--foreground)); } .settings-hint { font-size: 12px; - color: var(--color-text-secondary); + color: hsl(var(--muted-foreground)); margin-bottom: 8px; } .settings-description { font-size: 14px; - color: var(--color-text-secondary); + color: hsl(var(--muted-foreground)); margin: 4px 0 12px 0; } .settings-input, .settings-select { width: 100%; padding: 8px 12px; - border-radius: 4px; - border: 1px solid var(--color-details-hairline); - background-color: var(--color-background-elevation-2); - color: var(--color-text-primary); + border-radius: 6px; + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); font-size: 14px; box-sizing: border-box; - height: 32px; + height: 40px; } .settings-input:focus, .settings-select:focus { outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 1px var(--color-primary-opacity-30); + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); } .settings-status { @@ -255,8 +264,9 @@ export function getSettingsStyles(): string { justify-content: flex-end; align-items: center; gap: 12px; - padding: 16px 20px; - border-top: 1px solid var(--color-details-hairline); + padding: 16px 24px; + border-top: 1px solid hsl(var(--border)); + background: hsl(var(--background)); } .save-status { @@ -267,18 +277,22 @@ export function getSettingsStyles(): string { .settings-button { padding: 8px 16px; - border-radius: 4px; + height: 40px; + border-radius: 6px; font-size: 14px; cursor: pointer; transition: all 0.2s; font-family: inherit; - background-color: var(--color-background-elevation-1); - border: 1px solid var(--color-details-hairline); - color: var(--color-text-primary); + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + color: hsl(var(--foreground)); + display: inline-flex; + align-items: center; + justify-content: center; } .settings-button:hover { - background-color: var(--color-background-elevation-2); + background-color: hsl(var(--secondary)); } .settings-button:disabled { @@ -289,13 +303,13 @@ export function getSettingsStyles(): string { /* Add button styling */ .add-button { min-width: 60px; - border-radius: 4px; - font-size: 12px; - background-color: var(--color-background-elevation-1); + border-radius: 6px; + font-size: 14px; + background-color: hsl(var(--background)); } .add-button:hover { - background-color: var(--color-background-elevation-2); + background-color: hsl(var(--secondary)); } /* Icon button styling */ @@ -333,26 +347,32 @@ export function getSettingsStyles(): string { justify-content: center; } - /* Cancel button */ + /* Cancel button - Outline variant */ .cancel-button { - background-color: var(--color-background-elevation-1); - border: 1px solid var(--color-details-hairline); - color: var(--color-text-primary); + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + color: hsl(var(--foreground)); + height: 40px; + padding: 8px 16px; + border-radius: 6px; } .cancel-button:hover { - background-color: var(--color-background-elevation-2); + background-color: hsl(var(--secondary)); } - /* Save button */ + /* Save button - Primary variant */ .save-button { - background-color: var(--color-primary); - border: 1px solid var(--color-primary); - color: white; + background-color: hsl(var(--primary)); + border: 1px solid hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + height: 40px; + padding: 8px 16px; + border-radius: 6px; } .save-button:hover { - background-color: var(--color-primary-variant); + background-color: hsl(var(--primary) / 0.9); } .clear-button { diff --git a/front_end/panels/ai_chat/ui/version/VersionBanner.ts b/front_end/panels/ai_chat/ui/version/VersionBanner.ts index 52bf18a2d1..e025f989dd 100644 --- a/front_end/panels/ai_chat/ui/version/VersionBanner.ts +++ b/front_end/panels/ai_chat/ui/version/VersionBanner.ts @@ -12,7 +12,9 @@ export interface VersionInfo { latestVersion: string; releaseUrl: string; isUpda @customElement('ai-version-banner') export class VersionBanner extends HTMLElement { static readonly litTagName = Lit.StaticHtml.literal`ai-version-banner`; - readonly #shadow = this.attachShadow({mode: 'open'}); + // Use Light DOM + // readonly #shadow = this.attachShadow({mode: 'open'}); + readonly #shadow = this; // Manual properties #info: VersionInfo | null = null; @@ -32,7 +34,7 @@ export class VersionBanner extends HTMLElement { const info = this.#info; Lit.render(html`