diff --git a/browser/data-browser/package.json b/browser/data-browser/package.json index 8eee76e56..dacb6e21b 100644 --- a/browser/data-browser/package.json +++ b/browser/data-browser/package.json @@ -14,19 +14,23 @@ "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/react": "^1.1.1", "@emotion/is-prop-valid": "^1.3.1", + "@modelcontextprotocol/sdk": "^1.6.1", + "@openrouter/ai-sdk-provider": "^0.4.3", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-tabs": "^1.1.1", "@tanstack/react-router": "^1.95.1", - "@tiptap/extension-image": "^2.9.1", - "@tiptap/extension-link": "^2.9.1", - "@tiptap/extension-placeholder": "^2.9.1", - "@tiptap/extension-typography": "^2.9.1", - "@tiptap/pm": "^2.9.1", - "@tiptap/react": "^2.9.1", - "@tiptap/starter-kit": "^2.9.1", - "@tiptap/suggestion": "^2.9.1", + "@tiptap/extension-image": "^2.11.7", + "@tiptap/extension-link": "^2.11.7", + "@tiptap/extension-mention": "^2.11.7", + "@tiptap/extension-placeholder": "^2.11.7", + "@tiptap/extension-typography": "^2.11.7", + "@tiptap/pm": "^2.11.7", + "@tiptap/react": "^2.11.7", + "@tiptap/starter-kit": "^2.11.7", + "@tiptap/suggestion": "^2.11.7", "@tomic/react": "workspace:*", + "ai": "^4.1.61", "emoji-mart": "^5.6.0", "polished": "^4.3.1", "prismjs": "^1.29.0", @@ -50,7 +54,8 @@ "styled-components": "^6.1.13", "stylis": "4.3.0", "tippy.js": "^6.3.7", - "tiptap-markdown": "^0.8.10" + "tiptap-markdown": "^0.8.10", + "zod": "^3.24.2" }, "devDependencies": { "@tanstack/router-devtools": "^1.95.1", diff --git a/browser/data-browser/src/chunks/MarkdownEditor/AIChatInput/AsyncAIChatInput.tsx b/browser/data-browser/src/chunks/MarkdownEditor/AIChatInput/AsyncAIChatInput.tsx new file mode 100644 index 000000000..9a2d4850f --- /dev/null +++ b/browser/data-browser/src/chunks/MarkdownEditor/AIChatInput/AsyncAIChatInput.tsx @@ -0,0 +1,154 @@ +import { EditorContent, useEditor, type JSONContent } from '@tiptap/react'; +import { styled } from 'styled-components'; +import StarterKit from '@tiptap/starter-kit'; +import Mention from '@tiptap/extension-mention'; +import { TiptapContextProvider } from '../TiptapContext'; +import { EditorWrapperBase } from '../EditorWrapperBase'; +import { searchSuggestionBuilder } from './resourceSuggestions'; +import { useEffect, useRef, useState } from 'react'; +import { EditorEvents } from '../EditorEvents'; +import { Markdown } from 'tiptap-markdown'; +import { useStore } from '@tomic/react'; +import { useSettings } from '../../../helpers/AppSettings'; +import type { Node } from '@tiptap/pm/model'; +import Placeholder from '@tiptap/extension-placeholder'; + +// Modify the Mention extension to allow serializing to markdown. +const SerializableMention = Mention.extend({ + addStorage() { + return { + markdown: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + serialize(state: any, node: Node) { + state.write('@' + (node.attrs.label || '')); + state.renderContent(node); + state.flushClose(1); + }, + }, + }; + }, +}); + +interface AsyncAIChatInputProps { + onMentionUpdate: (mentions: string[]) => void; + onChange: (markdown: string) => void; + onSubmit: () => void; +} + +const AsyncAIChatInput: React.FC = ({ + onMentionUpdate, + onChange, + onSubmit, +}) => { + const store = useStore(); + const { drive } = useSettings(); + const [markdown, setMarkdown] = useState(''); + const markdownRef = useRef(markdown); + const onSubmitRef = useRef(onSubmit); + + const editor = useEditor({ + extensions: [ + Markdown.configure({ + html: true, + }), + StarterKit.extend({ + addKeyboardShortcuts() { + return { + Enter: () => { + // Check if the cursor is in a code block, if so allow the user to press enter. + // Pressing shift + enter will exit the code block. + if ('language' in this.editor.getAttributes('codeBlock')) { + return false; + } + + // The content has to be read from a ref because this callback is not updated often leading to stale content. + onSubmitRef.current(); + setMarkdown(''); + this.editor.commands.clearContent(); + + return true; + }, + }; + }, + }).configure({ + blockquote: false, + bulletList: false, + orderedList: false, + // paragraph: false, + heading: false, + listItem: false, + horizontalRule: false, + bold: false, + strike: false, + italic: false, + }), + SerializableMention.configure({ + HTMLAttributes: { + class: 'ai-chat-mention', + }, + suggestion: searchSuggestionBuilder(store, drive), + renderText({ options, node }) { + return `${options.suggestion.char}${node.attrs.title}`; + }, + }), + Placeholder.configure({ + placeholder: 'Ask me anything...', + }), + ], + autofocus: true, + }); + + const handleChange = (value: string) => { + setMarkdown(value); + markdownRef.current = value; + onChange(value); + + if (!editor) { + return; + } + + const mentions = digForMentions(editor.getJSON()); + onMentionUpdate(Array.from(new Set(mentions))); + }; + + useEffect(() => { + markdownRef.current = markdown; + onSubmitRef.current = onSubmit; + }, [markdown, onSubmit]); + + return ( + + + + + + + ); +}; + +export default AsyncAIChatInput; + +const EditorWrapper = styled(EditorWrapperBase)` + padding: ${p => p.theme.size(2)}; + font-size: 16px; + line-height: 1.5; + + .ai-chat-mention { + background-color: ${p => p.theme.colors.mainSelectedBg}; + color: ${p => p.theme.colors.mainSelectedFg}; + border-radius: 5px; + padding-inline: ${p => p.theme.size(1)}; + } +`; + +function digForMentions(data: JSONContent): string[] { + if (data.type === 'mention') { + return [data.attrs!.id]; + } + + if (data.content) { + return data.content.flatMap(digForMentions); + } + + return []; +} diff --git a/browser/data-browser/src/chunks/MarkdownEditor/AIChatInput/MentionList.tsx b/browser/data-browser/src/chunks/MarkdownEditor/AIChatInput/MentionList.tsx new file mode 100644 index 000000000..b664a2715 --- /dev/null +++ b/browser/data-browser/src/chunks/MarkdownEditor/AIChatInput/MentionList.tsx @@ -0,0 +1,133 @@ +import { forwardRef, useEffect, useImperativeHandle } from 'react'; +import styled from 'styled-components'; +import { getIconForClass } from '../../../helpers/iconMap'; +import { useSelectedIndex } from '../../../hooks/useSelectedIndex'; + +export type SearchSuggestion = { + id: string; + label: string; + isA: string[]; +}; +export interface MentionListProps { + items: SearchSuggestion[]; + command: (item: SearchSuggestion) => void; +} + +export interface MentionListRef { + onKeyDown: ({ event }: { event: React.KeyboardEvent }) => boolean; +} + +export const MentionList = forwardRef( + ({ items, command }, ref) => { + const { selectedIndex, onKeyDown, onMouseOver, onClick, resetIndex } = + useSelectedIndex( + items, + index => { + if (index === undefined) { + return; + } + + const item = items[index]; + + if (item) { + command(item); + } + }, + 0, + ); + + useEffect(() => resetIndex(), [items]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }) => { + onKeyDown(event); + + if (['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) { + return true; + } + + return false; + }, + })); + + return ( + + {items.length ? ( + items.map((item, index) => ( + onMouseOver(index)} + onClick={() => onClick(index)} + /> + )) + ) : ( +
No result
+ )} +
+ ); + }, +); + +MentionList.displayName = 'MentionList'; + +interface DropdownItemProps { + item: SearchSuggestion; + selected: boolean; + onClick: () => void; + onMouseOver: () => void; +} + +const DropdownItem = ({ + item, + selected, + onClick, + onMouseOver, +}: DropdownItemProps) => { + const Icon = getIconForClass(item.isA[0]); + + return ( + + ); +}; + +const DropdownMenu = styled.div` + background: ${p => p.theme.colors.bg}; + border-radius: 0.7rem; + box-shadow: ${p => p.theme.boxShadowIntense}; + display: flex; + flex-direction: column; + gap: 0.1rem; + overflow: auto; + padding: 0.4rem; + position: relative; + + button { + background: transparent; + appearance: none; + border: none; + border-radius: ${p => p.theme.radius}; + display: flex; + align-items: center; + gap: ${p => p.theme.size(1)}; + text-align: left; + width: 100%; + padding: 0.5rem; + cursor: pointer; + + &.is-selected { + background-color: ${p => p.theme.colors.mainSelectedBg}; + color: ${p => p.theme.colors.mainSelectedFg}; + } + } +`; diff --git a/browser/data-browser/src/chunks/MarkdownEditor/AIChatInput/resourceSuggestions.ts b/browser/data-browser/src/chunks/MarkdownEditor/AIChatInput/resourceSuggestions.ts new file mode 100644 index 000000000..b3288d783 --- /dev/null +++ b/browser/data-browser/src/chunks/MarkdownEditor/AIChatInput/resourceSuggestions.ts @@ -0,0 +1,93 @@ +import { ReactRenderer } from '@tiptap/react'; +import tippy, { type Instance } from 'tippy.js'; +import { + MentionList, + type MentionListProps, + type MentionListRef, + type SearchSuggestion, +} from './MentionList'; +import type { Store } from '@tomic/react'; +import type { SuggestionOptions } from '@tiptap/suggestion'; + +export const searchSuggestionBuilder = ( + store: Store, + drive: string, +): Partial => ({ + items: async ({ query }: { query: string }): Promise => { + const results = await store.search(query, { + limit: 10, + include: true, + parents: [drive], + }); + + const resultResources = await Promise.all( + results.map(subject => store.getResource(subject)), + ); + + return resultResources.map(resource => ({ + id: resource.subject, + label: resource.title, + isA: resource.getClasses(), + })); + }, + + render: () => { + let component: ReactRenderer; + let popup: Instance[]; + + return { + onStart: props => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }); + }, + + onUpdate(props) { + component.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup[0].hide(); + + return true; + } + + if (!component.ref) { + return false; + } + + // @ts-expect-error Tiptap uses a different event type from React but the core properties are the same. + return component.ref.onKeyDown(props); + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, +}); diff --git a/browser/data-browser/src/chunks/MarkdownEditor/AsyncMarkdownEditor.tsx b/browser/data-browser/src/chunks/MarkdownEditor/AsyncMarkdownEditor.tsx index 5ce5d8298..1171dc7ea 100644 --- a/browser/data-browser/src/chunks/MarkdownEditor/AsyncMarkdownEditor.tsx +++ b/browser/data-browser/src/chunks/MarkdownEditor/AsyncMarkdownEditor.tsx @@ -14,6 +14,7 @@ import { ToggleButton } from './ToggleButton'; import { SlashCommands, suggestion } from './SlashMenu/CommandsExtension'; import { ExtendedImage } from './ImagePicker'; import { transition } from '../../helpers/transition'; +import { EditorWrapperBase } from './EditorWrapperBase'; export type AsyncMarkdownEditorProps = { placeholder?: string; @@ -103,7 +104,7 @@ export default function AsyncMarkdownEditor({ return ( - + {codeMode && ( - + ); } @@ -139,61 +140,21 @@ const calcHeight = (value: string) => { return `calc(${lines * LINE_HEIGHT}em + 5px)`; }; -const EditorWrapper = styled.div<{ hideEditor: boolean }>` - position: relative; - background-color: ${p => p.theme.colors.bg}; - padding: ${p => p.theme.margin}rem; +const StyledEditorWrapper = styled(EditorWrapperBase)` + min-height: ${MIN_EDITOR_HEIGHT}; border-radius: ${p => p.theme.radius}; box-shadow: 0 0 0 1px ${p => p.theme.colors.bg2}; min-height: ${MIN_EDITOR_HEIGHT}; + padding: ${p => p.theme.size()}; ${transition('box-shadow')} &:focus-within { box-shadow: 0 0 0 2px ${p => p.theme.colors.main}; } - &:not(:focus-within) { - & .tiptap p.is-editor-empty:first-child::before { - color: ${p => p.theme.colors.textLight}; - content: attr(data-placeholder); - float: left; - height: 0; - pointer-events: none; - } - } - & .tiptap { - display: ${p => (p.hideEditor ? 'none' : 'block')}; - outline: none; width: min(100%, 75ch); min-height: ${MIN_EDITOR_HEIGHT}; - - .tiptap-image { - max-width: 100%; - height: auto; - } - - pre { - padding: 0.75rem 1rem; - background-color: ${p => p.theme.colors.bg1}; - border-radius: ${p => p.theme.radius}; - font-family: monospace; - - code { - white-space: pre; - color: inherit; - padding: 0; - background: none; - font-size: 0.8rem; - } - } - - blockquote { - margin-inline-start: 0; - border-inline-start: 3px solid ${p => p.theme.colors.textLight2}; - color: ${p => p.theme.colors.textLight}; - padding-inline-start: 1rem; - } } `; diff --git a/browser/data-browser/src/chunks/MarkdownEditor/EditorWrapperBase.tsx b/browser/data-browser/src/chunks/MarkdownEditor/EditorWrapperBase.tsx new file mode 100644 index 000000000..cee32aacb --- /dev/null +++ b/browser/data-browser/src/chunks/MarkdownEditor/EditorWrapperBase.tsx @@ -0,0 +1,49 @@ +import { styled } from 'styled-components'; + +export const EditorWrapperBase = styled.div<{ hideEditor: boolean }>` + position: relative; + background-color: ${p => p.theme.colors.bg}; + + &:not(:focus-within) { + & .tiptap p.is-editor-empty:first-child::before { + color: ${p => p.theme.colors.textLight}; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; + } + } + + & .tiptap { + display: ${p => (p.hideEditor ? 'none' : 'block')}; + outline: none; + width: min(100%, 75ch); + + .tiptap-image { + max-width: 100%; + height: auto; + } + + pre { + padding: 0.75rem 1rem; + background-color: ${p => p.theme.colors.bg1}; + border-radius: ${p => p.theme.radius}; + font-family: monospace; + + code { + white-space: pre; + color: inherit; + padding: 0; + background: none; + font-size: 0.8rem; + } + } + + blockquote { + margin-inline-start: 0; + border-inline-start: 3px solid ${p => p.theme.colors.textLight2}; + color: ${p => p.theme.colors.textLight}; + padding-inline-start: 1rem; + } + } +`; diff --git a/browser/data-browser/src/chunks/MarkdownEditor/SlashMenu/CommandsExtension.ts b/browser/data-browser/src/chunks/MarkdownEditor/SlashMenu/CommandsExtension.ts index f400647be..5146143c0 100644 --- a/browser/data-browser/src/chunks/MarkdownEditor/SlashMenu/CommandsExtension.ts +++ b/browser/data-browser/src/chunks/MarkdownEditor/SlashMenu/CommandsExtension.ts @@ -156,7 +156,7 @@ export const suggestion: Partial = { } popup = tippy('body', { - getReferenceClientRect: props.clientRect! as () => DOMRect, + getReferenceClientRect: props.clientRect as () => DOMRect, appendTo: () => document.body, content: component.element, showOnCreate: true, diff --git a/browser/data-browser/src/components/AI/AIChatMessage.tsx b/browser/data-browser/src/components/AI/AIChatMessage.tsx new file mode 100644 index 000000000..ce6162181 --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessage.tsx @@ -0,0 +1,136 @@ +import styled from 'styled-components'; +import Markdown from '../datatypes/Markdown'; +import type { CoreAssistantMessage, CoreToolMessage } from 'ai'; +import { FaCircleExclamation, FaTrash } from 'react-icons/fa6'; +import { + isAIErrorMessage, + isMessageWithContext, + type AIChatDisplayMessage, +} from './types'; +import { UserMessage, ToolResultMessage } from './AIChatMessageParts'; +import { AssistantMessage } from './AIChatMessageParts/AssistantMessage'; +import { IconButton } from '../IconButton/IconButton'; + +interface MessageProps { + message: AIChatDisplayMessage; + onDeleteMessage?: (message: AIChatDisplayMessage) => void; +} + +function isToolMessage( + message: AIChatDisplayMessage, +): message is CoreToolMessage { + return message.role === 'tool'; +} + +function isAssistantMessage( + message: AIChatDisplayMessage, +): message is CoreAssistantMessage { + return message.role === 'assistant'; +} + +export const AIChatMessage = ({ + message: messageIn, + onDeleteMessage, +}: MessageProps) => { + const [message, context] = isMessageWithContext(messageIn) + ? [messageIn.message, messageIn.context] + : [messageIn]; + + if (message.role === 'user') { + return ( + + + + ); + } + + if (isAIErrorMessage(message)) { + return ( + + + + Error + + + + ); + } + + if (isToolMessage(message)) { + return ; + } + + if (isAssistantMessage(message)) { + return ( + + + + ); + } + + return Unknown message type; +}; + +const ErrorMessageWrapper = styled.div` + border-radius: ${p => p.theme.radius}; + width: 90%; + padding: ${p => p.theme.size()}; + + background-color: ${p => (p.theme.darkMode ? '#440e0e' : '#f8dbdb')}; +`; + +const SenderName = styled.span` + display: inline-flex; + align-items: center; + gap: 1ch; + font-weight: bold; + font-size: 0.6rem; + color: ${p => p.theme.colors.textLight}; + svg { + font-size: 0.8rem; + color: ${p => p.theme.colors.textLight}; + } +`; + +const FloatingActionRow = styled.div` + position: absolute; + top: 0; + right: 0; + + display: none; +`; + +const MessageTopWrapper = styled.div` + position: relative; + display: flex; + flex-direction: column; + + &:hover { + ${FloatingActionRow} { + display: block; + } + } +`; + +const MessageActionWrapper: React.FC> = ({ + children, + message, + onDeleteMessage, +}) => { + return ( + + {onDeleteMessage && ( + + onDeleteMessage(message)} + title='Delete Message' + > + + + + )} + {children} + + ); +}; diff --git a/browser/data-browser/src/components/AI/AIChatMessageParts/AssistantMessage.tsx b/browser/data-browser/src/components/AI/AIChatMessageParts/AssistantMessage.tsx new file mode 100644 index 000000000..5f76753d8 --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessageParts/AssistantMessage.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { TOOL_NAMES } from '../useAtomicTools'; +import { + AtomicEditToolMessage, + AtomicFetchToolMessage, + AtomicSearchToolMessage, + BasicMessage, + ImageContent, + isImagePart, + ReasoningMessage, +} from './index'; +import type { CoreAssistantMessage, ToolCallPart } from 'ai'; +import styled from 'styled-components'; + +interface AssistantMessageProps { + message: CoreAssistantMessage; +} + +export const AssistantMessage: React.FC = ({ + message, +}) => { + if (message.content.length === 0) { + return null; + } + + if (typeof message.content === 'string') { + return ; + } + + return ( + <> + {message.content.map((c, index) => { + if (c.type === 'text') { + if (c.text.length === 0) { + return null; + } + + return ; + } + + if (isImagePart(c)) { + return ; + } + + if (c.type === 'tool-call') { + switch (c.toolName) { + case TOOL_NAMES.SEARCH_RESOURCE: + return ( + + ); + case TOOL_NAMES.GET_ATOMIC_RESOURCE: + return ; + case TOOL_NAMES.EDIT_ATOMIC_RESOURCE: + return ; + case TOOL_NAMES.CREATE_RESOURCE: + return ( + + Creating Resource + + ); + default: + return ( + + Using tool: {c.toolName} + + ); + } + } + + if (c.type === 'reasoning') { + return ; + } + + return null; + })} + + ); +}; + +interface ToolCallMessageProps { + toolCall: ToolCallPart; +} + +const BasicCustomToolUseMessage = ({ + toolCall, + children, +}: React.PropsWithChildren) => { + return ( + + {children} + + ); +}; + +const ToolUseMessage = styled.div` + background-color: var(--mainSelectedBg); + padding: 0.5em; + border-radius: var(--radius); + font-size: 0.7rem; + width: fit-content; + color: var(--textLight); +`; diff --git a/browser/data-browser/src/components/AI/AIChatMessageParts/AtomicEditToolMessage.tsx b/browser/data-browser/src/components/AI/AIChatMessageParts/AtomicEditToolMessage.tsx new file mode 100644 index 000000000..a78bd3d06 --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessageParts/AtomicEditToolMessage.tsx @@ -0,0 +1,67 @@ +import { useResource } from '@tomic/react'; +import { Row } from '../../Row'; +import { FaPencil } from 'react-icons/fa6'; +import styled from 'styled-components'; + +interface ToolCallMessageProps { + toolCall: { + toolCallId: string; + args: unknown; + }; +} + +function isEditArgs( + args: unknown, +): args is { property: string; subject: string } { + return ( + typeof args === 'object' && + args !== null && + 'property' in args && + 'subject' in args + ); +} + +const ClippedTitle = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 20ch; +`; + +const ToolUseMessage = styled.div` + background-color: ${p => p.theme.colors.mainSelectedBg}; + padding: ${p => p.theme.size(2)}; + border-radius: ${p => p.theme.radius}; + font-size: 0.7rem; + width: fit-content; + span { + color: ${p => p.theme.colors.textLight}; + } +`; + +export const AtomicEditToolMessage = ({ toolCall }: ToolCallMessageProps) => { + let propertyId: string | undefined = undefined; + let subjectId: string | undefined = undefined; + + if (isEditArgs(toolCall.args)) { + propertyId = toolCall.args.property; + subjectId = toolCall.args.subject; + } + + const property = useResource(propertyId); + const resource = useResource(subjectId); + + if (!propertyId || !resource || !property) { + return null; + } + + return ( + + + + Changing {property.title} on{' '} + {resource.title} + + + ); +}; diff --git a/browser/data-browser/src/components/AI/AIChatMessageParts/AtomicFetchToolMessage.tsx b/browser/data-browser/src/components/AI/AIChatMessageParts/AtomicFetchToolMessage.tsx new file mode 100644 index 000000000..422852a2c --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessageParts/AtomicFetchToolMessage.tsx @@ -0,0 +1,46 @@ +import { useResources } from '@tomic/react'; +import { Row } from '../../Row'; +import styled from 'styled-components'; + +interface ToolCallMessageProps { + toolCall: { + toolCallId: string; + args: unknown; + }; +} + +function isFetchArgs(args: unknown): args is { subjects: string[] } { + return ( + typeof args === 'object' && + args !== null && + 'subjects' in args && + Array.isArray((args as { subjects?: unknown }).subjects) + ); +} + +const SubtleToolUseMessage = styled.div` + color: ${p => p.theme.colors.textLight}; + font-size: 0.7rem; + width: fit-content; +`; + +export const AtomicFetchToolMessage = ({ toolCall }: ToolCallMessageProps) => { + const subjects = isFetchArgs(toolCall.args) ? toolCall.args.subjects : []; + const resources = useResources(subjects); + + return ( + <> + {Array.from(resources.values()).map(resource => ( + + + Reading + + {resource.title.slice(0, 20)} + {resource.title.length > 20 ? '...' : ''} + + + + ))} + + ); +}; diff --git a/browser/data-browser/src/components/AI/AIChatMessageParts/AtomicSearchToolMessage.tsx b/browser/data-browser/src/components/AI/AIChatMessageParts/AtomicSearchToolMessage.tsx new file mode 100644 index 000000000..652f0838a --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessageParts/AtomicSearchToolMessage.tsx @@ -0,0 +1,45 @@ +import { Row } from '../../Row'; +import { FaMagnifyingGlass } from 'react-icons/fa6'; +import styled from 'styled-components'; + +interface ToolCallMessageProps { + toolCall: { + toolCallId: string; + args: unknown; + }; +} + +function isSearchArgs(args: unknown): args is { query: string } { + return ( + typeof args === 'object' && + args !== null && + 'query' in args && + typeof (args as { query?: unknown }).query === 'string' + ); +} + +const ToolUseMessage = styled.div` + background-color: ${p => p.theme.colors.mainSelectedBg}; + padding: ${p => p.theme.size(2)}; + border-radius: ${p => p.theme.radius}; + font-size: 0.7rem; + width: fit-content; + span { + color: ${p => p.theme.colors.textLight}; + } +`; + +export const AtomicSearchToolMessage = ({ toolCall }: ToolCallMessageProps) => { + const query = isSearchArgs(toolCall.args) ? toolCall.args.query : ''; + + return ( + + + +
+ Searching for {query} +
+
+
+ ); +}; diff --git a/browser/data-browser/src/components/AI/AIChatMessageParts/BasicMessage.tsx b/browser/data-browser/src/components/AI/AIChatMessageParts/BasicMessage.tsx new file mode 100644 index 000000000..e28c5a38c --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessageParts/BasicMessage.tsx @@ -0,0 +1,16 @@ +import Markdown from '../../datatypes/Markdown'; +import styled from 'styled-components'; + +const MessageWrapper = styled.div` + border-radius: ${p => p.theme.radius}; + width: 90%; + padding-block: ${p => p.theme.size()}; +`; + +export const BasicMessage = ({ text }: { text: string }) => { + return ( + + + + ); +}; diff --git a/browser/data-browser/src/components/AI/AIChatMessageParts/ImageContent.tsx b/browser/data-browser/src/components/AI/AIChatMessageParts/ImageContent.tsx new file mode 100644 index 000000000..8fefc939c --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessageParts/ImageContent.tsx @@ -0,0 +1,38 @@ +import type { ImagePart } from 'ai'; +import { styled } from 'styled-components'; + +interface ImageContentProps { + imagePart: ImagePart; +} + +export function isImagePart(part: unknown): part is ImagePart { + return ( + !!part && + typeof part === 'object' && + 'type' in part && + part.type === 'image' + ); +} + +export const ImageContent: React.FC = ({ imagePart }) => { + const imageSrc = + typeof imagePart.image === 'string' + ? imagePart.image + : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; // Fallback 1x1 transparent image + + return ( + + + + ); +}; + +const MessageImageWrapper = styled.div` + margin: ${p => p.theme.size(1)} 0; + + img { + max-width: 100%; + max-height: 300px; + border-radius: ${p => p.theme.radius}; + } +`; diff --git a/browser/data-browser/src/components/AI/AIChatMessageParts/ReasoningMessage.tsx b/browser/data-browser/src/components/AI/AIChatMessageParts/ReasoningMessage.tsx new file mode 100644 index 000000000..3c9421f1e --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessageParts/ReasoningMessage.tsx @@ -0,0 +1,19 @@ +import Markdown from '../../datatypes/Markdown'; +import styled from 'styled-components'; + +const ReasoningMessageWrapper = styled.div` + padding: ${p => p.theme.size()}; + color: ${p => p.theme.colors.textLight}; + font-style: italic; + max-height: 10rem; + overflow-y: auto; + border-radius: ${p => p.theme.radius}; + width: 90%; +`; + +export const ReasoningMessage = ({ text }: { text: string }) => ( + + Thinking... + + +); diff --git a/browser/data-browser/src/components/AI/AIChatMessageParts/ToolResultMessage.tsx b/browser/data-browser/src/components/AI/AIChatMessageParts/ToolResultMessage.tsx new file mode 100644 index 000000000..4a72581bb --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessageParts/ToolResultMessage.tsx @@ -0,0 +1,94 @@ +import type { CoreToolMessage, ToolResultPart } from 'ai'; +import { Details } from '../../Details'; +import { ResourceInline } from '../../../views/ResourceInline'; +import { styled } from 'styled-components'; +import { TOOL_NAMES } from '../useAtomicTools'; + +interface ToolResultMessageProps { + message: CoreToolMessage; +} + +export const ToolResultMessage: React.FC = ({ + message, +}) => { + return message.content.map(c => { + const key = `result-${c.toolCallId}`; + + if (c.toolName === TOOL_NAMES.SEARCH_RESOURCE) { + return ; + } + + if (c.toolName === TOOL_NAMES.SHOW_SVG) { + if ( + typeof c.result === 'object' && + c.result !== null && + 'data' in c.result && + typeof (c.result as { data: unknown }).data === 'string' + ) { + return ( +
+ ); + } + + return null; + } + + let result; + + if (typeof c.result === 'string') { + result = c.result; + } else { + result = JSON.stringify(c.result, null, 2); + } + + return ( +
+
+ + {result} + +
+
+ ); + }); +}; + +interface ToolResultPartProps { + toolResultPart: ToolResultPart; +} + +const SearchResultMessage = ({ toolResultPart }: ToolResultPartProps) => { + const subjects = Object.keys( + toolResultPart.result as Record, + ); + + return ( +
+
+
    + {subjects.map(resource => ( +
  1. + +
  2. + ))} +
+
+
+ ); +}; + +const StyledPre = styled.pre` + background-color: ${p => p.theme.colors.bg}; + padding: ${p => p.theme.size()}; + border-radius: ${p => p.theme.radius}; + overflow-x: auto; + code { + font-family: Monaco, monospace; + font-size: 0.8em; + } +`; diff --git a/browser/data-browser/src/components/AI/AIChatMessageParts/UserMessage.tsx b/browser/data-browser/src/components/AI/AIChatMessageParts/UserMessage.tsx new file mode 100644 index 000000000..9f6446ac0 --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessageParts/UserMessage.tsx @@ -0,0 +1,117 @@ +import type { CoreUserMessage, FilePart } from 'ai'; +import { type AIResourceMessageContext } from '../types'; +import { styled } from 'styled-components'; +import { FaFile } from 'react-icons/fa6'; +import Markdown from '../../datatypes/Markdown'; +import { Row } from '../../Row'; +import { MessageContextItem } from '../MessageContextItem'; +import { ImageContent, isImagePart } from './ImageContent'; + +interface UserMessageProps { + message: CoreUserMessage; + context?: AIResourceMessageContext[]; +} + +function isFilePart(part: unknown): part is FilePart { + return ( + !!part && typeof part === 'object' && 'type' in part && part.type === 'file' + ); +} + +export const UserMessage: React.FC = ({ + message, + context, +}) => { + return ( + + You + {context && ( + + {context.map(item => ( + + ))} + + )} + {typeof message.content === 'string' ? ( + + ) : Array.isArray(message.content) ? ( + <> + {message.content.map((part, index) => { + if (typeof part === 'string') { + return ; + } else if (isImagePart(part)) { + return ; + } else if (isFilePart(part)) { + return ; + } else if (part.type === 'text') { + return ( + + ); + } else { + return null; // Handle other part types if needed + } + })} + + ) : null} + + ); +}; + +const RenderUserContent = ({ text }: { text: string }) => { + const extractedText = text.match(/([\s\S]*?)<\/context>/); + + if (extractedText) { + return ; + } + + return ; +}; + +const ContextItemRow = styled(Row)` + margin-block-end: ${p => p.theme.size(2)}; +`; + +const FileContent = () => { + // Display filename/title based on what's available + // FilePart has data and mimeType properties + return ( + + + Attached File + + ); +}; + +const MessageWrapper = styled.div` + border-radius: ${p => p.theme.radius}; + width: 90%; + padding-block: ${p => p.theme.size()}; + + &:hover { + background-color: ${p => p.theme.colors.bg}; + } +`; + +const UserMessageWrapper = styled(MessageWrapper)` + padding: ${p => p.theme.size()}; + background-color: ${p => p.theme.colors.bg}; + align-self: flex-end; + box-shadow: ${p => p.theme.boxShadow}; +`; + +const SenderName = styled.span` + display: inline-flex; + align-items: center; + gap: 1ch; + font-weight: bold; + font-size: 0.6rem; + color: ${p => p.theme.colors.textLight}; +`; + +const MessageFileWrapper = styled.div` + margin: ${p => p.theme.size(1)} 0; + + background-color: ${p => p.theme.colors.bg1}; + padding: ${p => p.theme.size(1)}; + border-radius: ${p => p.theme.radius}; +`; diff --git a/browser/data-browser/src/components/AI/AIChatMessageParts/index.ts b/browser/data-browser/src/components/AI/AIChatMessageParts/index.ts new file mode 100644 index 000000000..a9340a0c1 --- /dev/null +++ b/browser/data-browser/src/components/AI/AIChatMessageParts/index.ts @@ -0,0 +1,8 @@ +export { AtomicEditToolMessage } from './AtomicEditToolMessage'; +export { AtomicFetchToolMessage } from './AtomicFetchToolMessage'; +export { AtomicSearchToolMessage } from './AtomicSearchToolMessage'; +export { BasicMessage } from './BasicMessage'; +export { ReasoningMessage } from './ReasoningMessage'; +export { UserMessage } from './UserMessage'; +export { ImageContent, isImagePart } from './ImageContent'; +export { ToolResultMessage } from './ToolResultMessage'; diff --git a/browser/data-browser/src/components/AI/AIIcon.tsx b/browser/data-browser/src/components/AI/AIIcon.tsx new file mode 100644 index 000000000..6ec84cb3b --- /dev/null +++ b/browser/data-browser/src/components/AI/AIIcon.tsx @@ -0,0 +1,34 @@ +import type { IconType } from 'react-icons'; +import { styled } from 'styled-components'; + +export const AIIcon: IconType = ({ color }) => { + return ( + + + + ); +}; + +const BWIconWrapper = styled.svg<{ color?: string }>` + color: ${p => p.color || 'inherit'}; + width: 1em; + height: 1em; + fill-rule: nonzero; + clip-rule: evenodd; + stroke-linecap: round; + stroke-linejoin: round; + fill: currentColor; +`; diff --git a/browser/data-browser/src/components/AI/AISettings.tsx b/browser/data-browser/src/components/AI/AISettings.tsx new file mode 100644 index 000000000..3c0529232 --- /dev/null +++ b/browser/data-browser/src/components/AI/AISettings.tsx @@ -0,0 +1,105 @@ +import * as React from 'react'; +import { Column } from '../Row'; +import { Checkbox, CheckboxLabel } from '../forms/Checkbox'; +import { InputStyled, InputWrapper } from '../forms/InputStyles'; +import { MCPServersManager } from '../MCPServersManager'; +import styled from 'styled-components'; +import { transition } from '../../helpers/transition'; +import { useSettings } from '../../helpers/AppSettings'; +import { useEffect, useState } from 'react'; + +interface CreditUsage { + total: number; + used: number; +} + +const AISettings: React.FC = () => { + const { + enableAI, + setEnableAI, + openRouterApiKey, + setOpenRouterApiKey, + mcpServers, + setMcpServers, + showTokenUsage, + setShowTokenUsage, + } = useSettings(); + + const [creditUsage, setCreditUsage] = useState(); + + useEffect(() => { + if (!openRouterApiKey) { + return; + } + + fetch('https://openrouter.ai/api/v1/credits', { + headers: { + Authorization: `Bearer ${openRouterApiKey}`, + }, + }) + .then(res => res.json()) + .then(data => { + setCreditUsage({ + total: data.data.total_credits, + used: data.data.total_usage, + }); + }); + }, [openRouterApiKey]); + + return ( + <> + AI + + Enable AI + Features + + + + + + Show token usage in chats + + MCP Servers + + + + ); +}; + +const Heading = styled.h3` + font-size: 1em; + margin: 0; + margin-top: 1rem; +`; + +const ConditionalSettings = styled(Column)<{ enabled: boolean }>` + opacity: ${p => (p.enabled ? 1 : 0.3)}; + pointer-events: ${p => (p.enabled ? 'auto' : 'none')}; + touch-action: ${p => (p.enabled ? 'auto' : 'none')}; + ${transition('opacity')} +`; + +const CreditUsage = styled.div` + font-size: 0.8rem; + color: ${p => p.theme.colors.textLight}; +`; + +export default AISettings; diff --git a/browser/data-browser/src/components/AI/AISidebar.tsx b/browser/data-browser/src/components/AI/AISidebar.tsx new file mode 100644 index 000000000..451ef67e6 --- /dev/null +++ b/browser/data-browser/src/components/AI/AISidebar.tsx @@ -0,0 +1,179 @@ +import { styled } from 'styled-components'; +import { SimpleAIChat } from './SimpleAIChat'; +import React, { useEffect, useRef, useState } from 'react'; +import { newContextItem, useAISidebar } from './AISidebarContext'; +import type { AIChatDisplayMessage } from './types'; +import { useCurrentSubject } from '../../helpers/useCurrentSubject'; +import { FaFloppyDisk, FaPlus, FaXmark } from 'react-icons/fa6'; +import { IconButton } from '../IconButton/IconButton'; +import { Row } from '../Row'; +import { ParentPickerDialog } from '../ParentPicker/ParentPickerDialog'; +import { ai, core, useStore, type Ai } from '@tomic/react'; +import { useGenerativeData } from './useGenerativeData'; +import { displayMessageToResource } from './chatConversionUtils'; +import { useNavigateWithTransition } from '../../hooks/useNavigateWithTransition'; +import { constructOpenURL } from '../../helpers/navigation'; + +export const AISidebar: React.FC = () => { + const store = useStore(); + const { isOpen, contextItems, setContextItems, setIsOpen } = useAISidebar(); + const [messages, setMessages] = useState([]); + const [currentSubject] = useCurrentSubject(); + const [showParentPicker, setShowParentPicker] = useState(false); + const titlePromiseRef = useRef | undefined>( + undefined, + ); + const { generateTitleFromConversation } = useGenerativeData(); + const navigate = useNavigateWithTransition(); + + const addNewMessage = (message: AIChatDisplayMessage) => { + setMessages(prev => [...prev, message]); + }; + + const handleParentSelect = async (parent: string) => { + const chatResource = await store.newResource({ + parent, + isA: ai.classes.aiChat, + propVals: { + [core.properties.name]: 'New Chat', + }, + }); + + for (const message of messages) { + const messageResource = await displayMessageToResource( + message, + chatResource, + store, + ); + + chatResource.push(ai.properties.messages, [messageResource.subject]); + messageResource.save(); + } + + if (titlePromiseRef.current) { + const name = await titlePromiseRef.current; + + if (name) { + await chatResource.set(core.properties.name, name); + } + + titlePromiseRef.current = undefined; + } + + await chatResource.save(); + + store.notifyResourceManuallyCreated(chatResource); + + setMessages([]); + navigate(constructOpenURL(chatResource.subject)); + }; + + const handleMessageDelete = (message: AIChatDisplayMessage) => { + setMessages(prev => prev.filter(m => m !== message)); + }; + + useEffect(() => { + // When the user opens the AI sidebar and the chat is completely empty, we add the current subject to the context. + if ( + isOpen && + currentSubject && + messages.length === 0 && + contextItems.length < 2 + ) { + setContextItems([ + newContextItem({ + type: 'resource', + subject: currentSubject, + }), + ]); + } + }, [isOpen, currentSubject]); + + useEffect(() => { + if (messages.length > 2 && !titlePromiseRef.current) { + titlePromiseRef.current = generateTitleFromConversation(messages); + } + }, [messages]); + + return ( + + + + + setMessages([])} + color='textLight' + style={{ alignSelf: 'flex-end' }} + > + + + Atomic Assistant + + + setShowParentPicker(true)} + disabled={messages.length < 2} + color='textLight' + style={{ alignSelf: 'flex-end' }} + > + + + { + setIsOpen(false); + }} + > + + + + + + + + ); +}; + +const SidebarContainer = styled.div` + background-color: ${p => p.theme.colors.bg}; + display: none; + transform: translateX(30rem); + width: min(30rem, 100vw); + overflow: hidden; + border-left: 1px solid ${p => p.theme.colors.bg2}; + padding: ${p => p.theme.size()}; + padding-top: 2px; + transition: + display 100ms allow-discrete, + transform 100ms ease-in-out; + + &[data-open] { + transform: translateX(0rem); + display: block; + } + + @starting-style { + transform: translateX(30rem); + display: none; + } +`; + +const Heading = styled.h2` + font-size: 1rem; + font-weight: 600; + margin-bottom: ${p => p.theme.size(2)}; +`; diff --git a/browser/data-browser/src/components/AI/AISidebarContext.tsx b/browser/data-browser/src/components/AI/AISidebarContext.tsx new file mode 100644 index 000000000..a84429173 --- /dev/null +++ b/browser/data-browser/src/components/AI/AISidebarContext.tsx @@ -0,0 +1,43 @@ +import React, { useContext, useState, createContext } from 'react'; + +import type { AIMessageContext } from './types'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; + +export const AISidebarContext = createContext<{ + isOpen: boolean; + setIsOpen: React.Dispatch>; + contextItems: AIMessageContext[]; + setContextItems: React.Dispatch>; +}>({ + isOpen: false, + setIsOpen: () => {}, + contextItems: [], + setContextItems: () => {}, +}); + +export const useAISidebar = () => { + return useContext(AISidebarContext); +}; +export const AISidebarContextProvider: React.FC = ({ + children, +}) => { + const [isOpen, setIsOpen] = useLocalStorage('atomic.aiSidebar.open', false); + const [contextItems, setContextItems] = useState([]); + + return ( + + {children} + + ); +}; + +export const newContextItem = ( + item: Omit, +): AIMessageContext => { + return { + ...item, + id: crypto.randomUUID(), + }; +}; diff --git a/browser/data-browser/src/components/AI/AgentConfig.tsx b/browser/data-browser/src/components/AI/AgentConfig.tsx new file mode 100644 index 000000000..8aeb6e325 --- /dev/null +++ b/browser/data-browser/src/components/AI/AgentConfig.tsx @@ -0,0 +1,584 @@ +import { useEffect, useState } from 'react'; +import { styled, useTheme } from 'styled-components'; +import { Row, Column } from '../Row'; +import { FaPencil, FaPlus, FaTrash, FaStar } from 'react-icons/fa6'; +import { IconButton } from '../IconButton/IconButton'; +import { ModelSelect } from './ModelSelect'; +import type { AIAgent } from './types'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + useDialog, +} from '../Dialog'; +import { useLocalStorage } from '../../hooks/useLocalStorage'; +import { Button } from '../Button'; +import { SkeletonButton } from '../SkeletonButton'; +import { useSettings } from '../../helpers/AppSettings'; +import { Checkbox, CheckboxLabel } from '../forms/Checkbox'; +import { InputWrapper, InputStyled } from '../forms/InputStyles'; +import { transition } from '../../helpers/transition'; + +// Add this formatter at the top of the file, after imports +const temperatureFormatter = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +// Helper function to generate a unique ID +const generateId = () => { + return `custom-user-agent.${Math.random().toString(36).substring(2, 11)}`; +}; + +interface AgentConfigProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedAgent: AIAgent; + onSelectAgent: (agent: AIAgent) => void; +} + +const defaultNewAgent: Omit = { + name: '', + description: '', + systemPrompt: '', + availableTools: [], + model: 'openai/gpt-4o-mini', + canReadAtomicData: false, + canWriteAtomicData: false, + temperature: 0.1, +}; + +const defaultAgents: AIAgent[] = [ + { + name: 'Atomic Data Agent', + id: 'dev.atomicdata.atomic-agent', + description: + "An agent that is specialized in helping you use AtomicServer. It takes context from what you're doing.", + systemPrompt: `You are an AI assistant in the Atomic Data Browser. Users will ask questions about their data and you will answer by looking at the data or using your own knowledge about the world. +Atomic Data uses JSON-AD, Every resource including the properties themselves have a subject (the '@id' property in the JSON-AD), this is a URL that points to the resource. +Resources are always referenced by subject so make sure you have all the subjects you need before editing or creating resources. + +Keep the following things in mind: +- If the user mentions a resource by its name and you don't know the subject, use the search-resource tool to find its subject. +- If you need details on resources referenced by another resource, use the get-atomic-resource tool. +- When talking about a resource, always wrap the title in a link using markdown. +- If you don't know the answer to the users question, try to figure it out by using the tools provided to you. +`, + availableTools: [], + model: 'openai/gpt-4o-mini', + canReadAtomicData: true, + canWriteAtomicData: true, + temperature: 0.1, + }, + { + name: 'General Agent', + id: 'dev.atomicdata.general-agent', + description: "A basic agent that doesn't have any special purpose.", + systemPrompt: ``, + availableTools: [], + model: 'openai/gpt-4.1-nano', + canReadAtomicData: false, + canWriteAtomicData: false, + temperature: 0.1, + }, +]; + +// This hook manages the agent configuration +export const useAIAgentConfig = () => { + const [agents, setAgents] = useLocalStorage( + 'atomic.ai.agents', + defaultAgents, + ); + const [autoAgentSelectEnabled, setAutoAgentSelectEnabled] = useLocalStorage( + 'atomic.ai.autoAgentSelect', + true, + ); + const [defaultAgentId, setDefaultAgentId] = useLocalStorage( + 'atomic.ai.defaultAgentId', + agents[0]?.id || '', + ); + + // Save agents to settings + const saveAgents = (newAgents: AIAgent[]) => { + setAgents(newAgents); + }; + + return { + agents: agents.length > 0 ? agents : [], + autoAgentSelectEnabled, + setAutoAgentSelectEnabled, + saveAgents, + defaultAgentId, + setDefaultAgentId, + }; +}; + +export const AgentConfig = ({ + open, + onOpenChange, + selectedAgent, + onSelectAgent, +}: AgentConfigProps) => { + const { + agents, + autoAgentSelectEnabled, + setAutoAgentSelectEnabled, + saveAgents, + defaultAgentId, + setDefaultAgentId, + } = useAIAgentConfig(); + const [editingAgent, setEditingAgent] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const theme = useTheme(); + const [dialogProps, show, _close, isOpen] = useDialog({ + bindShow: onOpenChange, + }); + + const handleSaveAgent = () => { + if (!editingAgent) return; + + const newAgents = isCreating + ? [...agents, editingAgent] + : agents.map((agent: AIAgent) => + // Use ID to identify which agent we're editing + agent.id === editingAgent.id ? editingAgent : agent, + ); + + saveAgents(newAgents); + + // If we're editing the currently selected agent or creating a new one, update selection + if (selectedAgent.id === editingAgent.id || isCreating) { + onSelectAgent(editingAgent); + } + + setEditingAgent(null); + setIsCreating(false); + }; + + const handleDeleteAgent = (agentToDelete: AIAgent) => { + if (agents.length <= 1) { + // Prevent deleting the last agent + return; + } + + const newAgents = agents.filter( + (agent: AIAgent) => agent.id !== agentToDelete.id, + ); + saveAgents(newAgents); + + // If we're deleting the currently selected agent, select the first available + if (selectedAgent.id === agentToDelete.id) { + onSelectAgent(newAgents[0]); + } + }; + + const handleCreateNewAgent = () => { + setEditingAgent({ + ...defaultNewAgent, + id: generateId(), + }); + setIsCreating(true); + }; + + const handleEditAgent = (agent: AIAgent) => { + setEditingAgent({ ...agent }); + setIsCreating(false); + }; + + const handleCancel = () => { + setEditingAgent(null); + setIsCreating(false); + }; + + useEffect(() => { + if (open) { + show(); + } + }, [open]); + + return ( + + {isOpen && ( + <> + +

Select AI Agents

+
+ + {editingAgent ? ( + + ) : ( + +
+ + + Automatic Agent Selection + +

+ Pick best agent for the job based on name, description and + available tools +

+
+ + {agents.map((agent: AIAgent) => ( + onSelectAgent(agent)} + > + + + { + e.stopPropagation(); + setDefaultAgentId(agent.id); + }} + title={ + defaultAgentId === agent.id + ? 'Default agent' + : 'Set as default' + } + edgeAlign='start' + > + + + {agent.name} + + {agent.description} + + + { + e.stopPropagation(); + handleEditAgent(agent); + }} + title='Edit agent' + > + + + { + e.stopPropagation(); + handleDeleteAgent(agent); + }} + title='Delete agent' + disabled={agents.length <= 1} + > + + + + + ))} + + + + Create New Agent + +
+ )} +
+ {editingAgent && ( + + + + + )} + + )} +
+ ); +}; + +interface AgentFormProps { + agent: AIAgent; + onChange: (agent: AIAgent) => void; +} + +const AgentForm = ({ agent, onChange }: AgentFormProps) => { + const { mcpServers } = useSettings(); + + const handleChange = ( + field: keyof AIAgent, + value: string | boolean | number, + ) => { + onChange({ + ...agent, + [field]: value, + }); + }; + + const onToggleTool = (toolId: string) => { + onChange({ + ...agent, + availableTools: agent.availableTools.includes(toolId) + ? agent.availableTools.filter(t => t !== toolId) + : [...agent.availableTools, toolId], + }); + }; + + useEffect(() => { + // Check if the agent has any tools that are not available any more. + const currentlyAvailableServers = mcpServers.map(s => s.id); + const tools = agent.availableTools.filter(tool => + currentlyAvailableServers.includes(tool), + ); + + if (tools.length !== agent.availableTools.length) { + onChange({ + ...agent, + availableTools: tools, + }); + } + }, [mcpServers]); + + const enforceToolSupport = + agent.availableTools.length > 0 || + agent.canReadAtomicData || + agent.canWriteAtomicData; + + return ( + + + + handleChange('name', e.target.value)} + placeholder='Agent name' + /> + + + + + handleChange('description', e.target.value)} + placeholder='Agent description' + /> + + + + +