Skip to content

Commit 5af39a2

Browse files
authored
Qurator devtools: model override, session recording (#4467)
1 parent 8704035 commit 5af39a2

File tree

6 files changed

+250
-39
lines changed

6 files changed

+250
-39
lines changed

catalog/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ where verb is one of
1818

1919
## Changes
2020

21+
- [Added] Qurator devtools: model override, session recording ([#4467](https://github.com/quiltdata/quilt/pull/4467))
2122
- [Changed] Adjust GQL schema for the upstream changes and handle search timeouts ([#4477](https://github.com/quiltdata/quilt/pull/4477))
2223
- [Fixed] Correctly sign and proxy S3 requests and URIs in OPEN mode ([#4470](https://github.com/quiltdata/quilt/pull/4470))
2324
- [Fixed] Show "0 packages" when no packages ([#4473](https://github.com/quiltdata/quilt/pull/4473))

catalog/app/components/Assistant/Model/Assistant.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,74 @@ function usePassThru<T>(val: T) {
2020
return ref
2121
}
2222

23+
export const DEFAULT_MODEL_ID = 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'
24+
const MODEL_ID_KEY = 'QUILT_BEDROCK_MODEL_ID'
25+
26+
function useModelIdOverride() {
27+
const [value, setValue] = React.useState(
28+
() =>
29+
(typeof localStorage !== 'undefined' && localStorage.getItem(MODEL_ID_KEY)) || '',
30+
)
31+
32+
React.useEffect(() => {
33+
if (typeof localStorage !== 'undefined') {
34+
if (value) {
35+
localStorage.setItem(MODEL_ID_KEY, value)
36+
} else {
37+
localStorage.removeItem(MODEL_ID_KEY)
38+
}
39+
}
40+
}, [value])
41+
42+
const modelIdPassThru = usePassThru(value)
43+
const modelIdEff = React.useMemo(
44+
() => Eff.Effect.sync(() => modelIdPassThru.current || DEFAULT_MODEL_ID),
45+
[modelIdPassThru],
46+
)
47+
48+
return [
49+
modelIdEff,
50+
React.useMemo(() => ({ value, setValue }), [value, setValue]),
51+
] as const
52+
}
53+
54+
function useRecording() {
55+
const [enabled, enable] = React.useState(false)
56+
const [log, setLog] = React.useState<string[]>([])
57+
58+
const clear = React.useCallback(() => setLog([]), [])
59+
60+
const enabledPassThru = usePassThru(enabled)
61+
const record = React.useCallback(
62+
(entry: string) =>
63+
Eff.Effect.sync(() => {
64+
if (enabledPassThru.current) setLog((l) => l.concat(entry))
65+
}),
66+
[enabledPassThru],
67+
)
68+
69+
return [
70+
record,
71+
React.useMemo(() => ({ enabled, log, enable, clear }), [enabled, log, enable, clear]),
72+
] as const
73+
}
74+
2375
function useConstructAssistantAPI() {
76+
const [modelId, modelIdOverride] = useModelIdOverride()
77+
const [record, recording] = useRecording()
78+
2479
const passThru = usePassThru({
2580
bedrock: AWS.Bedrock.useClient(),
2681
context: Context.useLayer(),
2782
})
83+
2884
const layerEff = Eff.Effect.sync(() =>
2985
Eff.Layer.merge(
30-
Bedrock.LLMBedrock(passThru.current.bedrock),
86+
Bedrock.LLMBedrock(passThru.current.bedrock, { modelId, record }),
3187
passThru.current.context,
3288
),
3389
)
90+
3491
const [state, dispatch] = Actor.useActorLayer(
3592
Conversation.ConversationActor,
3693
Conversation.init,
@@ -59,6 +116,7 @@ function useConstructAssistantAPI() {
59116
assist,
60117
state,
61118
dispatch,
119+
devTools: { recording, modelIdOverride },
62120
}
63121
}
64122

catalog/app/components/Assistant/Model/Bedrock.ts

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import * as LLM from './LLM'
99

1010
const MODULE = 'Bedrock'
1111

12-
const MODEL_ID = 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'
12+
interface BedrockOptions {
13+
modelId: Eff.Effect.Effect<string>
14+
record?: (r: string) => Eff.Effect.Effect<void>
15+
}
1316

1417
const mapContent = (contentBlocks: BedrockRuntime.ContentBlocks | undefined) =>
1518
Eff.pipe(
@@ -116,43 +119,50 @@ function isAWSError(e: any): e is AWSSDK.AWSError {
116119
}
117120

118121
// a layer providing the service over aws.bedrock
119-
export function LLMBedrock(bedrock: BedrockRuntime) {
122+
export function LLMBedrock(bedrock: BedrockRuntime, options: BedrockOptions) {
120123
const converse = (prompt: LLM.Prompt, opts?: LLM.Options) =>
121124
Log.scoped({
122125
name: `${MODULE}.converse`,
123-
enter: [
124-
Log.br,
125-
'model id:',
126-
MODEL_ID,
127-
Log.br,
128-
'prompt:',
129-
prompt,
130-
Log.br,
131-
'opts:',
132-
opts,
133-
],
126+
enter: [Log.br, 'prompt:', prompt, Log.br, 'opts:', opts],
134127
})(
135-
Eff.Effect.tryPromise({
136-
try: () =>
137-
bedrock
138-
.converse({
139-
modelId: MODEL_ID,
140-
system: [{ text: prompt.system }],
141-
messages: messagesToBedrock(prompt.messages),
142-
toolConfig: prompt.toolConfig && toolConfigToBedrock(prompt.toolConfig),
143-
...opts,
144-
})
145-
.promise()
146-
.then((backendResponse) => ({
147-
backendResponse,
148-
content: mapContent(backendResponse.output.message?.content),
149-
})),
150-
catch: (e) =>
151-
new LLM.LLMError({
152-
message: isAWSError(e)
153-
? `Bedrock error (${e.code}): ${e.message}`
154-
: `Unexpected error: ${e}`,
155-
}),
128+
Eff.Effect.gen(function* () {
129+
const requestTimestamp = new Date(yield* Eff.Clock.currentTimeMillis)
130+
const modelId = yield* options.modelId
131+
const requestBody = {
132+
modelId,
133+
system: [{ text: prompt.system }],
134+
messages: messagesToBedrock(prompt.messages),
135+
toolConfig: prompt.toolConfig && toolConfigToBedrock(prompt.toolConfig),
136+
...opts,
137+
}
138+
const backendResponse = yield* Eff.Effect.tryPromise({
139+
try: () => bedrock.converse(requestBody).promise(),
140+
catch: (e) =>
141+
new LLM.LLMError({
142+
message: isAWSError(e)
143+
? `Bedrock error (${e.code}): ${e.message}`
144+
: `Unexpected error: ${e}`,
145+
}),
146+
})
147+
const responseTimestamp = new Date(yield* Eff.Clock.currentTimeMillis)
148+
if (options.record) {
149+
const entry = JSON.stringify(
150+
{
151+
requestTimestamp,
152+
responseTimestamp,
153+
modelId,
154+
request: requestBody,
155+
response: backendResponse,
156+
},
157+
null,
158+
2,
159+
)
160+
yield* options.record(entry)
161+
}
162+
return {
163+
backendResponse,
164+
content: mapContent(backendResponse.output.message?.content),
165+
}
156166
}),
157167
)
158168

catalog/app/components/Assistant/UI/Chat/Chat.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,9 +387,10 @@ const useStyles = M.makeStyles((t) => ({
387387
interface ChatProps {
388388
state: Model.Assistant.API['state']
389389
dispatch: Model.Assistant.API['dispatch']
390+
devTools: Model.Assistant.API['devTools']
390391
}
391392

392-
export default function Chat({ state, dispatch }: ChatProps) {
393+
export default function Chat({ state, dispatch, devTools }: ChatProps) {
393394
const classes = useStyles()
394395
const scrollRef = React.useRef<HTMLDivElement>(null)
395396

@@ -431,7 +432,7 @@ export default function Chat({ state, dispatch }: ChatProps) {
431432
/>
432433
<M.Slide direction="down" mountOnEnter unmountOnExit in={devToolsOpen}>
433434
<M.Paper square className={classes.devTools}>
434-
<DevTools state={state} />
435+
<DevTools state={state} {...devTools} />
435436
</M.Paper>
436437
</M.Slide>
437438
<div className={classes.historyContainer}>

catalog/app/components/Assistant/UI/Chat/DevTools.tsx

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,146 @@
11
import * as Eff from 'effect'
22
import * as React from 'react'
33
import * as M from '@material-ui/core'
4+
import {
5+
Clear as ClearIcon,
6+
Delete as DeleteIcon,
7+
GetApp as GetAppIcon,
8+
} from '@material-ui/icons'
49

510
import JsonDisplay from 'components/JsonDisplay'
611

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

14+
const useModelIdOverrideStyles = M.makeStyles((t) => ({
15+
root: {
16+
margin: t.spacing(2, 0),
17+
padding: t.spacing(0, 2),
18+
},
19+
}))
20+
21+
type ModelIdOverrideProps = Model.Assistant.API['devTools']['modelIdOverride']
22+
23+
function ModelIdOverride({ value, setValue }: ModelIdOverrideProps) {
24+
const classes = useModelIdOverrideStyles()
25+
26+
const handleModelIdChange = React.useCallback(
27+
(event: React.ChangeEvent<HTMLInputElement>) => {
28+
setValue(event.target.value)
29+
},
30+
[setValue],
31+
)
32+
33+
const handleClear = React.useCallback(() => setValue(''), [setValue])
34+
35+
return (
36+
<div className={classes.root}>
37+
<M.TextField
38+
label="Bedrock Model ID"
39+
placeholder={Model.Assistant.DEFAULT_MODEL_ID}
40+
value={value}
41+
onChange={handleModelIdChange}
42+
fullWidth
43+
helperText="Leave empty to use default"
44+
InputLabelProps={{ shrink: true }}
45+
InputProps={{
46+
endAdornment: value ? (
47+
<M.InputAdornment position="end">
48+
<M.IconButton
49+
aria-label="Clear model ID override"
50+
onClick={handleClear}
51+
edge="end"
52+
size="small"
53+
>
54+
<M.Tooltip arrow title="Clear model ID override">
55+
<ClearIcon />
56+
</M.Tooltip>
57+
</M.IconButton>
58+
</M.InputAdornment>
59+
) : null,
60+
}}
61+
/>
62+
</div>
63+
)
64+
}
65+
66+
const useRecordingControlsStyles = M.makeStyles((t) => ({
67+
root: {
68+
alignItems: 'center',
69+
display: 'flex',
70+
gap: t.spacing(1),
71+
margin: t.spacing(2, 0),
72+
padding: t.spacing(0, 2),
73+
},
74+
label: {
75+
...t.typography.body2,
76+
flexGrow: 1,
77+
padding: t.spacing(0, 2),
78+
},
79+
}))
80+
81+
type RecordingControlsProps = Model.Assistant.API['devTools']['recording']
82+
83+
function RecordingControls({ enabled, log, enable, clear }: RecordingControlsProps) {
84+
const classes = useRecordingControlsStyles()
85+
86+
const handleToggleRecording = React.useCallback(() => {
87+
enable(!enabled)
88+
}, [enabled, enable])
89+
90+
const handleDownload = React.useCallback(() => {
91+
const data = `[\n${log.join(',\n')}\n]`
92+
const blob = new Blob([data], { type: 'application/json' })
93+
const url = URL.createObjectURL(blob)
94+
const a = document.createElement('a')
95+
a.href = url
96+
a.download = `qurator-session-${new Date().toISOString()}.json`
97+
document.body.appendChild(a)
98+
a.click()
99+
document.body.removeChild(a)
100+
URL.revokeObjectURL(url)
101+
}, [log])
102+
103+
return (
104+
<div className={classes.root}>
105+
<M.Button
106+
onClick={handleToggleRecording}
107+
variant="contained"
108+
color="primary"
109+
size="small"
110+
>
111+
{enabled ? 'Stop' : 'Start'} Recording
112+
</M.Button>
113+
114+
{(enabled || log.length > 0) && (
115+
<div className={classes.label}>
116+
{log.length > 0 ? `${log.length} item(s) recorded` : 'Recording...'}
117+
</div>
118+
)}
119+
120+
{log.length > 0 && (
121+
<>
122+
<M.Button
123+
onClick={handleDownload}
124+
size="small"
125+
variant="outlined"
126+
startIcon={<GetAppIcon />}
127+
>
128+
Download Log
129+
</M.Button>
130+
<M.Button
131+
onClick={clear}
132+
size="small"
133+
variant="outlined"
134+
startIcon={<DeleteIcon />}
135+
>
136+
Clear Log
137+
</M.Button>
138+
</>
139+
)}
140+
</div>
141+
)
142+
}
143+
9144
const useStyles = M.makeStyles((t) => ({
10145
root: {
11146
display: 'flex',
@@ -30,9 +165,11 @@ const useStyles = M.makeStyles((t) => ({
30165

31166
interface DevToolsProps {
32167
state: Model.Assistant.API['state']
168+
modelIdOverride: Model.Assistant.API['devTools']['modelIdOverride']
169+
recording: Model.Assistant.API['devTools']['recording']
33170
}
34171

35-
export default function DevTools({ state }: DevToolsProps) {
172+
export default function DevTools({ state, modelIdOverride, recording }: DevToolsProps) {
36173
const classes = useStyles()
37174

38175
const context = Model.Context.useAggregatedContext()
@@ -52,6 +189,10 @@ export default function DevTools({ state }: DevToolsProps) {
52189
<section className={classes.root}>
53190
<h1 className={classes.heading}>Qurator Developer Tools</h1>
54191
<div className={classes.contents}>
192+
<ModelIdOverride {...modelIdOverride} />
193+
<M.Divider />
194+
<RecordingControls {...recording} />
195+
<M.Divider />
55196
<JsonDisplay className={classes.json} name="Context" value={context} />
56197
<JsonDisplay className={classes.json} name="State" value={state} />
57198
<JsonDisplay className={classes.json} name="Prompt" value={prompt} />

catalog/app/components/Assistant/UI/UI.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ function Sidebar() {
2626
<M.MuiThemeProvider theme={style.appTheme}>
2727
<M.Drawer anchor="right" open={api.visible} onClose={api.hide}>
2828
<div className={classes.sidebar}>
29-
<Chat state={api.state} dispatch={api.dispatch} />
29+
<Chat state={api.state} dispatch={api.dispatch} devTools={api.devTools} />
3030
</div>
3131
</M.Drawer>
3232
</M.MuiThemeProvider>

0 commit comments

Comments
 (0)