Skip to content

Commit 6128386

Browse files
authored
ECHO-702 Allow custom host prompts in verify flow (#470)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Create, edit, and delete custom verification topics from the project portal (with translations and emoji icons). * Artifacts display topic labels with expanded fallback sources for clearer names. * **Improvements** * New modal UI for managing custom topics with validation and confirmation flows. * Safer topic deletion (prevents deleting the last topic) and improved UX for topic icons and multi-language support. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent db6ef1e commit 6128386

23 files changed

Lines changed: 2074 additions & 564 deletions

File tree

echo/directus/sync/snapshot/fields/conversation_artifact/conversation_id.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"readonly": false,
2020
"required": false,
2121
"searchable": true,
22-
"sort": 10,
22+
"sort": 11,
2323
"special": [
2424
"m2o"
2525
],
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"collection": "conversation_artifact",
3+
"field": "topic_label",
4+
"type": "string",
5+
"meta": {
6+
"collection": "conversation_artifact",
7+
"conditions": null,
8+
"display": null,
9+
"display_options": null,
10+
"field": "topic_label",
11+
"group": null,
12+
"hidden": false,
13+
"interface": "input",
14+
"note": null,
15+
"options": null,
16+
"readonly": false,
17+
"required": false,
18+
"searchable": true,
19+
"sort": 10,
20+
"special": null,
21+
"translations": null,
22+
"validation": null,
23+
"validation_message": null,
24+
"width": "full"
25+
},
26+
"schema": {
27+
"name": "topic_label",
28+
"table": "conversation_artifact",
29+
"data_type": "character varying",
30+
"default_value": null,
31+
"max_length": 255,
32+
"numeric_precision": null,
33+
"numeric_scale": null,
34+
"is_nullable": true,
35+
"is_unique": false,
36+
"is_indexed": false,
37+
"is_primary_key": false,
38+
"is_generated": false,
39+
"generation_expression": null,
40+
"has_auto_increment": false,
41+
"foreign_key_table": null,
42+
"foreign_key_column": null
43+
}
44+
}

