Skip to content

Commit 368f018

Browse files
authored
Merge pull request #243 from code0-tech/feat/#239
AI subscription is removed on page change
2 parents da81433 + 8efb91c commit 368f018

6 files changed

Lines changed: 203 additions & 79 deletions

File tree

src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {GraphQLFormattedError} from "graphql/error";
2323
import {addIslandErrorNotification} from "@code0-tech/pictor/dist/components/island/Island.hook";
2424
import {createConsumer} from "@rails/actioncable";
2525
import ActionCableLink from "graphql-ruby-client/subscriptions/ActionCableLink";
26+
import {AIGenerationWatcherComponent} from "@edition/ai/components/AIGenerationWatcherComponent";
2627

2728
/**
2829
* Load the Inter font with Latin subset and swap display strategy
@@ -222,6 +223,7 @@ export default function RootLayout({children}: Readonly<{ children: React.ReactN
222223
<Suspense fallback={"loading..."}>
223224
<ApolloProvider client={client}>
224225
<Toaster position={"top-right"}/>
226+
<AIGenerationWatcherComponent/>
225227
{children}
226228
</ApolloProvider>
227229
</Suspense>

src/packages/ce/src/ai/components/AIChatComponent.tsx

Lines changed: 32 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,15 @@ import {Select} from "@radix-ui/react-select";
2525
import {IconChevronDown, IconPlayerStop, IconSend, IconSparkles2Filled} from "@tabler/icons-react";
2626
import {AIService} from "@edition/ai/services/AI.service";
2727
import {motion} from "framer-motion";
28-
import {Flow, NamespaceProject, Subscription} from "@code0-tech/sagittarius-graphql-types";
29-
import {useSubscription} from "@apollo/client/react";
30-
import generateFlowSubscription from "@edition/ai/services/subscriptions/AI.generateFlow.subscription.graphql"
31-
32-
const GENERATING_VARIANTS = [
33-
"Generating...",
34-
"Thinking...",
35-
"Analyzing your prompt...",
36-
"Composing flow...",
37-
"Crafting nodes...",
38-
"Wiring it up...",
39-
"Almost there..."
40-
]
28+
import {AiGenerateFlowSubscriptionPayload, Flow, NamespaceProject} from "@code0-tech/sagittarius-graphql-types";
29+
import {useAIGenerationStore} from "@edition/ai/hooks/AI.generation.hook";
30+
import {AIGeneratingMessageComponent} from "@edition/ai/components/AIGeneratingMessageComponent";
4131

4232
export interface AIChatComponentProps {
4333
projectId: NamespaceProject['id']
4434
flowId?: Flow['id']
4535
prompt?: string
46-
onData?: (data: any) => string | void
36+
onData?: (data: AiGenerateFlowSubscriptionPayload) => string | void
4737

4838
}
4939

@@ -53,70 +43,56 @@ export const AIChatComponent: React.FC<AIChatComponentProps> = (props) => {
5343

5444
const aiService = useService(AIService)
5545
const aiStore = useStore(AIService)
46+
const addGeneration = useAIGenerationStore(s => s.addGeneration)
47+
const removeGeneration = useAIGenerationStore(s => s.removeGeneration)
5648

5749
const [promptState, setPromptState] = React.useState<string>(prompt)
5850
const [model, setModel] = React.useState<string | undefined>(undefined)
5951
const [executionIdentifier, setExecutionIdentifier] = React.useState<string | null>(null)
60-
const [isAIActive, setIsAIActive] = React.useState(false)
61-
const [aiVariantIndex, setAiVariantIndex] = React.useState(0)
6252
const [aiErrorMessage, setAiErrorMessage] = React.useState<string | null>(null)
6353

54+
const isThisGenerating = useAIGenerationStore(s =>
55+
!!executionIdentifier && s.hasGeneration(executionIdentifier)
56+
)
57+
6458
const models = React.useMemo(
6559
() => aiService.values(),
6660
[aiStore]
6761
)
6862

69-
const {data} = useSubscription<Subscription>(generateFlowSubscription, {
70-
variables: {executionIdentifier: executionIdentifier},
71-
skip: !executionIdentifier,
72-
onData: (data) => {
73-
setIsAIActive(true)
74-
if (data.data.data?.aiGenerateFlow?.flow) {
75-
const result = onData?.(data.data.data?.aiGenerateFlow)
76-
if (typeof result === "string") {
77-
setAiErrorMessage(result)
78-
} else {
79-
setPromptState("")
80-
}
81-
setExecutionIdentifier(null)
82-
} else if (data.data.data?.aiGenerateFlow?.flow === null) {
83-
setExecutionIdentifier(null)
84-
setAiErrorMessage("Generation failed. Try another model.")
85-
}
86-
},
87-
onComplete: () => setIsAIActive(false),
88-
onError: () => {
89-
setIsAIActive(false)
90-
setAiErrorMessage("Generation failed. Try another model.")
63+
React.useEffect(() => {
64+
if (executionIdentifier && !isThisGenerating) {
65+
if (!aiErrorMessage) setPromptState("")
9166
setExecutionIdentifier(null)
92-
},
93-
})
67+
}
68+
}, [executionIdentifier, isThisGenerating, aiErrorMessage])
9469

95-
const aiLoading = React.useMemo(
96-
() => ((!data || Object.keys(data).length === 0) && executionIdentifier && isAIActive),
97-
[executionIdentifier, data, isAIActive]
98-
)
70+
const aiLoading = !!executionIdentifier && isThisGenerating
9971

10072
const onSend = React.useCallback(() => {
73+
setAiErrorMessage(null)
10174
aiService.generateFlow({
10275
prompt: promptState,
10376
projectId: projectId!,
10477
modelIdentifier: model!,
10578
flowId: flowId,
10679
}).then(payload => {
107-
if ((payload?.errors?.length ?? 0) <= 0) {
108-
setExecutionIdentifier(payload?.executionIdentifier ?? null)
80+
if ((payload?.errors?.length ?? 0) <= 0 && payload?.executionIdentifier) {
81+
const id = payload.executionIdentifier
82+
setExecutionIdentifier(id)
83+
addGeneration({
84+
executionIdentifier: id,
85+
onData: (data) => onData?.(data),
86+
onError: (message) => setAiErrorMessage(message),
87+
})
10988
}
11089
})
111-
}, [aiService, model, promptState])
90+
}, [aiService, model, promptState, projectId, flowId, onData, addGeneration])
11291

113-
React.useEffect(() => {
114-
const id = setInterval(
115-
() => setAiVariantIndex(i => (i + 1) % GENERATING_VARIANTS.length),
116-
4000
117-
)
118-
return () => clearInterval(id)
119-
}, [])
92+
const onStop = React.useCallback(() => {
93+
if (executionIdentifier) removeGeneration(executionIdentifier)
94+
setExecutionIdentifier(null)
95+
}, [executionIdentifier, removeGeneration])
12096

12197
React.useEffect(() => {
12298
setModel(models.length > 0
@@ -186,30 +162,7 @@ export const AIChatComponent: React.FC<AIChatComponentProps> = (props) => {
186162
height: "2.3rem",
187163
}}
188164
>
189-
<motion.div
190-
key={GENERATING_VARIANTS[aiVariantIndex]}
191-
initial={{y: 18, opacity: 0}}
192-
animate={{y: 0, opacity: 1}}
193-
exit={{y: -18, opacity: 0}}
194-
transition={{duration: 0.45, ease: [0.4, 0, 0.4, 1]}}
195-
>
196-
<motion.div
197-
style={{
198-
display: "block",
199-
backgroundImage:
200-
"linear-gradient(90deg, rgba(255,255,255,0.25) 0%, rgba(255,255,255,0.25) 40%, rgba(255,255,255,1) 50%, rgba(255,255,255,0.25) 60%, rgba(255,255,255,0.25) 100%)",
201-
backgroundSize: "200% 100%",
202-
WebkitBackgroundClip: "text",
203-
backgroundClip: "text",
204-
WebkitTextFillColor: "transparent",
205-
color: "transparent",
206-
}}
207-
animate={{backgroundPosition: ["200% 0%", "-200% 0%"]}}
208-
transition={{duration: 3, ease: "linear", repeat: Infinity}}
209-
>
210-
<Text>{GENERATING_VARIANTS[aiVariantIndex]}</Text>
211-
</motion.div>
212-
</motion.div>
165+
<AIGeneratingMessageComponent/>
213166
</div>
214167
) : (
215168
<EditorInput
@@ -296,7 +249,7 @@ export const AIChatComponent: React.FC<AIChatComponentProps> = (props) => {
296249
</Text>
297250
) : null}
298251
{aiLoading ? (
299-
<Button onClick={() => setExecutionIdentifier(null)} color={"secondary"}>
252+
<Button onClick={onStop} color={"secondary"}>
300253
<IconPlayerStop size={13}/>
301254
</Button>
302255
) : (
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"use client"
2+
3+
import React from "react"
4+
import {Text} from "@code0-tech/pictor"
5+
import {motion} from "framer-motion"
6+
7+
const VARIANTS = [
8+
"Generating...",
9+
"Thinking...",
10+
"Analyzing your prompt...",
11+
"Composing flow...",
12+
"Crafting nodes...",
13+
"Wiring it up...",
14+
"Almost there...",
15+
]
16+
17+
export const AIGeneratingMessageComponent: React.FC = () => {
18+
19+
const [index, setIndex] = React.useState(0)
20+
21+
React.useEffect(() => {
22+
const id = setInterval(() => setIndex(i => (i + 1) % VARIANTS.length), 4000)
23+
return () => clearInterval(id)
24+
}, [])
25+
26+
return <motion.div
27+
key={VARIANTS[index]}
28+
initial={{y: 12, opacity: 0}}
29+
animate={{y: 0, opacity: 1}}
30+
exit={{y: -12, opacity: 0}}
31+
transition={{duration: 0.4, ease: [0.4, 0, 0.4, 1]}}
32+
>
33+
<motion.div
34+
style={{
35+
backgroundImage:
36+
"linear-gradient(90deg, rgba(226,112,255,0.45) 0%, rgba(226,112,255,0.45) 40%, rgba(226,112,255,1) 50%, rgba(226,112,255,0.45) 60%, rgba(226,112,255,0.45) 100%)",
37+
backgroundSize: "200% 100%",
38+
WebkitBackgroundClip: "text",
39+
backgroundClip: "text",
40+
WebkitTextFillColor: "transparent",
41+
color: "transparent",
42+
}}
43+
animate={{backgroundPosition: ["200% 0%", "-200% 0%"]}}
44+
transition={{duration: 3, ease: "linear", repeat: Infinity}}
45+
>
46+
<Text>{VARIANTS[index]}</Text>
47+
</motion.div>
48+
</motion.div>
49+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"use client"
2+
3+
import React from "react"
4+
import {useSubscription} from "@apollo/client/react"
5+
import {AiGenerateFlowSubscriptionPayload, Subscription} from "@code0-tech/sagittarius-graphql-types"
6+
import {AIGeneration, useAIGenerationStore} from "@edition/ai/hooks/AI.generation.hook"
7+
import generateFlowSubscription from "@edition/ai/services/subscriptions/AI.generateFlow.subscription.graphql"
8+
9+
export interface AIGenerationSubscriberComponentProps {
10+
generation: AIGeneration
11+
}
12+
13+
export const AIGenerationSubscriberComponent: React.FC<AIGenerationSubscriberComponentProps> = ({generation}) => {
14+
15+
const removeGeneration = useAIGenerationStore(s => s.removeGeneration)
16+
17+
useSubscription<Subscription>(generateFlowSubscription, {
18+
variables: {executionIdentifier: generation.executionIdentifier},
19+
onData: (data) => {
20+
const payload = data.data.data?.aiGenerateFlow as AiGenerateFlowSubscriptionPayload | undefined
21+
if (payload?.flow) {
22+
const result = generation.onData(payload)
23+
if (typeof result === "string") {
24+
generation.onError?.(result)
25+
}
26+
removeGeneration(generation.executionIdentifier)
27+
} else if (payload?.flow === null) {
28+
generation.onError?.("Generation failed. Try another model.")
29+
removeGeneration(generation.executionIdentifier)
30+
}
31+
},
32+
onComplete: () => removeGeneration(generation.executionIdentifier),
33+
onError: () => {
34+
generation.onError?.("Generation failed. Try another model.")
35+
removeGeneration(generation.executionIdentifier)
36+
},
37+
})
38+
39+
return null
40+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"use client"
2+
3+
import React from "react"
4+
import {useAIGenerationStore} from "@edition/ai/hooks/AI.generation.hook"
5+
import {useIsland} from "@code0-tech/pictor/dist/components/island/Island.hook"
6+
import {IconSparkles2Filled} from "@tabler/icons-react"
7+
import {AIGeneratingMessageComponent} from "@edition/ai/components/AIGeneratingMessageComponent"
8+
import {AIGenerationSubscriberComponent} from "@edition/ai/components/AIGenerationSubscriberComponent"
9+
10+
export const AIGenerationWatcherComponent: React.FC = () => {
11+
12+
const generations = useAIGenerationStore(s => s.generations)
13+
const islandToastIdRef = React.useRef<number | null>(null)
14+
const addToast = useIsland(s => s.addToast)
15+
const removeToast = useIsland(s => s.removeToast)
16+
17+
const isGenerating = generations.length > 0
18+
19+
React.useEffect(() => {
20+
if (isGenerating && islandToastIdRef.current === null) {
21+
const id = Date.now()
22+
islandToastIdRef.current = id
23+
addToast({
24+
id,
25+
duration: Infinity,
26+
index: 2,
27+
icon: <IconSparkles2Filled size={16} color={"#e270ff"}/>,
28+
message: <AIGeneratingMessageComponent/>,
29+
})
30+
} else if (!isGenerating && islandToastIdRef.current !== null) {
31+
removeToast(islandToastIdRef.current)
32+
islandToastIdRef.current = null
33+
}
34+
}, [isGenerating, addToast, removeToast])
35+
36+
React.useEffect(() => () => {
37+
if (islandToastIdRef.current !== null) {
38+
removeToast(islandToastIdRef.current)
39+
islandToastIdRef.current = null
40+
}
41+
}, [removeToast])
42+
43+
return <>
44+
{generations.map(generation => (
45+
<AIGenerationSubscriberComponent
46+
key={generation.executionIdentifier}
47+
generation={generation}
48+
/>
49+
))}
50+
</>
51+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {create} from "zustand"
2+
import {AiGenerateFlowSubscriptionPayload} from "@code0-tech/sagittarius-graphql-types"
3+
4+
export interface AIGeneration {
5+
executionIdentifier: string
6+
onData: (payload: AiGenerateFlowSubscriptionPayload) => string | void
7+
onError?: (message: string) => void
8+
}
9+
10+
interface AIGenerationState {
11+
generations: AIGeneration[]
12+
addGeneration: (generation: AIGeneration) => void
13+
removeGeneration: (executionIdentifier: string) => void
14+
hasGeneration: (executionIdentifier: string) => boolean
15+
}
16+
17+
export const useAIGenerationStore = create<AIGenerationState>((setState, getState) => ({
18+
generations: [],
19+
addGeneration: (generation) => setState((state) => ({
20+
generations: state.generations.some(g => g.executionIdentifier === generation.executionIdentifier)
21+
? state.generations
22+
: [...state.generations, generation]
23+
})),
24+
removeGeneration: (executionIdentifier) => setState((state) => ({
25+
generations: state.generations.filter(g => g.executionIdentifier !== executionIdentifier)
26+
})),
27+
hasGeneration: (executionIdentifier) =>
28+
getState().generations.some(g => g.executionIdentifier === executionIdentifier),
29+
}))

0 commit comments

Comments
 (0)