Skip to content

Commit 43a3eb5

Browse files
committed
✨ Add Transcript tab in Results details modal
Closes baptisteArno#1263
1 parent 77eb695 commit 43a3eb5

File tree

5 files changed

+431
-18
lines changed

5 files changed

+431
-18
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { authenticatedProcedure } from "@/helpers/server/trpc";
2+
import { TRPCError } from "@trpc/server";
3+
import { computeResultTranscript } from "@typebot.io/bot-engine/computeResultTranscript";
4+
import { typebotInSessionStateSchema } from "@typebot.io/chat-session/schemas";
5+
import prisma from "@typebot.io/prisma";
6+
import type { Answer } from "@typebot.io/results/schemas/answers";
7+
import { SessionStore } from "@typebot.io/runtime-session-store";
8+
import { isReadTypebotForbidden } from "@typebot.io/typebot/helpers/isReadTypebotForbidden";
9+
import { z } from "@typebot.io/zod";
10+
11+
export const getResultTranscript = authenticatedProcedure
12+
.meta({
13+
openapi: {
14+
method: "GET",
15+
path: "/v1/typebots/{typebotId}/results/{resultId}/transcript",
16+
protect: true,
17+
summary: "Get result transcript",
18+
tags: ["Results"],
19+
},
20+
})
21+
.input(
22+
z.object({
23+
typebotId: z
24+
.string()
25+
.describe(
26+
"[Where to find my bot's ID?](../how-to#how-to-find-my-typebotid)",
27+
),
28+
resultId: z
29+
.string()
30+
.describe(
31+
"The `resultId` is returned by the /startChat endpoint or you can find it by listing results with `/results` endpoint",
32+
),
33+
}),
34+
)
35+
.output(
36+
z.object({
37+
transcript: z.array(
38+
z.object({
39+
role: z.enum(["bot", "user"]),
40+
type: z.enum(["text", "image", "video", "audio"]),
41+
text: z.string().optional(),
42+
image: z.string().optional(),
43+
video: z.string().optional(),
44+
audio: z.string().optional(),
45+
}),
46+
),
47+
}),
48+
)
49+
.query(async ({ input, ctx: { user } }) => {
50+
// Fetch typebot with necessary data for transcript computation
51+
const typebot = await prisma.typebot.findUnique({
52+
where: {
53+
id: input.typebotId,
54+
},
55+
select: {
56+
id: true,
57+
version: true,
58+
groups: true,
59+
edges: true,
60+
variables: true,
61+
events: true,
62+
workspace: {
63+
select: {
64+
isSuspended: true,
65+
isPastDue: true,
66+
members: {
67+
select: {
68+
userId: true,
69+
},
70+
},
71+
},
72+
},
73+
collaborators: {
74+
select: {
75+
userId: true,
76+
type: true,
77+
},
78+
},
79+
},
80+
});
81+
82+
if (!typebot || (await isReadTypebotForbidden(typebot, user)))
83+
throw new TRPCError({ code: "NOT_FOUND", message: "Typebot not found" });
84+
85+
// Fetch result data
86+
const result = await prisma.result.findUnique({
87+
where: {
88+
id: input.resultId,
89+
typebotId: typebot.id,
90+
},
91+
select: {
92+
answers: {
93+
select: {
94+
blockId: true,
95+
content: true,
96+
createdAt: true,
97+
},
98+
},
99+
answersV2: {
100+
select: {
101+
blockId: true,
102+
content: true,
103+
createdAt: true,
104+
attachedFileUrls: true,
105+
},
106+
},
107+
edges: {
108+
select: {
109+
edgeId: true,
110+
index: true,
111+
},
112+
},
113+
setVariableHistory: {
114+
select: {
115+
blockId: true,
116+
variableId: true,
117+
value: true,
118+
index: true,
119+
},
120+
},
121+
},
122+
});
123+
124+
if (!result)
125+
throw new TRPCError({ code: "NOT_FOUND", message: "Result not found" });
126+
127+
const answers = [...result.answersV2, ...result.answers]
128+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
129+
.map<Answer>((answer) => ({
130+
blockId: answer.blockId,
131+
content: answer.content,
132+
attachedFileUrls:
133+
"attachedFileUrls" in answer && answer.attachedFileUrls
134+
? (answer.attachedFileUrls as string[])
135+
: undefined,
136+
}));
137+
138+
const visitedEdges = result.edges
139+
.sort((a, b) => a.index - b.index)
140+
.map((edge) => edge.edgeId);
141+
142+
const setVariableHistory = result.setVariableHistory
143+
.sort((a, b) => a.index - b.index)
144+
.map(({ blockId, variableId, value }) => ({
145+
blockId,
146+
variableId,
147+
value: value as string | (string | null)[] | null,
148+
}));
149+
150+
const transcript = computeResultTranscript({
151+
typebot: typebotInSessionStateSchema.parse(typebot),
152+
answers,
153+
setVariableHistory,
154+
visitedEdges,
155+
sessionStore: new SessionStore(),
156+
});
157+
158+
return { transcript };
159+
});

