Skip to content

Commit

Permalink
Catalog: Qurator UX improvements (#4192)
Browse files Browse the repository at this point in the history
  • Loading branch information
nl0 authored Nov 29, 2024
1 parent facf51c commit 4dab290
Show file tree
Hide file tree
Showing 11 changed files with 257 additions and 68 deletions.
3 changes: 3 additions & 0 deletions catalog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ where verb is one of

## Changes

- [Changed] Qurator: propagate error messages from Bedrock ([#4192](https://github.com/quiltdata/quilt/pull/4192))
- [Added] Qurator Developer Tools ([#4192](https://github.com/quiltdata/quilt/pull/4192))
- [Changed] JsonDisplay: handle dates and functions ([#4192](https://github.com/quiltdata/quilt/pull/4192))
- [Fixed] Keep default Intercom launcher closed when closing Package Dialog ([#4244](https://github.com/quiltdata/quilt/pull/4244))
- [Fixed] Handle invalid bucket name in `ui.sourceBuckets` in bucket config ([#4242](https://github.com/quiltdata/quilt/pull/4242))
- [Added] Preview Markdown while editing ([#4153](https://github.com/quiltdata/quilt/pull/4153))
Expand Down
3 changes: 2 additions & 1 deletion catalog/app/components/Assistant/Model/Assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ function useConstructAssistantAPI() {
}
}

type AssistantAPI = ReturnType<typeof useConstructAssistantAPI>
export type AssistantAPI = ReturnType<typeof useConstructAssistantAPI>
export type { AssistantAPI as API }

const Ctx = React.createContext<AssistantAPI | typeof DISABLED | null>(null)

Expand Down
42 changes: 27 additions & 15 deletions catalog/app/components/Assistant/Model/Bedrock.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type * as AWSSDK from 'aws-sdk'
import BedrockRuntime from 'aws-sdk/clients/bedrockruntime'
import * as Eff from 'effect'

Expand Down Expand Up @@ -110,6 +111,10 @@ const toolConfigToBedrock = (
}),
})

function isAWSError(e: any): e is AWSSDK.AWSError {
return e.code !== undefined && e.message !== undefined
}

// a layer providing the service over aws.bedrock
export function LLMBedrock(bedrock: BedrockRuntime) {
const converse = (prompt: LLM.Prompt, opts?: LLM.Options) =>
Expand All @@ -127,21 +132,28 @@ export function LLMBedrock(bedrock: BedrockRuntime) {
opts,
],
})(
Eff.Effect.tryPromise(() =>
bedrock
.converse({
modelId: MODEL_ID,
system: [{ text: prompt.system }],
messages: messagesToBedrock(prompt.messages),
toolConfig: prompt.toolConfig && toolConfigToBedrock(prompt.toolConfig),
...opts,
})
.promise()
.then((backendResponse) => ({
backendResponse,
content: mapContent(backendResponse.output.message?.content),
})),
),
Eff.Effect.tryPromise({
try: () =>
bedrock
.converse({
modelId: MODEL_ID,
system: [{ text: prompt.system }],
messages: messagesToBedrock(prompt.messages),
toolConfig: prompt.toolConfig && toolConfigToBedrock(prompt.toolConfig),
...opts,
})
.promise()
.then((backendResponse) => ({
backendResponse,
content: mapContent(backendResponse.output.message?.content),
})),
catch: (e) =>
new LLM.LLMError({
message: isAWSError(e)
? `Bedrock error (${e.code}): ${e.message}`
: `Unexpected error: ${e}`,
}),
}),
)

return Eff.Layer.succeed(LLM.LLM, { converse })
Expand Down
2 changes: 1 addition & 1 deletion catalog/app/components/Assistant/Model/Content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,4 @@ export type PromptMessageContentBlock = Eff.Data.TaggedEnum<{
export const PromptMessageContentBlock = Eff.Data.taggedEnum<PromptMessageContentBlock>()

export const text = (first: string, ...rest: string[]) =>
PromptMessageContentBlock.Text({ text: [first, ...rest].join('') })
PromptMessageContentBlock.Text({ text: [first, ...rest].join('\n') })
12 changes: 6 additions & 6 deletions catalog/app/components/Assistant/Model/Conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export type Action = Eff.Data.TaggedEnum<{
readonly content: string
}
LLMError: {
readonly error: Eff.Cause.UnknownException
readonly error: LLM.LLMError
}
LLMResponse: {
readonly content: Exclude<Content.ResponseMessageContentBlock, { _tag: 'ToolUse' }>[]
Expand Down Expand Up @@ -144,8 +144,8 @@ const llmRequest = (events: Event[]) =>
const response = yield* llm.converse(prompt)

if (Eff.Option.isNone(response.content)) {
return yield* new Eff.Cause.UnknownException(
new Error('No content in LLM response'),
return yield* Eff.Effect.fail(
new LLM.LLMError({ message: 'No content in LLM response' }),
)
}

Expand Down Expand Up @@ -193,8 +193,8 @@ export const ConversationActor = Eff.Effect.succeed(
WaitingForAssistant: {
LLMError: ({ events }, { error }) =>
idle(events, {
message: 'Error while interacting with LLM. Please try again.',
details: `${error}`,
message: 'Error while interacting with LLM.',
details: error.message,
}),
LLMResponse: (state, { content, toolUses }, dispatch) =>
Eff.Effect.gen(function* () {
Expand Down Expand Up @@ -360,7 +360,7 @@ to your advantage.
Use GitHub Flavored Markdown syntax for formatting when appropriate.
`

const constructPrompt = (
export const constructPrompt = (
events: Event[],
context: Context.ContextShape,
): Eff.Effect.Effect<LLM.Prompt> =>
Expand Down
10 changes: 9 additions & 1 deletion catalog/app/components/Assistant/Model/LLM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,21 @@ interface ConverseResponse {
backendResponse: BedrockRuntime.ConverseResponse
}

export class LLMError {
message: string

constructor({ message }: { message: string }) {
this.message = message
}
}

// a service
export class LLM extends Eff.Context.Tag('LLM')<
LLM,
{
converse: (
prompt: Prompt,
opts?: Options,
) => Eff.Effect.Effect<ConverseResponse, Eff.Cause.UnknownException>
) => Eff.Effect.Effect<ConverseResponse, LLMError>
}
>() {}
111 changes: 101 additions & 10 deletions catalog/app/components/Assistant/UI/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import usePrevious from 'utils/usePrevious'

import * as Model from '../../Model'

import DevTools from './DevTools'
import Input from './Input'

const BG = {
Expand Down Expand Up @@ -145,10 +146,8 @@ function MessageAction({ children, onClick }: MessageActionProps) {
)
}

type ConversationDispatch = (action: Model.Conversation.Action) => void

interface ConversationDispatchProps {
dispatch: ConversationDispatch
dispatch: Model.Assistant.API['dispatch']
}

interface ConversationStateProps {
Expand Down Expand Up @@ -281,13 +280,85 @@ function WaitingState({ timestamp, dispatch }: WaitingStateProps) {
)
}

const useChatStyles = M.makeStyles((t) => ({
interface MenuProps {
state: Model.Assistant.API['state']
dispatch: Model.Assistant.API['dispatch']
onToggleDevTools: () => void
devToolsOpen: boolean
className?: string
}

function Menu({ state, dispatch, devToolsOpen, onToggleDevTools, className }: MenuProps) {
const [menuOpen, setMenuOpen] = React.useState<HTMLElement | null>(null)

const isIdle = state._tag === 'Idle'

const toggleMenu = React.useCallback(
(e: React.BaseSyntheticEvent) =>
setMenuOpen((prev) => (prev ? null : e.currentTarget)),
[setMenuOpen],
)
const closeMenu = React.useCallback(() => setMenuOpen(null), [setMenuOpen])

const startNewSession = React.useCallback(() => {
if (isIdle) dispatch(Model.Conversation.Action.Clear())
closeMenu()
}, [closeMenu, isIdle, dispatch])

const showDevTools = React.useCallback(() => {
onToggleDevTools()
closeMenu()
}, [closeMenu, onToggleDevTools])

return (
<>
<M.Fade in={!devToolsOpen}>
<M.IconButton
aria-label="menu"
aria-haspopup="true"
onClick={toggleMenu}
className={className}
>
<M.Icon>menu</M.Icon>
</M.IconButton>
</M.Fade>
<M.Fade in={devToolsOpen}>
<M.Tooltip title="Close Developer Tools">
<M.IconButton
aria-label="close"
onClick={onToggleDevTools}
className={className}
>
<M.Icon>close</M.Icon>
</M.IconButton>
</M.Tooltip>
</M.Fade>
<M.Menu anchorEl={menuOpen} open={!!menuOpen} onClose={closeMenu}>
<M.MenuItem onClick={startNewSession} disabled={!isIdle}>
New session
</M.MenuItem>
<M.MenuItem onClick={showDevTools}>Developer Tools</M.MenuItem>
</M.Menu>
</>
)
}

const useStyles = M.makeStyles((t) => ({
chat: {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'hidden',
},
menu: {
position: 'absolute',
right: t.spacing(1),
top: t.spacing(1),
zIndex: 1,
},
devTools: {
height: '50%',
},
historyContainer: {
flexGrow: 1,
overflowY: 'auto',
Expand All @@ -313,15 +384,15 @@ const useChatStyles = M.makeStyles((t) => ({
}))

interface ChatProps {
state: Model.Conversation.State
dispatch: ConversationDispatch
state: Model.Assistant.API['state']
dispatch: Model.Assistant.API['dispatch']
}

export default function Chat({ state, dispatch }: ChatProps) {
const classes = useChatStyles()
const classes = useStyles()
const scrollRef = React.useRef<HTMLDivElement>(null)

const inputDisabled = state._tag != 'Idle'
const inputDisabled = state._tag !== 'Idle'

const stateFingerprint = `${state._tag}:${state.timestamp.getTime()}`

Expand All @@ -341,8 +412,27 @@ export default function Chat({ state, dispatch }: ChatProps) {
[dispatch],
)

const [devToolsOpen, setDevToolsOpen] = React.useState(false)

const toggleDevTools = React.useCallback(
() => setDevToolsOpen((prev) => !prev),
[setDevToolsOpen],
)

return (
<div className={classes.chat}>
<Menu
state={state}
dispatch={dispatch}
onToggleDevTools={toggleDevTools}
devToolsOpen={devToolsOpen}
className={classes.menu}
/>
<M.Slide direction="down" mountOnEnter unmountOnExit in={devToolsOpen}>
<M.Paper square className={classes.devTools}>
<DevTools state={state} />
</M.Paper>
</M.Slide>
<div className={classes.historyContainer}>
<div className={classes.history}>
<MessageContainer>
Expand Down Expand Up @@ -375,8 +465,9 @@ export default function Chat({ state, dispatch }: ChatProps) {
Eff.Option.match(s.error, {
onSome: (e) => (
<MessageContainer timestamp={s.timestamp}>
Error occurred: {e.message}
<div>{e.details}</div>
<b>{e.message}</b>
<br />
{e.details}
</MessageContainer>
// TODO: retry / discard
),
Expand Down
61 changes: 61 additions & 0 deletions catalog/app/components/Assistant/UI/Chat/DevTools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as Eff from 'effect'
import * as React from 'react'
import * as M from '@material-ui/core'

import JsonDisplay from 'components/JsonDisplay'

import * as Model from '../../Model'

const useStyles = M.makeStyles((t) => ({
root: {
display: 'flex',
flexDirection: 'column',
height: '100%',
},
heading: {
...t.typography.h5,
borderBottom: `1px solid ${t.palette.divider}`,
lineHeight: '64px',
paddingLeft: t.spacing(2),
},
contents: {
flexGrow: 1,
overflow: 'auto',
},
json: {
margin: t.spacing(2, 0),
padding: t.spacing(0, 2),
},
}))

interface DevToolsProps {
state: Model.Assistant.API['state']
}

export default function DevTools({ state }: DevToolsProps) {
const classes = useStyles()

const context = Model.Context.useAggregatedContext()

const prompt = React.useMemo(
() =>
Eff.Effect.runSync(
Model.Conversation.constructPrompt(
state.events.filter((e) => !e.discarded),
context,
),
),
[state, context],
)

return (
<section className={classes.root}>
<h1 className={classes.heading}>Qurator Developer Tools</h1>
<div className={classes.contents}>
<JsonDisplay className={classes.json} name="Context" value={context} />
<JsonDisplay className={classes.json} name="State" value={state} />
<JsonDisplay className={classes.json} name="Prompt" value={prompt} />
</div>
</section>
)
}
9 changes: 8 additions & 1 deletion catalog/app/components/JsonDisplay/JsonDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,14 @@ function getHref(v: string) {
}

function NonStringValue({ value }: { value: PrimitiveValue }) {
return <div>{`${value}`}</div>
const formatted = React.useMemo(() => {
if (value instanceof Date) return `Date(${value.toISOString()})`
if (typeof value === 'function') {
return `Function(${(value as Function).name || 'anonymous'})`
}
return `${value}`
}, [value])
return <div>{formatted}</div>
}

function S3UrlValue({ href, children }: React.PropsWithChildren<{ href: string }>) {
Expand Down
Loading

0 comments on commit 4dab290

Please sign in to comment.