echo/frontend/src/components/conversation/VerifiedArtefactsSection.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ export const VerifiedArtefactsSection = ({
116116
<Group gap="sm" wrap="nowrap">
117117
<Stack gap={2}>
118118
<Text fw={500}>
119-
{topicLabelMap.get(artefact.key) ?? artefact.key ?? ""}
119+
{topicLabelMap.get(artefact.key) ??
120+
artefact.topic_label ??
121+
artefact.key ??
122+
""}
120123
</Text>
121124
{formattedDate && (
122125
<Text size="xs" c="dimmed">

echo/frontend/src/components/participant/verify/VerifiedArtefactsList.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ export const VerifiedArtefactsList = ({
9393
<VerifiedArtefactItem
9494
key={artefact.id}
9595
artefact={artefact}
96-
label={topicMetadataMap.get(artefact.key)?.label ?? artefact.key}
96+
label={
97+
topicMetadataMap.get(artefact.key)?.label ??
98+
artefact.topic_label ??
99+
artefact.key
100+
}
97101
icon={topicMetadataMap.get(artefact.key)?.icon}
98102
onViewArtefact={handleViewArtefact}
99103
dataTestId={`portal-verified-artefact-item-${index}`}

echo/frontend/src/components/participant/verify/VerifySelection.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export const VerifySelection = () => {
8282
const icon =
8383
TOPIC_ICON_MAP[topic.key] ??
8484
(topic.icon && !topic.icon.startsWith(":") ? topic.icon : undefined) ??
85-
"•";
85+
(topic.is_custom ? undefined : "•");
8686

8787
return {
8888
icon,
@@ -231,7 +231,9 @@ export const VerifySelection = () => {
231231
{...testId(`portal-verify-topic-${option.key}`)}
232232
>
233233
<Group gap="sm" align="center">
234-
<span className="text-xl">{option.icon}</span>
234+
{option.icon ? (
235+
<span className="text-xl">{option.icon}</span>
236+
) : null}
235237
<span className="text-base font-medium">{option.label}</span>
236238
</Group>
237239
</Box>
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { t } from "@lingui/core/macro";
2+
import { Trans } from "@lingui/react/macro";
3+
import {
4+
Button,
5+
Collapse,
6+
Group,
7+
Modal,
8+
Stack,
9+
Text,
10+
Textarea,
11+
TextInput,
12+
UnstyledButton,
13+
} from "@mantine/core";
14+
import { useDisclosure } from "@mantine/hooks";
15+
import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react";
16+
import { useEffect, useState } from "react";
17+
import type { VerificationTopicMetadata } from "@/lib/api";
18+
import { testId } from "@/lib/testUtils";
19+
20+
const MAX_LABEL_LENGTH = 100;
21+
const MAX_PROMPT_LENGTH = 1000;
22+
const MAX_ICON_LENGTH = 10;
23+
24+
const EMOJI_REGEX = /[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu;
25+
26+
const SUPPORTED_LANGUAGES = [
27+
{ code: "en-US", label: "English" },
28+
{ code: "nl-NL", label: "Nederlands" },
29+
{ code: "de-DE", label: "Deutsch" },
30+
{ code: "fr-FR", label: "Français" },
31+
{ code: "es-ES", label: "Español" },
32+
{ code: "it-IT", label: "Italiano" },
33+
] as const;
34+
35+
type CustomTopicModalProps = {
36+
opened: boolean;
37+
onClose: () => void;
38+
mode: "create" | "edit";
39+
topic?: VerificationTopicMetadata | null;
40+
onSubmit: (data: {
41+
label: string;
42+
prompt: string;
43+
icon: string;
44+
translations: Record<string, string>;
45+
}) => void;
46+
isLoading?: boolean;
47+
};
48+
49+
export const CustomTopicModal = ({
50+
opened,
51+
onClose,
52+
mode,
53+
topic,
54+
onSubmit,
55+
isLoading = false,
56+
}: CustomTopicModalProps) => {
57+
const [
58+
translationsOpen,
59+
{ toggle: toggleTranslations, close: closeTranslations },
60+
] = useDisclosure(false);
61+
const [labels, setLabels] = useState<Record<string, string>>({});
62+
const [prompt, setPrompt] = useState("");
63+
const [icon, setIcon] = useState("");
64+
65+
useEffect(() => {
66+
if (!opened) return;
67+
68+
if (mode === "edit" && topic) {
69+
const translationLabels: Record<string, string> = {};
70+
for (const lang of SUPPORTED_LANGUAGES) {
71+
translationLabels[lang.code] =
72+
topic.translations?.[lang.code]?.label ?? "";
73+
}
74+
setLabels(translationLabels);
75+
setPrompt(topic.prompt ?? "");
76+
setIcon(topic.icon ?? "");
77+
} else {
78+
setLabels({});
79+
setPrompt("");
80+
setIcon("");
81+
}
82+
closeTranslations();
83+
}, [opened, mode, topic, closeTranslations]);
84+
85+
const enUsLabel = labels["en-US"]?.trim() ?? "";
86+
87+
const hasChanges = (() => {
88+
if (mode === "create") return true;
89+
if (!topic) return true;
90+
91+
if (enUsLabel !== (topic.translations?.["en-US"]?.label ?? "")) return true;
92+
if (prompt.trim() !== (topic.prompt ?? "")) return true;
93+
if (icon.trim() !== (topic.icon ?? "")) return true;
94+
95+
for (const lang of SUPPORTED_LANGUAGES) {
96+
if (lang.code === "en-US") continue;
97+
const current = labels[lang.code]?.trim() ?? "";
98+
const original = topic.translations?.[lang.code]?.label ?? "";
99+
if (current !== original) return true;
100+
}
101+
102+
return false;
103+
})();
104+
105+
const canSubmit =
106+
enUsLabel.length > 0 && prompt.trim().length > 0 && hasChanges;
107+
108+
const handleSubmit = () => {
109+
if (!canSubmit) return;
110+
111+
const translations: Record<string, string> = {};
112+
for (const lang of SUPPORTED_LANGUAGES) {
113+
const val = labels[lang.code]?.trim();
114+
if (val) {
115+
translations[lang.code] = val;
116+
}
117+
}
118+
119+
onSubmit({
120+
icon: icon.trim(),
121+
label: enUsLabel,
122+
prompt: prompt.trim(),
123+
translations,
124+
});
125+
};
126+
127+
return (
128+
<Modal
129+
opened={opened}
130+
onClose={onClose}
131+
title={
132+
mode === "create" ? (
133+
<Trans>Add Custom Topic</Trans>
134+
) : (
135+
<Trans>Edit Custom Topic</Trans>
136+
)
137+
}
138+
size="lg"
139+
radius="md"
140+
padding="xl"
141+
{...testId("custom-topic-modal")}
142+
>
143+
<Stack gap="md">
144+
<TextInput
145+
label={t`Topic label`}
146+
placeholder={t`Required`}
147+
value={labels["en-US"] ?? ""}
148+
onChange={(e) => {
149+
const val = e.currentTarget.value;
150+
setLabels((prev) => ({ ...prev, "en-US": val }));
151+
}}
152+
maxLength={MAX_LABEL_LENGTH}
153+
required
154+
{...testId("custom-topic-label-en-US")}
155+
/>
156+
157+
<Stack gap={4}>
158+
<UnstyledButton onClick={toggleTranslations}>
159+
<Text
160+
size="sm"
161+
style={{
162+
alignItems: "center",
163+
display: "flex",
164+
gap: "0.2rem",
165+
}}
166+
>
167+
{translationsOpen ? (
168+
<CaretDownIcon size={14} style={{ display: "inline" }} />
169+
) : (
170+
<CaretRightIcon size={14} style={{ display: "inline" }} />
171+
)}{" "}
172+
<Trans>Add translations</Trans>
173+
</Text>
174+
</UnstyledButton>
175+
176+
<Collapse in={translationsOpen}>
177+
<Stack gap="xs" pt="xs" pl="md">
178+
{SUPPORTED_LANGUAGES.filter((l) => l.code !== "en-US").map(
179+
(lang) => (
180+
<TextInput
181+
key={lang.code}
182+
label={lang.label}
183+
placeholder={t`Optional (falls back to English)`}
184+
value={labels[lang.code] ?? ""}
185+
onChange={(e) => {
186+
const val = e.currentTarget.value;
187+
setLabels((prev) => ({
188+
...prev,
189+
[lang.code]: val,
190+
}));
191+
}}
192+
maxLength={MAX_LABEL_LENGTH}
193+
{...testId(`custom-topic-label-${lang.code}`)}
194+
/>
195+
),
196+
)}
197+
</Stack>
198+
</Collapse>
199+
</Stack>
200+
201+
<Textarea
202+
label={t`Prompt`}
203+
description={
204+
<Trans>Instructions for generating the verification outcome</Trans>
205+
}
206+
placeholder={t`Describe what the language model should extract or summarize from the conversation...`}
207+
value={prompt}
208+
onChange={(e) => setPrompt(e.currentTarget.value)}
209+
maxLength={MAX_PROMPT_LENGTH}
210+
autosize
211+
minRows={4}
212+
required
213+
{...testId("custom-topic-prompt")}
214+
/>
215+
216+
<TextInput
217+
label={t`Emoji`}
218+
description={
219+
<Trans>Emoji shown next to the topic e.g. 💡 🔍 📊</Trans>
220+
}
221+
placeholder={t`Optional`}
222+
value={icon}
223+
onChange={(e) => {
224+
const emojis = e.currentTarget.value.match(EMOJI_REGEX);
225+
setIcon(emojis ? emojis.join("") : "");
226+
}}
227+
maxLength={MAX_ICON_LENGTH}
228+
{...testId("custom-topic-icon")}
229+
/>
230+
231+
<Group justify="flex-end" mt="md">
232+
<Button variant="subtle" onClick={onClose}>
233+
<Trans>Cancel</Trans>
234+
</Button>
235+
<Button
236+
onClick={handleSubmit}
237+
disabled={!canSubmit}
238+
loading={isLoading}
239+
{...testId("custom-topic-submit")}
240+
>
241+
{mode === "create" ? <Trans>Create</Trans> : <Trans>Save</Trans>}
242+
</Button>
243+
</Group>
244+
</Stack>
245+
</Modal>
246+
);
247+
};

0 commit comments

Comments
 (0)