Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Uplift 1.62.x] AI chat issues cr121 1.62.x #21629

Merged
merged 5 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions browser/ui/webui/ai_chat/ai_chat_ui.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions components/ai_chat/resources/page/chat_ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
81 changes: 81 additions & 0 deletions components/ai_chat/resources/page/components/code_block/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={styles.container}>
<code>
{props.code}
</code>
</span>
)
}

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 (
<div className={styles.container}>
<div className={styles.toolbar}>
<div>{props.lang}</div>
<Button
kind='plain-faint'
onClick={handleCopy}
>
<div slot="icon-before">
<Icon className={styles.icon} name={hasCopied ? 'check-circle-outline' : 'copy'} />
</div>
<div>Copy code</div>
</Button>
</div>
<SyntaxHighlighter
language={props.lang}
style={hljsStyle}
wrapLines
wrapLongLines
codeTagProps={{ style: { wordBreak: 'break-word' } }}
>
{props.code}
</SyntaxHighlighter>
</div>
)
}

export default {
Inline,
Block
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(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 (<React.Suspense fallback={'...'}>
<CodeBlock lang={match[1]} code={match[2].trim()} />
</React.Suspense>)
} else if (match[0].substring(0,1).includes('`')) {
return (
<React.Suspense fallback={'...'}>
<CodeInline code={match[3]}/>
</React.Suspense>
)
} else {
return match[0]
}
})

return <>{formattedNodes}</>
}, [props.text])

return nodes
}

function ConversationList(props: ConversationListProps) {
const context = React.useContext(DataContext)
const {
isGenerating,
Expand All @@ -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<HTMLDivElement>(null)

React.useEffect(() => {
if (!lastEntryElementRef.current) return
props.onLastElementHeightChange()
}, [conversationHistory.length, lastEntryElementRef.current?.clientHeight])

return (
<>
<div>
Expand All @@ -84,7 +114,7 @@ function ConversationList() {
return (
<div
key={id}
ref={isLastEntry ? lastConversationEntryElementRef : null}
ref={isLastEntry ? lastEntryElementRef : null}
>
<div className={turnClass}>
{isAIAssistant && (
Expand All @@ -100,8 +130,10 @@ function ConversationList() {
<div className={avatarStyles}>
<Icon name={isHuman ? 'user-circle' : 'product-brave-leo'} />
</div>
<div className={styles.message}>
{turn.text}
<div
className={styles.message}
>
{<FormattedTextRenderer text={turn.text} />}
{isLoading && <span className={styles.caret} />}
{showSiteTitle && <div className={styles.siteTitleContainer}><SiteTitle size="default" /></div>}
</div>
Expand Down
64 changes: 34 additions & 30 deletions components/ai_chat/resources/page/components/input_box/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -36,7 +37,7 @@ function InputBox () {
setInputText('')
}

const handleSubmit = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
const handleSubmit = (e: CustomEvent<any>) => {
e.preventDefault()
submitInputTextToAPI()
}
Expand All @@ -52,37 +53,40 @@ function InputBox () {
}

return (
<div className={styles.container}>
<form className={styles.form}>
<div className={styles.textareaBox}>
<textarea
className={styles.textarea}
placeholder={getLocale('placeholderLabel')}
onChange={onInputChange}
onKeyDown={onUserPressEnter}
value={inputText}
autoFocus
/>
<div className={classnames({
[styles.counterText]: true,
[styles.counterTextVisible]: isCharLimitApproaching,
[styles.counterTextError]: isCharLimitExceeded
})}>
{`${inputText.length} / ${MAX_INPUT_CHAR}`}
</div>
<form className={styles.form}>
<div
className={styles.growWrap}
data-replicated-value={inputText}
>
<textarea
placeholder={getLocale('placeholderLabel')}
onChange={onInputChange}
onKeyDown={onUserPressEnter}
value={inputText}
autoFocus
rows={1}
/>
</div>
{isCharLimitApproaching && (
<div className={classnames({
[styles.counterText]: true,
[styles.counterTextVisible]: isCharLimitApproaching,
[styles.counterTextError]: isCharLimitExceeded
})}>
{`${inputText.length} / ${MAX_INPUT_CHAR}`}
</div>
<div>
<button
className={styles.buttonSend}
onClick={handleSubmit}
disabled={context.shouldDisableUserInput}
title={getLocale('sendChatButtonLabel')}
)}
<div className={styles.actions}>
<Button
kind="plain-faint"
onClick={handleSubmit}
disabled={context.shouldDisableUserInput}
title={getLocale('sendChatButtonLabel')}
>
<Icon name='send' />
</button>
</div>
</form>
</div>
<Icon name='send' />
</Button>
</div>
</form>
)
}

Expand Down
Loading