-
Notifications
You must be signed in to change notification settings - Fork 39
feat(geo): conversation sequences — multi-turn prompt tracking #666
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/geo-ai-visibility
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| "use client"; | ||
|
|
||
| import { Cancel01Icon, PlusSignIcon } from "@hugeicons/core-free-icons"; | ||
| import { HugeiconsIcon } from "@hugeicons/react"; | ||
| import { | ||
| ResponsiveDialog, | ||
| ResponsiveDialogContent, | ||
| ResponsiveDialogDescription, | ||
| ResponsiveDialogFooter, | ||
| ResponsiveDialogHeader, | ||
| ResponsiveDialogTitle, | ||
| } from "@notra/ui/components/shared/responsive-dialog"; | ||
| import { Button } from "@notra/ui/components/ui/button"; | ||
| import { Input } from "@notra/ui/components/ui/input"; | ||
| import { Label } from "@notra/ui/components/ui/label"; | ||
| import { Loader2Icon } from "lucide-react"; | ||
| import { useId, useState } from "react"; | ||
| import { GEO_PROMPT_MIN_LENGTH, GEO_SEQUENCE_MAX_TURNS } from "@/constants/geo"; | ||
| import { | ||
| useGeoSequenceCreate, | ||
| useGeoSequenceUpdate, | ||
| } from "@/lib/hooks/use-geo"; | ||
| import type { ConversationBuilderDialogProps } from "@/types/geo"; | ||
|
|
||
| export function ConversationBuilderDialog({ | ||
| open, | ||
| onOpenChange, | ||
| organizationId, | ||
| sequence, | ||
| }: ConversationBuilderDialogProps) { | ||
| const nameId = useId(); | ||
| const create = useGeoSequenceCreate(organizationId); | ||
| const update = useGeoSequenceUpdate(organizationId); | ||
| const [name, setName] = useState(sequence?.name ?? ""); | ||
| const [steps, setSteps] = useState<string[]>( | ||
| sequence && sequence.steps.length > 0 ? sequence.steps : [""] | ||
| ); | ||
|
|
||
| const pending = create.isPending || update.isPending; | ||
| const validSteps = steps | ||
| .map((step) => step.trim()) | ||
| .filter((step) => step.length >= GEO_PROMPT_MIN_LENGTH); | ||
| const canSave = name.trim().length > 0 && validSteps.length > 0 && !pending; | ||
|
|
||
| const handleOpenChange = (next: boolean) => { | ||
| if (!next) { | ||
| setName(sequence?.name ?? ""); | ||
| setSteps(sequence && sequence.steps.length > 0 ? sequence.steps : [""]); | ||
| } | ||
| onOpenChange(next); | ||
| }; | ||
|
|
||
| const handleSave = async () => { | ||
| if (!canSave) { | ||
| return; | ||
| } | ||
| if (sequence) { | ||
| await update.mutateAsync({ | ||
| sequenceId: sequence.id, | ||
| name: name.trim(), | ||
| steps: validSteps, | ||
| }); | ||
| } else { | ||
| await create.mutateAsync({ name: name.trim(), steps: validSteps }); | ||
| setName(""); | ||
| setSteps([""]); | ||
| } | ||
| onOpenChange(false); | ||
| }; | ||
|
|
||
| return ( | ||
| <ResponsiveDialog onOpenChange={handleOpenChange} open={open}> | ||
| <ResponsiveDialogContent className="sm:max-w-lg"> | ||
| <ResponsiveDialogHeader> | ||
| <ResponsiveDialogTitle> | ||
| {sequence ? `Edit ${sequence.name}` : "New conversation"} | ||
| </ResponsiveDialogTitle> | ||
| <ResponsiveDialogDescription> | ||
| A real buyer conversation: an opening question and the follow-ups | ||
| that decide the purchase. Every turn is checked for your brand. | ||
| </ResponsiveDialogDescription> | ||
| </ResponsiveDialogHeader> | ||
| <div className="space-y-4 px-4 md:px-0"> | ||
| <div className="space-y-1.5"> | ||
| <Label htmlFor={nameId}>Name</Label> | ||
| <Input | ||
| id={nameId} | ||
| onChange={(event) => setName(event.target.value)} | ||
| placeholder="Changelog tool research" | ||
| value={name} | ||
| /> | ||
| </div> | ||
| <div className="space-y-2"> | ||
| <Label>Turns</Label> | ||
| <div className="space-y-2"> | ||
| {steps.map((step, index) => ( | ||
| <div | ||
| className="flex items-start gap-2" | ||
| key={`turn-${index.toString()}`} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React Doctor · Your users can see & submit the wrong data when this list reorders or filters, so use a stable id like Fix → Use a stable id from the item, like |
||
| > | ||
| <span className="mt-2 w-5 shrink-0 text-right text-muted-foreground text-xs tabular-nums"> | ||
| {index + 1} | ||
| </span> | ||
| <div className="min-w-0 flex-1 rounded-2xl rounded-tl-sm border border-border bg-muted/40 px-3 py-2"> | ||
| <textarea | ||
| className="block w-full resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground" | ||
| onChange={(event) => | ||
| setSteps((previous) => | ||
| previous.map((item, itemIndex) => | ||
| itemIndex === index ? event.target.value : item | ||
| ) | ||
| ) | ||
| } | ||
| placeholder={ | ||
| index === 0 | ||
| ? "What is the best tool to automate changelogs?" | ||
| : "Which of those is the cheapest?" | ||
| } | ||
| rows={2} | ||
| value={step} | ||
| /> | ||
| </div> | ||
| {steps.length > 1 && ( | ||
| <Button | ||
| aria-label={`Remove turn ${index + 1}`} | ||
| className="mt-1 shrink-0" | ||
| onClick={() => | ||
| setSteps((previous) => | ||
| previous.filter((_, itemIndex) => itemIndex !== index) | ||
| ) | ||
| } | ||
| size="icon" | ||
| type="button" | ||
| variant="ghost" | ||
| > | ||
| <HugeiconsIcon icon={Cancel01Icon} size={14} /> | ||
| </Button> | ||
| )} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| {steps.length < GEO_SEQUENCE_MAX_TURNS && ( | ||
| <Button | ||
| className="ml-7" | ||
| onClick={() => setSteps((previous) => [...previous, ""])} | ||
| size="sm" | ||
| type="button" | ||
| variant="outline" | ||
| > | ||
| <HugeiconsIcon icon={PlusSignIcon} size={14} /> | ||
| Add follow-up | ||
| </Button> | ||
| )} | ||
| </div> | ||
| </div> | ||
| <ResponsiveDialogFooter> | ||
| <Button disabled={!canSave} onClick={handleSave} type="button"> | ||
| {pending && <Loader2Icon className="size-4 animate-spin" />} | ||
| {sequence ? "Save changes" : "Create conversation"} | ||
| </Button> | ||
| </ResponsiveDialogFooter> | ||
| </ResponsiveDialogContent> | ||
| </ResponsiveDialog> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| "use client"; | ||
|
|
||
| import { | ||
| ResponsiveDialog, | ||
| ResponsiveDialogContent, | ||
| ResponsiveDialogDescription, | ||
| ResponsiveDialogHeader, | ||
| ResponsiveDialogTitle, | ||
| } from "@notra/ui/components/shared/responsive-dialog"; | ||
| import { Skeleton } from "@notra/ui/components/ui/skeleton"; | ||
| import { useMemo } from "react"; | ||
| import { EngineIcon } from "@/components/geo/engine-icon"; | ||
| import { useGeoSequenceResults } from "@/lib/hooks/use-geo"; | ||
| import type { | ||
| ConversationResultsDialogProps, | ||
| GeoSequenceTurnResult, | ||
| } from "@/types/geo"; | ||
| import { engineFamilyLabel, engineFamilyOf } from "@/utils/geo-charts"; | ||
| import { buildSequenceTurnGroups } from "@/utils/geo-sequences"; | ||
|
|
||
| function EngineResult({ result }: { result: GeoSequenceTurnResult }) { | ||
| const label = engineFamilyLabel(engineFamilyOf(result.engine)); | ||
| return ( | ||
| <span | ||
| className="inline-flex items-center gap-1.5 rounded-full border border-border px-2 py-0.5 text-xs" | ||
| title={result.excerpt} | ||
| > | ||
| <EngineIcon className="size-3.5" engine={result.engine} /> | ||
| {label} | ||
| {result.mentioned ? ( | ||
| <span className="font-medium text-emerald-600 tabular-nums dark:text-emerald-400"> | ||
| {result.position !== null ? `#${result.position}` : "Mentioned"} | ||
| </span> | ||
| ) : ( | ||
| <span className="text-muted-foreground">Absent</span> | ||
| )} | ||
| </span> | ||
| ); | ||
| } | ||
|
|
||
| export function ConversationResultsDialog({ | ||
| open, | ||
| onOpenChange, | ||
| organizationId, | ||
| sequence, | ||
| }: ConversationResultsDialogProps) { | ||
| const { data, isLoading } = useGeoSequenceResults( | ||
| organizationId, | ||
| open ? sequence?.id : undefined | ||
| ); | ||
|
|
||
| const turns = useMemo( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React Doctor · React Compiler can cache this value automatically. Verify that removing Fix → Profile compiler-managed code and remove |
||
| () => buildSequenceTurnGroups(data?.results ?? [], sequence?.id), | ||
| [data, sequence] | ||
| ); | ||
|
|
||
| if (!sequence) { | ||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <ResponsiveDialog onOpenChange={onOpenChange} open={open}> | ||
| <ResponsiveDialogContent className="sm:max-w-2xl"> | ||
| <ResponsiveDialogHeader> | ||
| <ResponsiveDialogTitle>{sequence.name}</ResponsiveDialogTitle> | ||
| <ResponsiveDialogDescription> | ||
| Where your brand shows up as the conversation unfolds. | ||
| </ResponsiveDialogDescription> | ||
| </ResponsiveDialogHeader> | ||
| <div className="max-h-[60svh] space-y-4 overflow-y-auto px-4 md:px-0"> | ||
| {isLoading && <Skeleton className="h-40 w-full" />} | ||
| {!isLoading && turns.length === 0 && ( | ||
| <p className="py-8 text-center text-muted-foreground text-sm"> | ||
| No results yet. Run a scan to play this conversation against the | ||
| engines. | ||
| </p> | ||
| )} | ||
| {turns.map(([turn, results]) => ( | ||
| <div className="flex items-start gap-2" key={turn}> | ||
| <span className="mt-2 w-5 shrink-0 text-right text-muted-foreground text-xs tabular-nums"> | ||
| {turn} | ||
| </span> | ||
| <div className="min-w-0 flex-1 space-y-2"> | ||
| <div className="rounded-2xl rounded-tl-sm border border-border bg-muted/40 px-3 py-2 text-sm"> | ||
| {results[0]?.prompt} | ||
| </div> | ||
| <div className="flex flex-wrap gap-1.5"> | ||
| {results.map((result) => ( | ||
| <EngineResult | ||
| key={`${result.turn}-${result.engine}`} | ||
| result={result} | ||
| /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </ResponsiveDialogContent> | ||
| </ResponsiveDialog> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
React Doctor ·
react-doctor/js-combine-iterations(warning)This loops over your list twice because .map().filter() makes two passes, so do it in one pass with .reduce() or a for...of loop
Fix → Combine
.map().filter()style chains into one pass with.reduce()or afor...ofloop, so you only loop over the list onceDocs