apps/builder/src/features/results/api/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { router } from "@/helpers/server/trpc";
22
import { deleteResults } from "./deleteResults";
33
import { getResult } from "./getResult";
44
import { getResultLogs } from "./getResultLogs";
5+
import { getResultTranscript } from "./getResultTranscript";
56
import { getResults } from "./getResults";
67

78
export const resultsRouter = router({
89
getResults,
910
getResult,
11+
getResultTranscript,
1012
deleteResults,
1113
getResultLogs,
1214
});

apps/builder/src/features/results/components/ResultModal.tsx

Lines changed: 106 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import { useTypebot } from "@/features/editor/providers/TypebotProvider";
22
import {
3+
Box,
4+
Button,
35
HStack,
46
Heading,
57
Modal,
68
ModalBody,
79
ModalCloseButton,
810
ModalContent,
911
ModalOverlay,
12+
Spinner,
1013
Stack,
14+
Tag,
1115
Text,
16+
useColorModeValue,
1217
} from "@chakra-ui/react";
1318
import { byId, isDefined } from "@typebot.io/lib/utils";
1419
import { parseColumnsOrder } from "@typebot.io/results/parseColumnsOrder";
15-
import React from "react";
20+
import React, { useState } from "react";
1621
import { useResults } from "../ResultsProvider";
22+
import { useResultTranscriptQuery } from "../hooks/useResultTranscriptQuery";
1723
import { HeaderIcon } from "./HeaderIcon";
1824

