Skip to content

Commit ae632cb

Browse files
committed
draft: conversation view of hydrated convo
1 parent e2e3345 commit ae632cb

File tree

4 files changed

+240
-1
lines changed

4 files changed

+240
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { type ReactNode, useRef, useEffect } from "react"
2+
import { debounce } from "lodash-es"
3+
import type {
4+
RealtimeConversationItem,
5+
RealtimeConversationItemContent,
6+
} from "@tsorta/browser/openai"
7+
import {
8+
DefinedRole,
9+
RealtimeConversationItemSimple,
10+
simplifyItem,
11+
} from "./simpleConversation"
12+
13+
const log = console
14+
15+
export interface ConversationProps {
16+
conversation: RealtimeConversationItemSimple[]
17+
}
18+
19+
export const ConversationView = ({
20+
conversation,
21+
}: ConversationProps): ReactNode => {
22+
return (
23+
<div className="conversation d-flex flex-column overflow-y-scroll flex-grow-1">
24+
{conversation
25+
.filter((item) => item.role !== "system")
26+
.map((item, index, arr) => (
27+
<ConversationItem
28+
key={item.id}
29+
item={item}
30+
doScrollIntoView={index === arr.length - 1}
31+
/>
32+
))}
33+
</div>
34+
)
35+
}
36+
37+
const RoleBgColorMap: Record<DefinedRole, string> = {
38+
user: "bg-primary-subtle",
39+
assistant: "bg-secondary-subtle",
40+
system: "bg-secondary",
41+
}
42+
const RoleTextColorMap: Record<DefinedRole, string> = {
43+
user: "text-primary",
44+
assistant: "text-secondary",
45+
system: "text-secondary",
46+
}
47+
const RoleLabelMap: Record<DefinedRole, string> = {
48+
user: "You",
49+
assistant: "Assistant",
50+
system: "System",
51+
}
52+
53+
interface ConversationItemProps {
54+
item: RealtimeConversationItem
55+
doScrollIntoView: boolean
56+
}
57+
58+
const ConversationItem = ({
59+
item,
60+
doScrollIntoView,
61+
}: ConversationItemProps): ReactNode => {
62+
const simpleItem = simplifyItem(item)
63+
const { id, role, content } = simpleItem
64+
const alignClass = role === "user" ? "align-self-end" : "align-self-start"
65+
66+
const divRef = useRef<HTMLDivElement>(null)
67+
68+
useEffect(() => {
69+
const executeScroll = () => {
70+
// behavior: smooth works fine on FF+macOS, but not on Chrome+macOS, so we detect and force "instant" on chrome
71+
const behavior = /Chrome/.test(navigator.userAgent) ? "instant" : "smooth"
72+
divRef.current?.scrollIntoView({
73+
behavior,
74+
block: "end",
75+
inline: "nearest",
76+
})
77+
}
78+
79+
if (doScrollIntoView) {
80+
debounce(executeScroll, 500)()
81+
}
82+
}, [item, doScrollIntoView, item.content, simpleItem.content.length])
83+
84+
return (
85+
<div
86+
className={`conversation-item ${alignClass}`}
87+
style={{ maxWidth: "60%" }}
88+
ref={divRef}
89+
>
90+
<div className="attribution mx-8 mb-1 mt-6 text-xs text-gray-500 text-muted">
91+
<small>{RoleLabelMap[role]}</small>
92+
</div>
93+
94+
<div
95+
key={id}
96+
className={`mb-5 p-3 rounded-pill ${RoleBgColorMap[role]} ${RoleTextColorMap[role]}`}
97+
>
98+
<div className="my-content">
99+
{content.map((contentItem, index) => (
100+
<ConversationItemContent
101+
key={index}
102+
content={contentItem}
103+
doScrollIntoView={doScrollIntoView}
104+
/>
105+
))}
106+
</div>
107+
</div>
108+
</div>
109+
)
110+
}
111+
112+
interface ConversationItemContentProps {
113+
content: RealtimeConversationItemContent
114+
doScrollIntoView: boolean
115+
}
116+
117+
const ConversationItemContent = ({
118+
content,
119+
}: ConversationItemContentProps): ReactNode => {
120+
if (!["text", "input_text", "input_audio"].includes(content.type)) {
121+
// NOTE: we find content.type="audio" coming in here in logging though it is not in the types!
122+
if ((content.type as string) !== "audio") {
123+
log.warn(
124+
`Unexpected type for RealtimeConversationItemContent '${content.type}'. Will not be rendered: %o`,
125+
content
126+
)
127+
}
128+
return null
129+
}
130+
131+
return (
132+
<div className={`item-content item-type-${content.type}`}>
133+
{(content.type == "text" || content.type == "input_text") && (
134+
<span>{content.text}</span>
135+
)}
136+
{content.type == "input_audio" && (
137+
<span className="input_audio transcript">
138+
{content.transcript ? content.transcript : "..."}
139+
</span>
140+
)}
141+
</div>
142+
)
143+
}

