diff --git a/browser/ui/webui/ai_chat/ai_chat_ui.cc b/browser/ui/webui/ai_chat/ai_chat_ui.cc index dcc5d05c85d3..658241b6f6db 100644 --- a/browser/ui/webui/ai_chat/ai_chat_ui.cc +++ b/browser/ui/webui/ai_chat/ai_chat_ui.cc @@ -99,6 +99,9 @@ AIChatUI::AIChatUI(content::WebUI* web_ui) untrusted_source->OverrideContentSecurityPolicy( network::mojom::CSPDirectiveName::FontSrc, "font-src 'self' data: chrome-untrusted://resources;"); + + untrusted_source->OverrideContentSecurityPolicy( + network::mojom::CSPDirectiveName::TrustedTypes, "trusted-types default;"); } AIChatUI::~AIChatUI() = default; diff --git a/components/ai_chat/resources/page/chat_ui.tsx b/components/ai_chat/resources/page/chat_ui.tsx index b37dff1540b7..c5c05f86ca20 100644 --- a/components/ai_chat/resources/page/chat_ui.tsx +++ b/components/ai_chat/resources/page/chat_ui.tsx @@ -11,6 +11,7 @@ import { setIconBasePath } from '@brave/leo/react/icon' import '$web-components/app.global.scss' import '@brave/leo/tokens/css/variables.css' +import '$web-common/defaultTrustedTypesPolicy' import { loadTimeData } from '$web-common/loadTimeData' import BraveCoreThemeProvider from '$web-common/BraveCoreThemeProvider' import Main from './components/main' diff --git a/components/ai_chat/resources/page/components/code_block/index.tsx b/components/ai_chat/resources/page/components/code_block/index.tsx new file mode 100644 index 000000000000..6e62013324d1 --- /dev/null +++ b/components/ai_chat/resources/page/components/code_block/index.tsx @@ -0,0 +1,81 @@ +/* Copyright (c) 2023 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import * as React from 'react' + +import styles from './style.module.scss' +import Button from '@brave/leo/react/button' +import Icon from '@brave/leo/react/icon' +import { Light as SyntaxHighlighter } from 'react-syntax-highlighter' +import hljsStyle from 'react-syntax-highlighter/dist/esm/styles/hljs/ir-black' +import cpp from 'react-syntax-highlighter/dist/esm/languages/hljs/cpp' +import javascript from 'react-syntax-highlighter/dist/esm/languages/hljs/javascript' +import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python' +import json from 'react-syntax-highlighter/dist/esm/languages/hljs/json' + +SyntaxHighlighter.registerLanguage('cpp', cpp) +SyntaxHighlighter.registerLanguage('javascript', javascript) +SyntaxHighlighter.registerLanguage('python', python) +SyntaxHighlighter.registerLanguage('json', json) + +interface CodeInlineProps { + code: string +} +interface CodeBlockProps { + code: string + lang: string +} + +function Inline(props: CodeInlineProps) { + return ( + + + {props.code} + + + ) +} + +function Block(props: CodeBlockProps) { + const [hasCopied, setHasCopied] = React.useState(false) + + const handleCopy = () => { + navigator.clipboard.writeText(props.code).then(() => { + setHasCopied(true) + setTimeout(() => setHasCopied(false), 1000) + }) + } + + return ( +
+
+
{props.lang}
+ +
+ + {props.code} + +
+ ) +} + +export default { + Inline, + Block +} diff --git a/components/ai_chat/resources/page/components/code_block/style.module.scss b/components/ai_chat/resources/page/components/code_block/style.module.scss new file mode 100644 index 000000000000..7343b7e92b88 --- /dev/null +++ b/components/ai_chat/resources/page/components/code_block/style.module.scss @@ -0,0 +1,37 @@ +// Copyright (c) 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// you can obtain one at https://mozilla.org/MPL/2.0/. + +.container { + overflow: auto; + background: var(--leo-color-page-background); + border: 1px solid var(--leo-color-divider-subtle); + border-radius: 8px; + + pre, + code { + white-space: pre-wrap; + margin: 0; + } + + pre { + padding: var(--leo-spacing-xl); + } + + code { + padding: var(--leo-spacing-s); + } +} + +.toolbar { + background: var(--leo-color-container-background); + padding: var(--leo-spacing-m) 16px var(--leo-spacing-m) var(--leo-spacing-2xl); + display: flex; + align-items: center; + justify-content: space-between; + + leo-button { + max-width: max-content; + } +} diff --git a/components/ai_chat/resources/page/components/conversation_list/index.tsx b/components/ai_chat/resources/page/components/conversation_list/index.tsx index ef4adaa362e8..4386e5fb9edc 100644 --- a/components/ai_chat/resources/page/components/conversation_list/index.tsx +++ b/components/ai_chat/resources/page/components/conversation_list/index.tsx @@ -15,15 +15,54 @@ import ContextMenuAssistant from '../context_menu_assistant' import { getLocale } from '$web-common/locale' import SiteTitle from '../site_title' +const CodeBlock = React.lazy(async () => ({ default: (await import('../code_block')).default.Block })) +const CodeInline = React.lazy(async () => ({ default: (await import('../code_block')).default.Inline })) + +// Capture markdown-style code blocks and inline code. +// It captures: +// 1. Multiline code blocks with optional language specifiers (```lang\n...code...```). +// 2. Inline code segments (`code`). +// 3. Regular text outside of code segments. +const codeFormatRegexp = /```([^\n`]+)?\n?([\s\S]*?)```|`(.*?)`|([^`]+)/gs + const SUGGESTION_STATUS_SHOW_BUTTON: mojom.SuggestionGenerationStatus[] = [ mojom.SuggestionGenerationStatus.CanGenerate, mojom.SuggestionGenerationStatus.IsGenerating ] -function ConversationList() { - // Scroll the last conversation item in to view when entries are added. - const lastConversationEntryElementRef = React.useRef(null) +interface ConversationListProps { + onLastElementHeightChange: () => void +} + +interface FormattedTextProps { + text: string +} + +function FormattedTextRenderer(props: FormattedTextProps): JSX.Element { + const nodes = React.useMemo(() => { + const formattedNodes = Array.from(props.text.matchAll(codeFormatRegexp)).map((match: any) => { + if (match[0].substring(0,3).includes('```')) { + return ( + + ) + } else if (match[0].substring(0,1).includes('`')) { + return ( + + + + ) + } else { + return match[0] + } + }) + + return <>{formattedNodes} + }, [props.text]) + + return nodes +} +function ConversationList(props: ConversationListProps) { const context = React.useContext(DataContext) const { isGenerating, @@ -41,26 +80,17 @@ function ConversationList() { suggestedQuestions.length > 0 || SUGGESTION_STATUS_SHOW_BUTTON.includes(context.suggestionStatus)) - React.useEffect(() => { - if (!conversationHistory.length && !isGenerating) { - return - } - - if (!lastConversationEntryElementRef.current) { - console.error('Conversation entry element did not exist when expected') - } else { - lastConversationEntryElementRef.current.scrollIntoView(false) - } - }, [ - conversationHistory.length, - isGenerating, - lastConversationEntryElementRef.current?.clientHeight - ]) - const handleQuestionSubmit = (question: string) => { getPageHandlerInstance().pageHandler.submitHumanConversationEntry(question) } + const lastEntryElementRef = React.useRef(null) + + React.useEffect(() => { + if (!lastEntryElementRef.current) return + props.onLastElementHeightChange() + }, [conversationHistory.length, lastEntryElementRef.current?.clientHeight]) + return ( <>
@@ -84,7 +114,7 @@ function ConversationList() { return (
{isAIAssistant && ( @@ -100,8 +130,10 @@ function ConversationList() {
-
- {turn.text} +
+ {} {isLoading && } {showSiteTitle &&
}
diff --git a/components/ai_chat/resources/page/components/input_box/index.tsx b/components/ai_chat/resources/page/components/input_box/index.tsx index 65a4e49f61e1..59f736e67cf5 100644 --- a/components/ai_chat/resources/page/components/input_box/index.tsx +++ b/components/ai_chat/resources/page/components/input_box/index.tsx @@ -8,6 +8,7 @@ import * as React from 'react' import classnames from 'classnames' import { getLocale } from '$web-common/locale' import Icon from '@brave/leo/react/icon' +import Button from '@brave/leo/react/button' import styles from './style.module.scss' import DataContext from '../../state/context' @@ -36,7 +37,7 @@ function InputBox () { setInputText('') } - const handleSubmit = (e: React.MouseEvent) => { + const handleSubmit = (e: CustomEvent) => { e.preventDefault() submitInputTextToAPI() } @@ -52,37 +53,40 @@ function InputBox () { } return ( -
-
-
-