1925
type Props = {
@@ -22,12 +28,23 @@ type Props = {
2228
};
2329

2430
export const ResultModal = ({ resultId, onClose }: Props) => {
31+
const hostBubbleBgColor = useColorModeValue("gray.100", "gray.900");
32+
const hostBubbleColor = useColorModeValue("gray.800", "gray.200");
33+
const bgColor = useColorModeValue("white", "gray.950");
34+
const [tab, setTab] = useState<"transcript" | "answers">("transcript");
2535
const { tableData, resultHeader } = useResults();
2636
const { typebot } = useTypebot();
2737
const result = isDefined(resultId)
2838
? tableData.find((data) => data.id.plainText === resultId)
2939
: undefined;
3040

41+
const { data: transcriptData, isLoading: isTranscriptLoading } =
42+
useResultTranscriptQuery({
43+
typebotId: typebot?.id ?? "",
44+
resultId: resultId ?? "",
45+
enabled: isDefined(resultId) && isDefined(typebot?.id),
46+
});
47+
3148
const columnsOrder = parseColumnsOrder(
3249
typebot?.resultsTablePreferences?.columnsOrder,
3350
resultHeader,
@@ -37,28 +54,99 @@ export const ResultModal = ({ resultId, onClose }: Props) => {
3754
val: string | { plainText: string; element?: JSX.Element | undefined },
3855
) => (typeof val === "string" ? val : (val.element ?? val.plainText));
3956

57+
const renderTranscriptMessage = (message: any, index: number) => {
58+
const isBot = message.role === "bot";
59+
const content =
60+
message.text || message.image || message.video || message.audio || "";
61+
62+
return (
63+
<HStack
64+
key={index}
65+
justify={isBot ? "flex-start" : "flex-end"}
66+
w="full"
67+
mb={2}
68+
>
69+
<Box
70+
maxW="70%"
71+
bg={isBot ? hostBubbleBgColor : "orange.500"}
72+
color={isBot ? hostBubbleColor : "white"}
73+
borderWidth={1}
74+
px={3}
75+
py={2}
76+
borderRadius="lg"
77+
borderBottomLeftRadius={isBot ? "sm" : "lg"}
78+
borderBottomRightRadius={isBot ? "lg" : "sm"}
79+
>
80+
<Text whiteSpace="pre-wrap" fontSize="sm">
81+
{message.type === "text"
82+
? content
83+
: `[${message.type.toUpperCase()}] ${content}`}
84+
</Text>
85+
</Box>
86+
</HStack>
87+
);
88+
};
89+
90+
if (isTranscriptLoading)
91+
return (
92+
<Stack align="center" py={8}>
93+
<Spinner />
94+
<Text>Loading transcript...</Text>
95+
</Stack>
96+
);
4097
return (
4198
<Modal isOpen={isDefined(result)} onClose={onClose} size="2xl">
4299
<ModalOverlay />
43100
<ModalContent>
44101
<ModalCloseButton />
45-
<ModalBody as={Stack} p="10" spacing="10">
46-
{columnsOrder.map((headerId) => {
47-
if (!result || !result[headerId]) return null;
48-
const header = resultHeader.find(byId(headerId));
49-
if (!header) return null;
50-
return (
51-
<Stack key={header.id} spacing="4">
52-
<HStack>
53-
<HeaderIcon header={header} />
54-
<Heading fontSize="md">{header.label}</Heading>
55-
</HStack>
56-
<Text whiteSpace="pre-wrap" textAlign="justify">
57-
{getHeaderValue(result[header.id])}
58-
</Text>
59-
</Stack>
60-
);
61-
})}
102+
<ModalBody as={Stack} p="6" spacing="6">
103+
<HStack>
104+
<Button
105+
variant={tab === "transcript" ? "outline" : "ghost"}
106+
onClick={() => setTab("transcript")}
107+
colorScheme={tab === "transcript" ? "orange" : "gray"}
108+
size="sm"
109+
>
110+
Transcript
111+
<Tag size="sm" colorScheme="orange" ml={1}>
112+
Beta
113+
</Tag>
114+
</Button>
115+
<Button
116+
variant={tab === "answers" ? "outline" : "ghost"}
117+
onClick={() => setTab("answers")}
118+
colorScheme={tab === "answers" ? "orange" : "gray"}
119+
size="sm"
120+
>
121+
Answers
122+
</Button>
123+
</HStack>
124+
125+
{tab === "transcript" && (
126+
<Box borderWidth={1} borderRadius="md" p={4} bg={bgColor}>
127+
{transcriptData?.transcript.map(renderTranscriptMessage)}
128+
</Box>
129+
)}
130+
{tab === "answers" && (
131+
<Stack spacing="6">
132+
{columnsOrder.map((headerId) => {
133+
if (!result || !result[headerId]) return null;
134+
const header = resultHeader.find(byId(headerId));
135+
if (!header) return null;
136+
return (
137+
<Stack key={header.id} spacing="4">
138+
<HStack>
139+
<HeaderIcon header={header} />
140+
<Heading fontSize="md">{header.label}</Heading>
141+
</HStack>
142+
<Text whiteSpace="pre-wrap" textAlign="justify">
143+
{getHeaderValue(result[header.id])}
144+
</Text>
145+
</Stack>
146+
);
147+
})}
148+
</Stack>
149+
)}
62150
</ModalBody>
63151
</ModalContent>
64152
</Modal>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { trpc } from "@/lib/queryClient";
2+
import { useQuery } from "@tanstack/react-query";
3+
4+
type Params = {
5+
typebotId: string;
6+
resultId: string;
7+
enabled?: boolean;
8+
};
9+
10+
export const useResultTranscriptQuery = ({
11+
typebotId,
12+
resultId,
13+
enabled = false,
14+
}: Params) => {
15+
return useQuery(
16+
trpc.results.getResultTranscript.queryOptions(
17+
{ typebotId, resultId },
18+
{ enabled },
19+
),
20+
);
21+
};

0 commit comments

Comments
 (0)