Skip to content

Commit

Permalink
[Uplift 1.62.x] AI chat issues cr121 1.62.x (#21629)
Browse files Browse the repository at this point in the history
* aichat: input is growable (#21124)

* aichat: scroll is interruptable (#21235)

* aichat: model maker text shouldnt look like a link (#21220)

* aichat: code formatting (#21342)

* make claude output formatted code (#21599)
  • Loading branch information
nullhook authored Jan 19, 2024
1 parent 2bd1244 commit 7b1aeb7
Show file tree
Hide file tree
Showing 16 changed files with 707 additions and 103 deletions.
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

0 comments on commit 7b1aeb7

Please sign in to comment.