diff --git a/catalog/app/components/Chat/Chat.tsx b/catalog/app/components/Chat/Chat.tsx
new file mode 100644
index 00000000000..29222aa5926
--- /dev/null
+++ b/catalog/app/components/Chat/Chat.tsx
@@ -0,0 +1,91 @@
+import * as React from 'react'
+import * as M from '@material-ui/core'
+import * as Lab from '@material-ui/lab'
+
+import Skeleton from 'components/Skeleton'
+import type * as AWS from 'utils/AWS'
+
+import History from './History'
+import Input from './Input'
+
+const useStyles = M.makeStyles((t) => ({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ flexGrow: 1,
+ overflow: 'hidden',
+ },
+ error: {
+ marginTop: t.spacing(2),
+ },
+ history: {
+ ...t.typography.body1,
+ maxHeight: t.spacing(70),
+ overflowY: 'auto',
+ },
+ input: {
+ marginTop: t.spacing(2),
+ },
+}))
+
+const noMessages: AWS.Bedrock.Message[] = []
+
+export function ChatSkeleton() {
+ const classes = useStyles()
+ return (
+
+
+
+
+ )
+}
+
+const Submitting = Symbol('Submitting')
+
+interface ChatProps {
+ initializing: boolean
+ history: AWS.Bedrock.History
+ onSubmit: (value: string) => Promise
+}
+
+export default function Chat({ history, onSubmit, initializing }: ChatProps) {
+ const classes = useStyles()
+
+ const [value, setValue] = React.useState('')
+ const [state, setState] = React.useState(null)
+
+ const handleSubmit = React.useCallback(async () => {
+ if (state) return
+
+ setState(Submitting)
+ try {
+ await onSubmit(value)
+ setValue('')
+ } catch (e) {
+ setState(e instanceof Error ? e : new Error('Failed to submit message'))
+ }
+ setState(null)
+ }, [state, onSubmit, value])
+
+ return (
+
+
+ {state instanceof Error && (
+
+ {state.message}
+
+ )}
+
+
+ )
+}
diff --git a/catalog/app/components/Chat/History.tsx b/catalog/app/components/Chat/History.tsx
new file mode 100644
index 00000000000..bb1ef818756
--- /dev/null
+++ b/catalog/app/components/Chat/History.tsx
@@ -0,0 +1,101 @@
+import cx from 'classnames'
+import * as React from 'react'
+import * as M from '@material-ui/core'
+
+import usePrevious from 'utils/usePrevious'
+import * as AWS from 'utils/AWS'
+
+import * as Messages from './Message'
+
+const useStyles = M.makeStyles((t) => ({
+ assistant: {
+ animation: `$show 300ms ease-out`,
+ },
+ message: {
+ '& + &': {
+ marginTop: t.spacing(2),
+ },
+ },
+ user: {
+ animation: `$slide 150ms ease-out`,
+ marginLeft: 'auto',
+ width: '60%',
+ },
+ '@keyframes slide': {
+ '0%': {
+ transform: `translateX($${t.spacing(8)}px)`,
+ },
+ '100%': {
+ transform: `translateX(0)`,
+ },
+ },
+ '@keyframes show': {
+ '0%': {
+ opacity: 0.7,
+ },
+ '100%': {
+ opacity: '1',
+ },
+ },
+}))
+
+interface HistoryProps {
+ className?: string
+ loading: boolean
+ messages: AWS.Bedrock.Message[]
+}
+
+export default function History({ className, loading, messages }: HistoryProps) {
+ const classes = useStyles()
+
+ const list = React.useMemo(
+ () => messages.filter((message) => message.role !== 'system'),
+ [messages],
+ )
+
+ const ref = React.useRef(null)
+ usePrevious(messages, (prev) => {
+ if (prev && messages.length > prev.length) {
+ ref.current?.scroll({
+ top: ref.current?.firstElementChild?.clientHeight,
+ behavior: 'smooth',
+ })
+ }
+ })
+
+ return (
+
+
+ {list.map((message, index) => {
+ switch (message.role) {
+ case 'user':
+ return (
+
+ )
+ case 'summarize':
+ return (
+
+ )
+ case 'assistant':
+ return (
+
+ )
+ }
+ })}
+ {loading &&
}
+
+
+ )
+}
diff --git a/catalog/app/components/Chat/Input.tsx b/catalog/app/components/Chat/Input.tsx
new file mode 100644
index 00000000000..bd27dfa7dec
--- /dev/null
+++ b/catalog/app/components/Chat/Input.tsx
@@ -0,0 +1,51 @@
+import * as React from 'react'
+import * as M from '@material-ui/core'
+
+interface ChatInputProps {
+ className?: string
+ disabled?: boolean
+ onChange: (value: string) => void
+ onSubmit: () => void
+ value: string
+}
+
+export default function ChatInput({
+ className,
+ disabled,
+ onChange,
+ onSubmit,
+ value,
+}: ChatInputProps) {
+ const handleSubmit = React.useCallback(
+ (event) => {
+ event.preventDefault()
+ if (!value || disabled) return
+ onSubmit()
+ },
+ [disabled, onSubmit, value],
+ )
+ return (
+
+ )
+}
diff --git a/catalog/app/components/Chat/Message.tsx b/catalog/app/components/Chat/Message.tsx
new file mode 100644
index 00000000000..390a265ae1f
--- /dev/null
+++ b/catalog/app/components/Chat/Message.tsx
@@ -0,0 +1,73 @@
+import cx from 'classnames'
+import * as React from 'react'
+import * as M from '@material-ui/core'
+import { fade } from '@material-ui/core/styles'
+
+import Markdown from 'components/Markdown'
+import Skel from 'components/Skeleton'
+
+const useSkeletonStyles = M.makeStyles((t) => ({
+ text: {
+ height: t.spacing(2),
+ '& + &': {
+ marginTop: t.spacing(1),
+ },
+ },
+}))
+
+interface SkeletonProps {
+ className?: string
+}
+
+export function Skeleton({ className }: SkeletonProps) {
+ const classes = useSkeletonStyles()
+ return (
+
+
+
+
+
+
+ )
+}
+
+interface AssistantProps {
+ className?: string
+ content: string
+}
+
+export function Assistant({ className, content }: AssistantProps) {
+ return (
+
+
+
+ )
+}
+
+const useUserStyles = M.makeStyles((t) => ({
+ root: {
+ borderRadius: t.shape.borderRadius,
+ background: t.palette.primary.main,
+ },
+ inner: {
+ padding: t.spacing(2),
+ background: fade(t.palette.background.paper, 0.9),
+ },
+}))
+
+interface UserProps {
+ className?: string
+ content: string
+}
+
+export function User({ className, content }: UserProps) {
+ const classes = useUserStyles()
+
+ return (
+
+ )
+}
diff --git a/catalog/app/components/Chat/index.ts b/catalog/app/components/Chat/index.ts
new file mode 100644
index 00000000000..ef4f2666527
--- /dev/null
+++ b/catalog/app/components/Chat/index.ts
@@ -0,0 +1,3 @@
+export { default as Input } from './Input'
+export { default as History } from './History'
+export { default, ChatSkeleton } from './Chat'
diff --git a/catalog/app/components/Markdown/Markdown.tsx b/catalog/app/components/Markdown/Markdown.tsx
index 2edcecaf775..1f810f21edb 100644
--- a/catalog/app/components/Markdown/Markdown.tsx
+++ b/catalog/app/components/Markdown/Markdown.tsx
@@ -206,6 +206,18 @@ const useContainerStyles = M.makeStyles({
maxWidth: '100%',
},
+ '& * + h1, & * + h2, & * + h3, & * + h4, & * + h5, & * + h6': {
+ marginTop: '8px',
+ },
+
+ '& * + p': {
+ marginTop: '8px',
+ },
+
+ '& li + li': {
+ marginTop: '4px',
+ },
+
'& table': {
maxWidth: '100%',
width: '100%',
diff --git a/catalog/app/containers/Bucket/File.js b/catalog/app/containers/Bucket/File.js
index 5f3d6f44001..34b8d9eb18f 100644
--- a/catalog/app/containers/Bucket/File.js
+++ b/catalog/app/containers/Bucket/File.js
@@ -32,6 +32,7 @@ import { readableBytes, readableQuantity } from 'utils/string'
import FileCodeSamples from './CodeSamples/File'
import FileProperties from './FileProperties'
import * as FileView from './FileView'
+import QuratorSection from './Qurator/Section'
import Section from './Section'
import renderPreview from './renderPreview'
import * as requests from './requests'
@@ -501,6 +502,9 @@ export default function File() {
{!!cfg.analyticsBucket && !!blocks.analytics && (
)}
+ {cfg.qurator && blocks.qurator && (
+
+ )}
{blocks.meta && (
<>
diff --git a/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx b/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx
index 3c2d5bbf830..99e8f989f86 100644
--- a/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx
+++ b/catalog/app/containers/Bucket/PackageTree/PackageTree.tsx
@@ -37,6 +37,7 @@ import * as FileView from '../FileView'
import * as Listing from '../Listing'
import PackageCopyDialog from '../PackageCopyDialog'
import * as PD from '../PackageDialog'
+import QuratorSection from '../Qurator/Section'
import Section from '../Section'
import * as Successors from '../Successors'
import Summary from '../Summary'
@@ -677,6 +678,9 @@ function FileDisplay({
>
)}
+ {cfg.qurator && blocks.qurator && (
+
+ )}
>
),
_: () => null,
diff --git a/catalog/app/containers/Bucket/Qurator/Section.tsx b/catalog/app/containers/Bucket/Qurator/Section.tsx
new file mode 100644
index 00000000000..a818e983b89
--- /dev/null
+++ b/catalog/app/containers/Bucket/Qurator/Section.tsx
@@ -0,0 +1,31 @@
+import * as React from 'react'
+
+import { ChatSkeleton } from 'components/Chat'
+import * as Model from 'model'
+
+import PageSection, { NodeRenderer } from '../Section'
+
+const QuratorSummary = React.lazy(() => import('./Summary'))
+
+interface QuratorSectionProps {
+ handle: Model.S3.S3ObjectLocation
+}
+
+export default function QuratorSection({ handle }: QuratorSectionProps) {
+ return (
+
+ {({ expanded }: Parameters[0]) =>
+ expanded && (
+ }>
+
+
+ )
+ }
+
+ )
+}
diff --git a/catalog/app/containers/Bucket/Qurator/Summary.tsx b/catalog/app/containers/Bucket/Qurator/Summary.tsx
new file mode 100644
index 00000000000..dfd17c1beaa
--- /dev/null
+++ b/catalog/app/containers/Bucket/Qurator/Summary.tsx
@@ -0,0 +1,170 @@
+import * as React from 'react'
+import * as Lab from '@material-ui/lab'
+
+import Chat from 'components/Chat'
+import type * as Model from 'model'
+import * as APIConnector from 'utils/APIConnector'
+import * as AWS from 'utils/AWS'
+import mkSearch from 'utils/mkSearch'
+
+const Loading = Symbol('Loading')
+
+type EsHit = { _source?: { content?: string } }
+
+type EsOutput = { hits?: { hits?: { _source?: { content?: string } }[] } } | null
+
+interface Hit {
+ value: EsHit | null
+}
+
+const FILE_PROMPT = (
+ content: string,
+) => `I will ask you questions about the file's content indexed by ElasticSearch. The ElasticSearch JSON output:
+
+${content}.
+
+Please summarize the content of this file intelligently and concisely. Focus on file's content, don't mention ElasticSearch if unnecessary.`
+
+const NO_CONTENT_PROMPT = (
+ json: string,
+) => `Please respond that you can't answer questions about the content of the file, since it's empty or not yet indexed. However, you can tell about metadata indexed by ElasticSearch. This metadata is:
+
+${json}`
+
+const NO_DATA_PROMPT = `Please respond that you can't answer questions about the file, since it's empty or not yet indexed.
+However, you can answer questions about Quilt.`
+
+function getPrompt(hit: Hit) {
+ if (!hit.value) return NO_DATA_PROMPT
+ // eslint-disable-next-line no-underscore-dangle
+ if (!hit.value._source?.content) return NO_CONTENT_PROMPT(JSON.stringify(hit.value))
+ return FILE_PROMPT(JSON.stringify(hit.value))
+}
+
+function useBedrock(foundHit: null | typeof Loading | Error | Hit) {
+ const invokeModel = AWS.Bedrock.use()
+
+ const [history, setHistory] = React.useState<
+ null | typeof Loading | Error | AWS.Bedrock.History
+ >(null)
+
+ React.useEffect(() => {
+ if (foundHit === null || foundHit === Loading || foundHit instanceof Error) {
+ setHistory(foundHit)
+ return
+ }
+
+ const message = AWS.Bedrock.createMessage(getPrompt(foundHit), 'summarize')
+ const newHistory = AWS.Bedrock.historyCreate(message)
+ setHistory(newHistory)
+ invokeModel(newHistory).then(setHistory).catch(setHistory)
+ }, [foundHit, invokeModel])
+
+ const invoke = React.useCallback(
+ async (userInput: string) => {
+ if (history === null || history === Loading || history instanceof Error) {
+ throw new Error('Invoking model when chat UI is not ready')
+ }
+ const prompt = AWS.Bedrock.createMessage(userInput)
+ const newHistory = AWS.Bedrock.historyAppend(prompt, history)
+ setHistory(newHistory)
+ try {
+ setHistory(await invokeModel(newHistory))
+ } catch (e) {
+ setHistory(history)
+ throw e
+ }
+ },
+ [history, invokeModel],
+ )
+ return { history, invoke }
+}
+
+interface ApiRequest {
+ (endpoint: string): Promise
+}
+
+async function loadFileContent(
+ req: ApiRequest,
+ handle: Model.S3.S3ObjectLocation,
+): Promise {
+ const qs = mkSearch({
+ action: 'freeform',
+ body: JSON.stringify({
+ query: {
+ query_string: {
+ query: `key:"${handle.key}"`,
+ },
+ },
+ }),
+ filter_path: 'hits.hits',
+ index: handle.bucket,
+ size: 1,
+ })
+ const res: EsOutput = await req(`/search${qs}`)
+ if (!res?.hits?.hits?.length) return { value: null }
+ const firstHit: EsHit = res.hits.hits[0]
+ // eslint-disable-next-line no-underscore-dangle
+ if (firstHit._source?.content) {
+ // Take first 80% of "words" (word is the simplest attempt to get token).
+ // So, some space is left for user to chat.
+ // eslint-disable-next-line no-underscore-dangle
+ firstHit._source.content = firstHit._source?.content
+ .split(' ')
+ .slice(0, 0.8 * AWS.Bedrock.MAX_TOKENS)
+ .join(' ')
+ }
+ return { value: firstHit }
+}
+
+const NeverResolvedComponent = React.lazy(
+ () =>
+ new Promise(() => {
+ /* Never resolved */
+ }),
+)
+
+function useFileContent(handle: Model.S3.S3ObjectLocation) {
+ const req: ApiRequest = APIConnector.use()
+ const [state, setState] = React.useState(null)
+ React.useEffect(() => {
+ setState(Loading)
+
+ loadFileContent(req, handle).then((content) => {
+ if (content === undefined) {
+ setState(new Error('Failed to find the content for this file'))
+ } else {
+ setState(content)
+ }
+ })
+ }, [handle, req])
+
+ return state
+}
+
+interface SummaryProps {
+ handle: Model.S3.S3ObjectLocation
+}
+
+export default function Summary({ handle }: SummaryProps) {
+ const fileContent = useFileContent(handle)
+ const { history, invoke } = useBedrock(fileContent)
+
+ if (history === null) return null
+ // if `history === Loading`, TS thinks history still can be a symbol
+ if (typeof history === 'symbol') {
+ return
+ }
+
+ if (history instanceof Error) {
+ return {history.message}
+ }
+
+ return (
+
+ )
+}
diff --git a/catalog/app/containers/Bucket/Section.tsx b/catalog/app/containers/Bucket/Section.tsx
index 1a737800df4..69cd5c74d94 100644
--- a/catalog/app/containers/Bucket/Section.tsx
+++ b/catalog/app/containers/Bucket/Section.tsx
@@ -28,7 +28,7 @@ const useStyles = M.makeStyles((t) => ({
},
}))
-type NodeRenderer = (props: {
+export type NodeRenderer = (props: {
expanded: boolean
setExpanded: (exp: boolean) => void
}) => React.ReactNode
diff --git a/catalog/app/utils/AWS/Bedrock/Bedrock.ts b/catalog/app/utils/AWS/Bedrock/Bedrock.ts
new file mode 100644
index 00000000000..e252f5fbe58
--- /dev/null
+++ b/catalog/app/utils/AWS/Bedrock/Bedrock.ts
@@ -0,0 +1,79 @@
+import * as React from 'react'
+
+import BedrockRuntime from 'aws-sdk/clients/bedrockruntime'
+
+import cfg from 'constants/config'
+
+import * as Config from '../Config'
+import * as Credentials from '../Credentials'
+
+import { foldMessages, historyAppend, History } from './History'
+import { CONTENT_TYPE, bedrockBodyToMessage, createMessage } from './Message'
+
+const MODEL_ID = 'anthropic.claude-3-sonnet-20240229-v1:0'
+const ANTHROPIC_VERSION = 'bedrock-2023-05-31'
+
+// https://docs.aws.amazon.com/bedrock/latest/userguide/key-definitions.html
+// Token – A sequence of characters that a model can interpret or predict as a single unit of meaning.
+// Not a word, but eiher word, or phrase, or punctuaction mark, or word suffix or prefix.
+export const MAX_TOKENS = 100000
+
+// Bedrock calls are not free
+// https://aws.amazon.com/bedrock/pricing/
+//
+// You can use `MOCK_BEDROCK = true` to mock Bedrock API response
+const MOCK_BEDROCK = false
+
+export function useBedrock(
+ overrides?: Partial,
+) {
+ Credentials.use().suspend()
+
+ const awsConfig = Config.use()
+
+ const client: BedrockRuntime = React.useMemo(() => {
+ const opts = {
+ ...awsConfig,
+ region: cfg.region,
+ ...overrides,
+ }
+ return new BedrockRuntime(opts)
+ }, [awsConfig, overrides])
+
+ // `invokeModel()` with prepared new history (last message should be `user` or `system` message)
+ // Returns new history with appended `assistant` message
+ return React.useCallback(
+ async (history: History): Promise => {
+ const contentType = CONTENT_TYPE
+ const options = {
+ contentType,
+ body: JSON.stringify({
+ anthropic_version: ANTHROPIC_VERSION,
+ max_tokens: MAX_TOKENS,
+ messages: foldMessages(history.messages),
+ }),
+ modelId: MODEL_ID,
+ }
+ const response = await client.invokeModel(options).promise()
+ const output = await bedrockBodyToMessage(response.body, contentType)
+ return historyAppend(output, history)
+ },
+ [client],
+ )
+}
+
+// Use it to mock Bedrock API response
+// It helps to test UI and reduce costs
+function useMock() {
+ return React.useCallback(
+ (history: History): Promise =>
+ new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(historyAppend(createMessage('Hello, world!', 'assistant'), history))
+ }, 3000)
+ }),
+ [],
+ )
+}
+
+export const use = MOCK_BEDROCK ? useMock : useBedrock
diff --git a/catalog/app/utils/AWS/Bedrock/History.spec.ts b/catalog/app/utils/AWS/Bedrock/History.spec.ts
new file mode 100644
index 00000000000..6ff1fb4773a
--- /dev/null
+++ b/catalog/app/utils/AWS/Bedrock/History.spec.ts
@@ -0,0 +1,45 @@
+import * as History from './History'
+import * as Message from './Message'
+
+describe('utils/AWS/Bedrock/History', () => {
+ describe('historyCreate', () => {
+ it('Creates new history with system prompt', () => {
+ const history = History.historyCreate(Message.createMessage('foo bar'))
+ expect(history.messages[0].role).toBe('system')
+ expect(history.messages[1].content).toBe('foo bar')
+ })
+ })
+
+ describe('historyAppend', () => {
+ it('Appends messages to history', () => {
+ const history = History.historyCreate(Message.createMessage('foo'))
+ expect(history.messages.length).toBe(2)
+ const newHistory = History.historyAppend(Message.createMessage('bar'), history)
+ expect(newHistory.messages.length).toBe(3)
+ expect(newHistory.messages[2].content).toBe('bar')
+ })
+ })
+
+ describe('foldMessages', () => {
+ it('Fold same-role messages', async () => {
+ const userFoo = Message.createMessage('foo')
+ const userBar = Message.createMessage('bar')
+ const assistantFoo = Message.createMessage('foo', 'assistant')
+ const assistantBaz = Message.createMessage('baz', 'assistant')
+ const list = History.foldMessages([userFoo, userBar, assistantFoo, assistantBaz])
+ expect(list.length).toBe(2)
+ expect(list[0].content).toBe('foo\nbar')
+ expect(list[1].content).toBe('foo\nbaz')
+ })
+
+ it('Fold system and user messages', async () => {
+ const userFoo = Message.createMessage('foo')
+ const userBar = Message.createMessage('bar')
+ const systemFoo = Message.createMessage('foo', 'system')
+ const systemBaz = Message.createMessage('baz', 'system')
+ const list = History.foldMessages([userFoo, userBar, systemFoo, systemBaz])
+ expect(list.length).toBe(1)
+ expect(list[0].content).toBe('foo\nbar\nfoo\nbaz')
+ })
+ })
+})
diff --git a/catalog/app/utils/AWS/Bedrock/History.ts b/catalog/app/utils/AWS/Bedrock/History.ts
new file mode 100644
index 00000000000..a327e7fc59f
--- /dev/null
+++ b/catalog/app/utils/AWS/Bedrock/History.ts
@@ -0,0 +1,39 @@
+import {
+ messageToBedrockMessage,
+ createMessage,
+ BedrockMessage,
+ Message,
+} from './Message'
+
+const SYSTEM_PROMPT = `You are a conservative and creative scientist. When asked a question about Quilt, refer to the documentation at https://docs.quiltdata.com. For cross-account bucket policies, see https://docs.quiltdata.com/advanced/crossaccount. Use GitHub flavored Markdown syntax for formatting when appropriate.`
+
+export interface History {
+ messages: Message[]
+}
+
+// Creates new `History` object augmented with system prompt
+export function historyCreate(prompt: Message): History {
+ return { messages: [createMessage(SYSTEM_PROMPT, 'system'), prompt] }
+}
+
+// Push message to the end of messages list in existing history
+export function historyAppend(prompt: Message, history: History): History {
+ return { messages: history.messages.concat(prompt) }
+}
+
+// Fold messages with the same role into a single message
+// Bedrock doesn't accept multiple messages with the same role, they should alternate user -> assistant -> user -> ...
+// But for convinience we have multiple 'system'/'user' messages in a row ('system' becomes 'user')
+export function foldMessages(messages: Message[]): BedrockMessage[] {
+ return messages.reduce((memo, message) => {
+ const bedrockMessage = messageToBedrockMessage(message)
+ const last = memo[memo.length - 1]
+ if (last && last.role === bedrockMessage.role) {
+ return [
+ ...memo.slice(0, -1),
+ { role: last.role, content: `${last.content}\n${bedrockMessage.content}` },
+ ]
+ }
+ return memo.concat(bedrockMessage)
+ }, [] as BedrockMessage[])
+}
diff --git a/catalog/app/utils/AWS/Bedrock/Message.spec.ts b/catalog/app/utils/AWS/Bedrock/Message.spec.ts
new file mode 100644
index 00000000000..732a7fa7211
--- /dev/null
+++ b/catalog/app/utils/AWS/Bedrock/Message.spec.ts
@@ -0,0 +1,91 @@
+import * as Message from './Message'
+
+describe('utils/AWS/Bedrock/Message', () => {
+ describe('createMessage', () => {
+ it('Creates user role message', () => {
+ expect(Message.createMessage('foo')).toMatchObject({
+ role: 'user',
+ content: 'foo',
+ })
+ })
+
+ it('Creates any role message', () => {
+ expect(Message.createMessage('foo', 'system')).toMatchObject({
+ role: 'system',
+ content: 'foo',
+ })
+ })
+ })
+
+ describe('bedrockBodyToMessage', () => {
+ it('Support application/json only', async () => {
+ expect(Message.bedrockBodyToMessage('foo', 'text/html')).rejects.toThrow(
+ 'Unsupported content type',
+ )
+ })
+
+ it('Throws on invalid formats', async () => {
+ expect(Message.bedrockBodyToMessage('foo', 'application/json')).rejects.toThrow(
+ 'is not valid JSON',
+ )
+
+ expect(
+ Message.bedrockBodyToMessage('{"foo": "bar"}', 'application/json'),
+ ).rejects.toThrow('`content` is empty')
+
+ expect(
+ Message.bedrockBodyToMessage('{"content": {"foo": "bar"}}', 'application/json'),
+ ).rejects.toThrow('Unsupported `content` type')
+
+ expect(
+ Message.bedrockBodyToMessage('{"content": []}', 'application/json'),
+ ).rejects.toThrow('`content` list is empty')
+
+ expect(
+ Message.bedrockBodyToMessage(
+ '{"content": [{"type": "foo"}]}',
+ 'application/json',
+ ),
+ ).rejects.toThrow('`content` list is empty')
+ })
+
+ it('Accept `content` string', async () => {
+ const message = await Message.bedrockBodyToMessage(
+ '{"content": "foo"}',
+ 'application/json',
+ )
+ expect(message.content).toBe('foo')
+ })
+
+ it('Accept `content` item as string', async () => {
+ const message = await Message.bedrockBodyToMessage(
+ '{"content": ["foo bar"]}',
+ 'application/json',
+ )
+ expect(message.content).toBe('foo bar')
+ })
+
+ it('Accept regular `content` object', async () => {
+ const message = await Message.bedrockBodyToMessage(
+ '{"content": [{"type": "text", "text": "foo bar"}]}',
+ 'application/json',
+ )
+ expect(message.content).toBe('foo bar')
+ })
+
+ it('Accept blobs', async () => {
+ const json = '{ "content": [{ "type": "text", "text": "foo bar" }] }'
+ const blob = new Blob([json], { type: 'text/plain' })
+ const message = await Message.bedrockBodyToMessage(blob, 'application/json')
+ expect(message.content).toBe('foo bar')
+ })
+
+ it('Accept ArrayBuffers', async () => {
+ const json = '{ "content": [{ "type": "text", "text": "foo bar" }] }'
+ const blob = new Blob([json], { type: 'text/plain' })
+ const arrayBuffer = await blob.arrayBuffer()
+ const message = await Message.bedrockBodyToMessage(arrayBuffer, 'application/json')
+ expect(message.content).toBe('foo bar')
+ })
+ })
+})
diff --git a/catalog/app/utils/AWS/Bedrock/Message.ts b/catalog/app/utils/AWS/Bedrock/Message.ts
new file mode 100644
index 00000000000..65458bfa90c
--- /dev/null
+++ b/catalog/app/utils/AWS/Bedrock/Message.ts
@@ -0,0 +1,93 @@
+import type { Body } from 'aws-sdk/clients/bedrockruntime'
+
+export const CONTENT_TYPE = 'application/json'
+
+// Roles used in chat to show different styles and hide system messages
+type Role = 'user' | 'assistant' | 'system' | 'summarize'
+
+// Roles used in bedrock API for messaging
+type BedrockRole = 'user' | 'assistant'
+
+export interface Message {
+ role: Role
+ content: string
+}
+
+export interface BedrockMessage {
+ role: BedrockRole
+ content: string
+}
+
+export function createMessage(text: string, role?: Role): Message {
+ return { role: role || 'user', content: text }
+}
+
+async function bedrockBodyToString(body: Body): Promise {
+ if (typeof body === 'string') {
+ return body
+ }
+ if (body instanceof Blob) {
+ return body.text()
+ }
+ return new TextDecoder().decode(body as Buffer | Uint8Array)
+}
+
+async function bedrockJsonToMessage(json: any): Promise {
+ const content = json?.content
+ if (!content) {
+ throw new Error('Failed to parse Bedrock response. `content` is empty')
+ }
+ if (typeof content === 'string') {
+ return createMessage(content, 'assistant')
+ }
+ if (!Array.isArray(content)) {
+ throw new Error('Failed to parse Bedrock response. Unsupported `content` type')
+ }
+
+ const message: Message | null = content
+ .map((item) => {
+ if (typeof item === 'string') {
+ // There is no documenation example with string item,
+ // but neither could I find a clear indication that it can't be a string
+ return createMessage(item, 'assistant')
+ }
+ if (item.type === 'text' && typeof item.text === 'string') {
+ return createMessage(item.text, 'assistant')
+ }
+ return null
+ })
+ .reduce((memo, item) => {
+ if (!item) return null
+ return createMessage(
+ memo ? `${memo.content} ${item.content}` : item.content,
+ 'assistant',
+ )
+ }, null)
+ if (!message) {
+ throw new Error('Failed to parse Bedrock response. `content` list is empty')
+ }
+ return message
+}
+
+export async function bedrockBodyToMessage(
+ body: Body,
+ contentType: string,
+): Promise {
+ const output = await bedrockBodyToString(body)
+ if (contentType !== CONTENT_TYPE) {
+ throw new Error(`Unsupported content type: ${contentType}`)
+ }
+ const json = JSON.parse(output)
+ return bedrockJsonToMessage(json)
+}
+
+// All 'system' messages become 'user' messages
+export function messageToBedrockMessage(message: Message): BedrockMessage {
+ if (message.role === 'user' || message.role === 'assistant') {
+ return message as BedrockMessage
+ }
+ return {
+ ...message,
+ role: 'user',
+ }
+}
diff --git a/catalog/app/utils/AWS/Bedrock/index.ts b/catalog/app/utils/AWS/Bedrock/index.ts
new file mode 100644
index 00000000000..072aaa64650
--- /dev/null
+++ b/catalog/app/utils/AWS/Bedrock/index.ts
@@ -0,0 +1,3 @@
+export * from './Bedrock'
+export * from './History'
+export * from './Message'
diff --git a/catalog/app/utils/AWS/index.js b/catalog/app/utils/AWS/index.js
index ea629deeb6e..ee21ddaeb97 100644
--- a/catalog/app/utils/AWS/index.js
+++ b/catalog/app/utils/AWS/index.js
@@ -1,4 +1,5 @@
export * as Athena from './Athena'
+export * as Bedrock from './Bedrock'
export * as Config from './Config'
export * as Credentials from './Credentials'
export * as S3 from './S3'
diff --git a/catalog/app/utils/BucketPreferences/BucketPreferences.spec.ts b/catalog/app/utils/BucketPreferences/BucketPreferences.spec.ts
index 8efd7c64d47..730d005878a 100644
--- a/catalog/app/utils/BucketPreferences/BucketPreferences.spec.ts
+++ b/catalog/app/utils/BucketPreferences/BucketPreferences.spec.ts
@@ -31,6 +31,7 @@ const expectedDefaults = {
packages: true,
summarize: true,
},
+ qurator: true,
},
nav: {
files: true,
diff --git a/catalog/app/utils/BucketPreferences/BucketPreferences.ts b/catalog/app/utils/BucketPreferences/BucketPreferences.ts
index ebd80fd2610..a58a7c67c04 100644
--- a/catalog/app/utils/BucketPreferences/BucketPreferences.ts
+++ b/catalog/app/utils/BucketPreferences/BucketPreferences.ts
@@ -44,6 +44,7 @@ interface BlocksPreferencesInput {
code?: boolean
meta?: boolean | MetaBlockPreferencesInput
gallery?: boolean | GalleryPreferences
+ qurator?: boolean
}
interface BlocksPreferences {
@@ -52,6 +53,7 @@ interface BlocksPreferences {
code: boolean
meta: false | MetaBlockPreferences
gallery: false | GalleryPreferences
+ qurator: boolean
}
export type NavPreferences = Record<'files' | 'packages' | 'queries', boolean>
@@ -149,6 +151,7 @@ const defaultPreferences: BucketPreferences = {
code: true,
meta: defaultBlockMeta,
gallery: defaultGallery,
+ qurator: true,
},
nav: {
files: true,
diff --git a/catalog/app/utils/Config.ts b/catalog/app/utils/Config.ts
index e44be2e7fec..c1172cdf391 100644
--- a/catalog/app/utils/Config.ts
+++ b/catalog/app/utils/Config.ts
@@ -47,6 +47,8 @@ export interface ConfigJson {
chunkedChecksums?: boolean
+ qurator?: boolean
+
build_version?: string // not sure where this comes from
}
@@ -94,6 +96,7 @@ const transformConfig = (cfg: ConfigJson) => ({
noOverviewImages: !!cfg.noOverviewImages,
desktop: !!cfg.desktop,
chunkedChecksums: !!cfg.chunkedChecksums,
+ qurator: !!cfg.qurator,
})
export function prepareConfig(input: unknown) {
diff --git a/catalog/config-schema.json b/catalog/config-schema.json
index e5528edc6e5..71804d77e3f 100644
--- a/catalog/config-schema.json
+++ b/catalog/config-schema.json
@@ -100,6 +100,10 @@
"gtmId": {
"type": "string",
"description": "ID for Google TagManager/Analytics service"
+ },
+ "qurator": {
+ "type": "boolean",
+ "description": "Enable Qurator AI Assistant (powered by AWS Bedrock)"
}
},
"required": [
diff --git a/catalog/config.json.tmpl b/catalog/config.json.tmpl
index 1c19ec20880..45bfe147d87 100644
--- a/catalog/config.json.tmpl
+++ b/catalog/config.json.tmpl
@@ -14,5 +14,6 @@
"analyticsBucket": "${ANALYTICS_BUCKET}",
"serviceBucket": "${SERVICE_BUCKET}",
"mode": "${CATALOG_MODE}",
- "chunkedChecksums": ${CHUNKED_CHECKSUMS}
+ "chunkedChecksums": ${CHUNKED_CHECKSUMS},
+ "qurator": ${QURATOR}
}
diff --git a/catalog/package-lock.json b/catalog/package-lock.json
index 1c63883308f..877b71a43f6 100644
--- a/catalog/package-lock.json
+++ b/catalog/package-lock.json
@@ -26,7 +26,7 @@
"ace-builds": "^1.23.4",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
- "aws-sdk": "^2.1431.0",
+ "aws-sdk": "^2.1467.0",
"brace": "^0.11.1",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
@@ -5397,9 +5397,10 @@
}
},
"node_modules/aws-sdk": {
- "version": "2.1431.0",
- "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1431.0.tgz",
- "integrity": "sha512-p6NGyI6+BgojiGn6uW2If6v7uxRPO5C+aGy/M+9/Rhdk8a5n7l0123v9ZUnEJgAy0tsNkazL2ifzV33nc0aGNA==",
+ "version": "2.1628.0",
+ "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1628.0.tgz",
+ "integrity": "sha512-mUjvITHmV46JbCTQR8TovWzJEHZtbC9cTRySXyuIuuYQNjA2RrbfqLXXKR/xru+vPFKnfTLw+QQ/BC2l1/Ln5g==",
+ "hasInstallScript": true,
"dependencies": {
"buffer": "4.9.2",
"events": "1.1.1",
@@ -5410,7 +5411,7 @@
"url": "0.10.3",
"util": "^0.12.4",
"uuid": "8.0.0",
- "xml2js": "0.5.0"
+ "xml2js": "0.6.2"
},
"engines": {
"node": ">= 10.0.0"
@@ -16666,7 +16667,7 @@
"node_modules/sax": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz",
- "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o="
+ "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="
},
"node_modules/saxes": {
"version": "6.0.0",
@@ -18527,9 +18528,13 @@
}
},
"node_modules/uuid": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
- "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -19771,9 +19776,9 @@
}
},
"node_modules/xml2js": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
- "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
@@ -24061,9 +24066,9 @@
"integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw=="
},
"aws-sdk": {
- "version": "2.1431.0",
- "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1431.0.tgz",
- "integrity": "sha512-p6NGyI6+BgojiGn6uW2If6v7uxRPO5C+aGy/M+9/Rhdk8a5n7l0123v9ZUnEJgAy0tsNkazL2ifzV33nc0aGNA==",
+ "version": "2.1628.0",
+ "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1628.0.tgz",
+ "integrity": "sha512-mUjvITHmV46JbCTQR8TovWzJEHZtbC9cTRySXyuIuuYQNjA2RrbfqLXXKR/xru+vPFKnfTLw+QQ/BC2l1/Ln5g==",
"requires": {
"buffer": "4.9.2",
"events": "1.1.1",
@@ -24074,7 +24079,7 @@
"url": "0.10.3",
"util": "^0.12.4",
"uuid": "8.0.0",
- "xml2js": "0.5.0"
+ "xml2js": "0.6.2"
},
"dependencies": {
"buffer": {
@@ -32610,7 +32615,7 @@
"sax": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz",
- "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o="
+ "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="
},
"saxes": {
"version": "6.0.0",
@@ -34030,9 +34035,9 @@
"dev": true
},
"uuid": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
- "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="
},
"v8-to-istanbul": {
"version": "9.1.0",
@@ -35019,9 +35024,9 @@
"dev": true
},
"xml2js": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
- "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
diff --git a/catalog/package.json b/catalog/package.json
index c6839d024cc..ac8cce0be95 100644
--- a/catalog/package.json
+++ b/catalog/package.json
@@ -63,7 +63,7 @@
"ace-builds": "^1.23.4",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
- "aws-sdk": "^2.1431.0",
+ "aws-sdk": "^2.1467.0",
"brace": "^0.11.1",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 34a4e9d8968..62e6a42f88b 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -23,6 +23,7 @@ Entries inside each section should be ordered by type:
* [Added] Support multiple roles per user ([#3982](https://github.com/quiltdata/quilt/pull/3982))
* [Added] Add `ui.actions = False` and `ui.actions.writeFile` for configuring visibility of buttons ([#4001](https://github.com/quiltdata/quilt/pull/4001))
* [Added] Support creating folders and rearranging entries with drag and drop in package creation dialog ([#3999](https://github.com/quiltdata/quilt/pull/3999))
+* [Added] Qurator AI Assistant for summarizing file contents using Bedrock API ([#3989](https://github.com/quiltdata/quilt/pull/3989))
# 6.0.0a4 - 2024-06-18
## Python API
diff --git a/shared/schemas/bucketConfig.yml.json b/shared/schemas/bucketConfig.yml.json
index 310b0a9b562..0fc107cbed8 100644
--- a/shared/schemas/bucketConfig.yml.json
+++ b/shared/schemas/bucketConfig.yml.json
@@ -175,6 +175,12 @@
"workflows": { "expanded": 1 }
}
]
+ },
+ "qurator": {
+ "default": true,
+ "description": "Show/hide Qurator block",
+ "type": "boolean",
+ "examples": [true, false]
}
}
},