apps/browser-example/src/components/RealtimeSessionView.tsx

+50-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BootstrapIcon } from "./BootstrapIcon"
33
import { EventList } from "./EventList"
44
import { useModal } from "../hooks/useModal"
55
import { RealtimeSessionCreateRequest } from "@tsorta/browser/openai"
6+
import { ConversationView } from "./ConversationView"
67

78
type PartialSessionRequestWithModel = Partial<RealtimeSessionCreateRequest> &
89
Pick<Required<RealtimeSessionCreateRequest>, "model">
@@ -131,7 +132,55 @@ export function RealtimeSessionView({
131132
</li>
132133
</ul>
133134

134-
<h2>Events:</h2>
135+
<ul className="nav nav-tabs mb-3" role="tablist">
136+
<li className="nav-item" role="presentation">
137+
<button
138+
className="nav-link active"
139+
id="events-tab"
140+
data-bs-toggle="tab"
141+
data-bs-target="#events"
142+
type="button"
143+
role="tab"
144+
aria-controls="events"
145+
aria-selected="true"
146+
>
147+
Events
148+
</button>
149+
</li>
150+
<li className="nav-item" role="presentation">
151+
<button
152+
className="nav-link"
153+
id="conversation-tab"
154+
data-bs-toggle="tab"
155+
data-bs-target="#conversation"
156+
type="button"
157+
role="tab"
158+
aria-controls="conversation"
159+
aria-selected="false"
160+
>
161+
Conversation
162+
</button>
163+
</li>
164+
</ul>
165+
<div className="tab-content">
166+
<div
167+
className="tab-pane fade show active"
168+
id="events"
169+
role="tabpanel"
170+
aria-labelledby="events-tab"
171+
>
172+
<EventList events={events} />
173+
</div>
174+
<div
175+
className="tab-pane fade"
176+
id="conversation"
177+
role="tabpanel"
178+
aria-labelledby="conversation-tab"
179+
>
180+
<div>TODO</div>
181+
{/*<ConversationView conversation={} />*/}
182+
</div>
183+
</div>
135184
<EventList events={events} />
136185
</div>
137186
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
RealtimeConversationItem,
3+
RealtimeConversationItemContent,
4+
} from "@tsorta/browser/openai"
5+
6+
/** Removes the possibility of `undefined` in @see RealtimeConversationItem.role. */
7+
export type DefinedRole = NonNullable<RealtimeConversationItem["role"]>
8+
9+
/** Removes the possibility of `undefined` in @see RealtimeConversationItem.type. */
10+
type DefinedRealtimeConversationItemType = NonNullable<
11+
RealtimeConversationItem["type"]
12+
>
13+
14+
export type RealtimeConversationItemSimple = Pick<
15+
RealtimeConversationItem,
16+
"id"
17+
> & {
18+
role: DefinedRole
19+
type: DefinedRealtimeConversationItemType
20+
content: RealtimeConversationItemContent[]
21+
}
22+
23+
export function simplifyItem(
24+
item: RealtimeConversationItem
25+
): RealtimeConversationItemSimple {
26+
if (!item.role) {
27+
throw new Error("Role missing in conversation item")
28+
}
29+
const role: DefinedRole = item.role
30+
const id = item.id || "id-missing"
31+
const type = item.type as DefinedRealtimeConversationItemType
32+
// NOTE: There may be no contents on the initial creation while the model is still replying.
33+
const content: RealtimeConversationItemContent[] = (item.content ||
34+
[]) as RealtimeConversationItemContent[]
35+
36+
return { id, type, role, content }
37+
}

apps/browser-example/src/pages/WebRTCExample.tsx

+10
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "../components/RealtimeSessionView"
66
import { RealtimeClient } from "@tsorta/browser/WebRTC"
77
import { PageProps } from "./props"
8+
import { RealtimeConversationItem } from "@tsorta/browser/openai"
89

910
export function WebRTCExample({
1011
apiKey,
@@ -15,6 +16,9 @@ export function WebRTCExample({
1516

1617
const [client, setClient] = useState<RealtimeClient | undefined>(undefined)
1718
const [events, setEvents] = useState<any[]>([])
19+
const [conversation, setConversation] = useState<RealtimeConversationItem[]>(
20+
[]
21+
)
1822

1923
const startSession = useCallback(
2024
async function startSession({
@@ -52,6 +56,11 @@ export function WebRTCExample({
5256
setEvents((events) => [...events, event.event])
5357
})
5458

59+
client.addEventListener("conversationChanged", (event) => {
60+
console.debug("conversationChanged event:", event.conversation)
61+
setConversation(event.conversation)
62+
})
63+
5564
await client.start()
5665

5766
onSessionStatusChanged("recording")
@@ -79,6 +88,7 @@ export function WebRTCExample({
7988
by Scott Willeke.
8089
</p>
8190
<audio ref={audioElementRef}></audio>
91+
8292
<RealtimeSessionView
8393
startSession={startSession}
8494
stopSession={stopSession}

0 commit comments

Comments
 (0)