Skip to content

Commit d3c1c93

Browse files
committed
assistant devtools: session recording
1 parent 4dad888 commit d3c1c93

File tree

5 files changed

+193
-51
lines changed

5 files changed

+193
-51
lines changed

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: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,11 @@ import * as LLM from './LLM'
99

1010
const MODULE = 'Bedrock'
1111

12-
export const MODEL_ID_KEY = 'QUILT_BEDROCK_MODEL_ID'
13-
export const DEFAULT_MODEL_ID = 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'
14-
15-
export const getModelIdOverride = () =>
16-
(typeof localStorage !== 'undefined' && localStorage.getItem(MODEL_ID_KEY)) || ''
17-
18-
export const setModelIdOverride = (modelId: string) => {
19-
if (typeof localStorage !== 'undefined') {
20-
if (modelId) {
21-
localStorage.setItem(MODEL_ID_KEY, modelId)
22-
} else {
23-
localStorage.removeItem(MODEL_ID_KEY)
24-
}
25-
}
12+
interface BedrockOptions {
13+
modelId: Eff.Effect.Effect<string>
14+
record?: (r: string) => Eff.Effect.Effect<void>
2615
}
2716

28-
const getModelId = Eff.Effect.sync(() => getModelIdOverride() || DEFAULT_MODEL_ID)
29-
3017
const mapContent = (contentBlocks: BedrockRuntime.ContentBlocks | undefined) =>
3118
Eff.pipe(
3219
contentBlocks,
@@ -132,36 +119,50 @@ function isAWSError(e: any): e is AWSSDK.AWSError {
132119
}
133120

134121
// a layer providing the service over aws.bedrock
135-
export function LLMBedrock(bedrock: BedrockRuntime) {
122+
export function LLMBedrock(bedrock: BedrockRuntime, options: BedrockOptions) {
136123
const converse = (prompt: LLM.Prompt, opts?: LLM.Options) =>
137124
Log.scoped({
138125
name: `${MODULE}.converse`,
139126
enter: [Log.br, 'prompt:', prompt, Log.br, 'opts:', opts],
140127
})(
141128
Eff.Effect.gen(function* () {
142-
const modelId = yield* getModelId
143-
return yield* Eff.Effect.tryPromise({
144-
try: () =>
145-
bedrock
146-
.converse({
147-
modelId,
148-
system: [{ text: prompt.system }],
149-
messages: messagesToBedrock(prompt.messages),
150-
toolConfig: prompt.toolConfig && toolConfigToBedrock(prompt.toolConfig),
151-
...opts,
152-
})
153-
.promise()
154-
.then((backendResponse) => ({
155-
backendResponse,
156-
content: mapContent(backendResponse.output.message?.content),
157-
})),
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(),
158140
catch: (e) =>
159141
new LLM.LLMError({
160142
message: isAWSError(e)
161143
? `Bedrock error (${e.code}): ${e.message}`
162144
: `Unexpected error: ${e}`,
163145
}),
164146
})
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+
}
165166
}),
166167
)
167168

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: 96 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import * as Eff from 'effect'
22
import * as React from 'react'
33
import * as M from '@material-ui/core'
44
import ClearIcon from '@material-ui/icons/Clear'
5+
import DeleteIcon from '@material-ui/icons/Delete'
6+
import GetAppIcon from '@material-ui/icons/GetApp'
57

68
import JsonDisplay from 'components/JsonDisplay'
79

810
import * as Model from '../../Model'
9-
import * as Bedrock from '../../Model/Bedrock'
1011

1112
const useModelIdOverrideStyles = M.makeStyles((t) => ({
1213
root: {
@@ -15,34 +16,32 @@ const useModelIdOverrideStyles = M.makeStyles((t) => ({
1516
},
1617
}))
1718

18-
function ModelIdOverride() {
19-
const classes = useModelIdOverrideStyles()
19+
type ModelIdOverrideProps = Model.Assistant.API['devTools']['modelIdOverride']
2020

21-
const [modelId, setModelId] = React.useState(Bedrock.getModelIdOverride)
21+
function ModelIdOverride({ value, setValue }: ModelIdOverrideProps) {
22+
const classes = useModelIdOverrideStyles()
2223

2324
const handleModelIdChange = React.useCallback(
2425
(event: React.ChangeEvent<HTMLInputElement>) => {
25-
setModelId(event.target.value)
26+
setValue(event.target.value)
2627
},
27-
[],
28+
[setValue],
2829
)
2930

30-
React.useEffect(() => Bedrock.setModelIdOverride(modelId), [modelId])
31-
32-
const handleClear = React.useCallback(() => setModelId(''), [])
31+
const handleClear = React.useCallback(() => setValue(''), [setValue])
3332

3433
return (
3534
<div className={classes.root}>
3635
<M.TextField
3736
label="Bedrock Model ID"
38-
placeholder={Bedrock.DEFAULT_MODEL_ID}
39-
value={modelId}
37+
placeholder={Model.Assistant.DEFAULT_MODEL_ID}
38+
value={value}
4039
onChange={handleModelIdChange}
4140
fullWidth
4241
helperText="Leave empty to use default"
4342
InputLabelProps={{ shrink: true }}
4443
InputProps={{
45-
endAdornment: modelId ? (
44+
endAdornment: value ? (
4645
<M.InputAdornment position="end">
4746
<M.IconButton
4847
aria-label="Clear model ID override"
@@ -62,6 +61,84 @@ function ModelIdOverride() {
6261
)
6362
}
6463

64+
const useRecordingControlsStyles = M.makeStyles((t) => ({
65+
root: {
66+
alignItems: 'center',
67+
display: 'flex',
68+
gap: t.spacing(1),
69+
margin: t.spacing(2, 0),
70+
padding: t.spacing(0, 2),
71+
},
72+
label: {
73+
...t.typography.body2,
74+
flexGrow: 1,
75+
padding: t.spacing(0, 2),
76+
},
77+
}))
78+
79+
type RecordingControlsProps = Model.Assistant.API['devTools']['recording']
80+
81+
function RecordingControls({ enabled, log, enable, clear }: RecordingControlsProps) {
82+
const classes = useRecordingControlsStyles()
83+
84+
const handleToggleRecording = React.useCallback(() => {
85+
enable(!enabled)
86+
}, [enabled, enable])
87+
88+
const handleDownload = React.useCallback(() => {
89+
const data = `[\n${log.join(',\n')}\n]`
90+
const blob = new Blob([data], { type: 'application/json' })
91+
const url = URL.createObjectURL(blob)
92+
const a = document.createElement('a')
93+
a.href = url
94+
a.download = `qurator-session-${new Date().toISOString()}.json`
95+
document.body.appendChild(a)
96+
a.click()
97+
document.body.removeChild(a)
98+
URL.revokeObjectURL(url)
99+
}, [log])
100+
101+
return (
102+
<div className={classes.root}>
103+
<M.Button
104+
onClick={handleToggleRecording}
105+
variant="contained"
106+
color="primary"
107+
size="small"
108+
>
109+
{enabled ? 'Stop' : 'Start'} Recording
110+
</M.Button>
111+
112+
{(enabled || log.length > 0) && (
113+
<div className={classes.label}>
114+
{log.length > 0 ? `${log.length} item(s) recorded` : 'Recording...'}
115+
</div>
116+
)}
117+
118+
{log.length > 0 && (
119+
<>
120+
<M.Button
121+
onClick={handleDownload}
122+
size="small"
123+
variant="outlined"
124+
startIcon={<GetAppIcon />}
125+
>
126+
Download Log
127+
</M.Button>
128+
<M.Button
129+
onClick={clear}
130+
size="small"
131+
variant="outlined"
132+
startIcon={<DeleteIcon />}
133+
>
134+
Clear Log
135+
</M.Button>
136+
</>
137+
)}
138+
</div>
139+
)
140+
}
141+
65142
const useStyles = M.makeStyles((t) => ({
66143
root: {
67144
display: 'flex',
@@ -86,9 +163,11 @@ const useStyles = M.makeStyles((t) => ({
86163

87164
interface DevToolsProps {
88165
state: Model.Assistant.API['state']
166+
modelIdOverride: Model.Assistant.API['devTools']['modelIdOverride']
167+
recording: Model.Assistant.API['devTools']['recording']
89168
}
90169

91-
export default function DevTools({ state }: DevToolsProps) {
170+
export default function DevTools({ state, modelIdOverride, recording }: DevToolsProps) {
92171
const classes = useStyles()
93172

94173
const context = Model.Context.useAggregatedContext()
@@ -108,7 +187,10 @@ export default function DevTools({ state }: DevToolsProps) {
108187
<section className={classes.root}>
109188
<h1 className={classes.heading}>Qurator Developer Tools</h1>
110189
<div className={classes.contents}>
111-
<ModelIdOverride />
190+
<ModelIdOverride {...modelIdOverride} />
191+
<M.Divider />
192+
<RecordingControls {...recording} />
193+
<M.Divider />
112194
<JsonDisplay className={classes.json} name="Context" value={context} />
113195
<JsonDisplay className={classes.json} name="State" value={state} />
114196
<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)