Skip to content
This repository has been archived by the owner on Jul 12, 2024. It is now read-only.

Commit

Permalink
feat: add ai fx
Browse files Browse the repository at this point in the history
  • Loading branch information
foxminchan committed Jun 11, 2024
1 parent cc4dcb8 commit dbeb285
Show file tree
Hide file tree
Showing 10 changed files with 294 additions and 10 deletions.
2 changes: 1 addition & 1 deletion src/RookieShop.AppHost/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Aspire.Hosting;
using Microsoft.Extensions.Hosting;
using Projects;
using RookieShop.AppHost;
Expand Down Expand Up @@ -72,6 +71,7 @@
.WithHttpEndpoint(3000, env: "PORT")
.WithEnvironment("BROWSER", "none")
.WithEnvironment("OPENAI_API_KEY", openAiKey)
.WithEnvironment("MODEL_NAME", chatModelName)
.WithEnvironment("REMOTE_BFF", bff.GetEndpoint(protocol))
.PublishAsDockerFile();

Expand Down
15 changes: 9 additions & 6 deletions src/RookieShop.BackOffice/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { ReactNode, useEffect } from "react"
import { useRouter } from "next/navigation"
import { CopilotKit } from "@copilotkit/react-core"

import useAuthUser from "@/hooks/useAuthUser"
import Header from "@/components/layouts/header"
Expand All @@ -15,18 +16,20 @@ export default function MainDashboardLayout({
const router = useRouter()
const { isLoggedIn } = useAuthUser()

// useEffect(() => {
// if (isLoggedIn) {
// router.push("/")
// }
// }, [isLoggedIn])
useEffect(() => {
if (isLoggedIn) {
router.push("/")
}
}, [isLoggedIn])

return (
<>
<Header />
<div className="flex overflow-hidden">
<Sidebar />
<main className="w-full pt-16">{children}</main>
<CopilotKit runtimeUrl="/api/copilot">
<main className="w-full pt-16">{children}</main>
</CopilotKit>
</div>
</>
)
Expand Down
39 changes: 39 additions & 0 deletions src/RookieShop.BackOffice/app/api/copilot/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { env } from "@/env.mjs"
import { CopilotRuntime, OpenAIAdapter } from "@copilotkit/backend"
import { AnnotatedFunction } from "@copilotkit/shared"

import { researchWithLangGraph } from "@/lib/fx/ai.fx"

export const runtime = "edge"

const researchAction: AnnotatedFunction<any> = {
name: "research",
description:
"Call this function to conduct research on a certain topic. Respect other notes about when to call this function",
argumentAnnotations: [
{
name: "topic",
type: "string",
description: "The topic to research. 5 characters or longer.",
required: true,
},
],
implementation: async (topic) => {
console.log("Researching topic: ", topic)
return await researchWithLangGraph(topic)
},
}

export async function POST(req: Request): Promise<Response> {
const actions: AnnotatedFunction<any>[] = []

if (env.TAVILY_API_KEY) {
actions.push(researchAction)
}

const copilotKit = new CopilotRuntime({
actions: actions,
})

return copilotKit.response(req, new OpenAIAdapter())
}
1 change: 1 addition & 0 deletions src/RookieShop.BackOffice/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "./globals.css"
import "@copilotkit/react-textarea/styles.css"

import { ReactNode } from "react"
import type { Metadata } from "next"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"use client"

import { ChangeEventHandler } from "react"
import { CKEditor } from "@ckeditor/ckeditor5-react"

import Editor from "../ckeditor5/build/ckeditor"

export default function CustomEditor(
props: Readonly<{
event: ChangeEventHandler<HTMLTextAreaElement> | undefined
content: string
handleContent: (content: string) => void
}>
Expand All @@ -15,10 +17,12 @@ export default function CustomEditor(
editor={Editor}
data={props?.content}
config={Editor.defaultConfig}
onChange={(_event, editor) => {
onChange={(event, editor) => {
const newData = editor.getData()
props.handleContent(newData)
event
}}

/>
)
}
7 changes: 5 additions & 2 deletions src/RookieShop.BackOffice/components/forms/category-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import { FC, useEffect } from "react"
import { FC, useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { UpdateCategoryRequest } from "@/features/category/category.type"
import useCreateCategory from "@/features/category/useCreateCategory"
Expand Down Expand Up @@ -100,6 +100,8 @@ export const CategoryForm: FC<CategoryFormProps> = ({ initialData }) => {
}
}, [createCategorySuccess, updateCategorySuccess])

const [copilotText, setCopilotText] = useState("")

return (
<>
<div className="flex items-center justify-between">
Expand Down Expand Up @@ -136,10 +138,11 @@ export const CategoryForm: FC<CategoryFormProps> = ({ initialData }) => {
<FormLabel>Description</FormLabel>
<FormControl>
<CustomEditor
content={initialData?.description ?? ""}
content={initialData?.description ?? copilotText}
handleContent={(content: string) => {
form.setValue("description", content)
}}
event={(event) => setCopilotText(event.target.value)}
disabled={isDisabled}
{...field}
/>
Expand Down
2 changes: 2 additions & 0 deletions src/RookieShop.BackOffice/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ export const env = createEnv({
server: {
TAVILY_API_KEY: z.string().min(1),
OPENAI_API_KEY: z.string().min(1),
MODEL_NAME: z.string().min(1),
REMOTE_BFF: z.string().min(1).url(),
PORT: z.string().min(1).default("3000"),
},
runtimeEnv: {
TAVILY_API_KEY: process.env.TAVILY_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
MODEL_NAME: process.env.MODEL_NAME,
REMOTE_BFF: process.env.REMOTE_BFF,
PORT: process.env.PORT,
},
Expand Down
11 changes: 11 additions & 0 deletions src/RookieShop.BackOffice/lib/configs/model.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { env } from "@/env.mjs"
import { AgentState } from "@/types"
import { END, StateGraph } from "@langchain/langgraph"
import { ChatOpenAI } from "@langchain/openai"

export default function model() {
return new ChatOpenAI({
temperature: 0.7,
modelName: env.MODEL_NAME,
})
}
214 changes: 214 additions & 0 deletions src/RookieShop.BackOffice/lib/fx/ai.fx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { AgentState } from "@/types"
import { TavilySearchAPIRetriever } from "@langchain/community/retrievers/tavily_search_api"
import { HumanMessage, SystemMessage } from "@langchain/core/messages"
import { RunnableLambda } from "@langchain/core/runnables"
import { END, START, StateGraph } from "@langchain/langgraph"

import model from "../configs/model.config"

async function search(state: {
agentState: AgentState
}): Promise<{ agentState: AgentState }> {
const retriever = new TavilySearchAPIRetriever({
k: 10,
})
let topic = state.agentState.topic
if (topic.length < 5) {
topic = "topic: " + topic
}
const docs = await retriever.invoke(topic)
return {
agentState: {
...state.agentState,
searchResults: JSON.stringify(docs),
},
}
}

async function curate(state: {
agentState: AgentState
}): Promise<{ agentState: AgentState }> {
const response = await model().invoke(
[
new SystemMessage(
`You are a bookstore content curator.
Your sole task is to return a list of URLs of the 5 most relevant articles for the provided product or category as a JSON list of strings
in this format:
{
urls: ["url1", "url2", "url3", "url4", "url5"]
}
.`.replace(/\s+/g, " ")
),
new HumanMessage(
`Today's date is ${new Date().toLocaleDateString("en-US")}.
Product or Category: ${state.agentState.topic}
Here is a list of articles:
${state.agentState.searchResults}`.replace(/\s+/g, " ")
),
],
{
response_format: {
type: "json_object",
},
}
)
const urls = JSON.parse(response.content as string).urls
const searchResults = JSON.parse(state.agentState.searchResults!)
const newSearchResults = searchResults.filter((result: any) => {
return urls.includes(result.metadata.source)
})
return {
agentState: {
...state.agentState,
searchResults: JSON.stringify(newSearchResults),
},
}
}

async function critique(state: {
agentState: AgentState
}): Promise<{ agentState: AgentState }> {
let feedbackInstructions = ""
if (state.agentState.critique) {
feedbackInstructions =
`The writer has revised the description based on your previous critique: ${state.agentState.critique}
The writer might have left feedback for you encoded between <FEEDBACK> tags.
The feedback is only for you to see and will be removed from the final description.
`.replace(/\s+/g, " ")
}
const response = await model().invoke([
new SystemMessage(
`You are a bookstore description critique. Your sole purpose is to provide short feedback on a written
description so the writer will know what to fix.
Today's date is ${new Date().toLocaleDateString("en-US")}
Your task is to provide really short feedback on the description only if necessary.
if you think the description is good, please return [DONE].
You can provide feedback on the revised description or just
return [DONE] if you think the description is good.
Please return a string of your critique or [DONE].`.replace(/\s+/g, " ")
),
new HumanMessage(
`${feedbackInstructions}
This is the description: ${state.agentState.description}`
),
])
const content = response.content as string
console.log("critique:", content)
return {
agentState: {
...state.agentState,
critique: content.includes("[DONE]") ? undefined : content,
},
}
}

async function write(state: {
agentState: AgentState
}): Promise<{ agentState: AgentState }> {
const response = await model().invoke([
new SystemMessage(
`You are a bookstore content writer. Your sole purpose is to write a well-written description about a
product or category using a list of articles. Write 3 paragraphs in markdown.`.replace(
/\s+/g,
" "
)
),
new HumanMessage(
`Today's date is ${new Date().toLocaleDateString("en-US")}.
Your task is to write a compelling description for me about the provided product or
category based on the sources.
Here is a list of articles: ${state.agentState.searchResults}
This is the product or category: ${state.agentState.topic}
Please return a well-written description based on the provided information.`.replace(
/\s+/g,
" "
)
),
])
const content = response.content as string
return {
agentState: {
...state.agentState,
description: content,
},
}
}

async function revise(state: {
agentState: AgentState
}): Promise<{ agentState: AgentState }> {
const response = await model().invoke([
new SystemMessage(
`You are a bookstore content editor. Your sole purpose is to edit a well-written description about a
product or category based on given critique.`.replace(/\s+/g, " ")
),
new HumanMessage(
`Your task is to edit the description based on the critique given.
This is the description: ${state.agentState.description}
This is the critique: ${state.agentState.critique}
Please return the edited description based on the critique given.
You may leave feedback about the critique encoded between <FEEDBACK> tags like this:
<FEEDBACK> here goes the feedback ...</FEEDBACK>`.replace(/\s+/g, " ")
),
])
const content = response.content as string
return {
agentState: {
...state.agentState,
description: content,
},
}
}

const shouldContinue = (state: { agentState: AgentState }) => {
const result = state.agentState.critique === undefined ? "end" : "continue"
return result
}

const agentState = {
agentState: {
value: (x: AgentState, y: AgentState) => y,
default: () => ({
topic: "",
}),
},
__root__: {
value: (x: any, y: any) => y,
default: () => ({}),
},
}

const workflow = new StateGraph({
channels: agentState,
})

workflow.addNode("search", new RunnableLambda({ func: search }) as any)
workflow.addNode("curate", new RunnableLambda({ func: curate }) as any)
workflow.addNode("write", new RunnableLambda({ func: write }) as any)
workflow.addNode("critique", new RunnableLambda({ func: critique }) as any)
workflow.addNode("revise", new RunnableLambda({ func: revise }) as any)
workflow.addEdge(START, "search" as "__start__")
workflow.addEdge("search" as "__start__", "curate" as "__start__")
workflow.addEdge("curate" as "__start__", "write" as "__start__")
workflow.addEdge("write" as "__start__", "critique" as "__start__")

workflow.addConditionalEdges("critique" as "__start__", shouldContinue, {
continue: "revise" as "__start__",
end: END,
})
workflow.addEdge("revise" as "__start__", "critique" as "__start__")

const app = workflow.compile()

export async function researchWithLangGraph(topic: string) {
const inputs = {
agentState: {
topic,
},
}
const result = await app.invoke(inputs)
const regex = /<FEEDBACK>[\s\S]*?<\/FEEDBACK>/g
const description = result.agentState.description.replace(regex, "")
return description
}
Loading

0 comments on commit dbeb285

Please sign in to comment.