From dbeb285e4126e841b48e97fc6f8dc1157d56a7d2 Mon Sep 17 00:00:00 2001 From: Nguyen Xuan Nhan Date: Tue, 11 Jun 2024 17:09:32 +0700 Subject: [PATCH] feat: add ai fx --- src/RookieShop.AppHost/Program.cs | 2 +- .../app/(dashboard)/layout.tsx | 15 +- .../app/api/copilot/route.ts | 39 ++++ src/RookieShop.BackOffice/app/layout.tsx | 1 + .../components/custom/custom-editor.tsx | 6 +- .../components/forms/category-form.tsx | 7 +- src/RookieShop.BackOffice/env.mjs | 2 + .../lib/configs/model.config.ts | 11 + src/RookieShop.BackOffice/lib/fx/ai.fx.ts | 214 ++++++++++++++++++ src/RookieShop.BackOffice/types/index.d.ts | 7 + 10 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 src/RookieShop.BackOffice/app/api/copilot/route.ts create mode 100644 src/RookieShop.BackOffice/lib/configs/model.config.ts create mode 100644 src/RookieShop.BackOffice/lib/fx/ai.fx.ts diff --git a/src/RookieShop.AppHost/Program.cs b/src/RookieShop.AppHost/Program.cs index 7812fa97..c19b1f09 100644 --- a/src/RookieShop.AppHost/Program.cs +++ b/src/RookieShop.AppHost/Program.cs @@ -1,4 +1,3 @@ -using Aspire.Hosting; using Microsoft.Extensions.Hosting; using Projects; using RookieShop.AppHost; @@ -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(); diff --git a/src/RookieShop.BackOffice/app/(dashboard)/layout.tsx b/src/RookieShop.BackOffice/app/(dashboard)/layout.tsx index 3659d713..119d4951 100644 --- a/src/RookieShop.BackOffice/app/(dashboard)/layout.tsx +++ b/src/RookieShop.BackOffice/app/(dashboard)/layout.tsx @@ -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" @@ -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 ( <>
-
{children}
+ +
{children}
+
) diff --git a/src/RookieShop.BackOffice/app/api/copilot/route.ts b/src/RookieShop.BackOffice/app/api/copilot/route.ts new file mode 100644 index 00000000..f81e2950 --- /dev/null +++ b/src/RookieShop.BackOffice/app/api/copilot/route.ts @@ -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 = { + 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 { + const actions: AnnotatedFunction[] = [] + + if (env.TAVILY_API_KEY) { + actions.push(researchAction) + } + + const copilotKit = new CopilotRuntime({ + actions: actions, + }) + + return copilotKit.response(req, new OpenAIAdapter()) +} diff --git a/src/RookieShop.BackOffice/app/layout.tsx b/src/RookieShop.BackOffice/app/layout.tsx index e175b86d..593cba72 100644 --- a/src/RookieShop.BackOffice/app/layout.tsx +++ b/src/RookieShop.BackOffice/app/layout.tsx @@ -1,4 +1,5 @@ import "./globals.css" +import "@copilotkit/react-textarea/styles.css" import { ReactNode } from "react" import type { Metadata } from "next" diff --git a/src/RookieShop.BackOffice/components/custom/custom-editor.tsx b/src/RookieShop.BackOffice/components/custom/custom-editor.tsx index d75bcd98..2968c3ed 100644 --- a/src/RookieShop.BackOffice/components/custom/custom-editor.tsx +++ b/src/RookieShop.BackOffice/components/custom/custom-editor.tsx @@ -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 | undefined content: string handleContent: (content: string) => void }> @@ -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 }} + /> ) } diff --git a/src/RookieShop.BackOffice/components/forms/category-form.tsx b/src/RookieShop.BackOffice/components/forms/category-form.tsx index fa8f6f33..f1fe1da4 100644 --- a/src/RookieShop.BackOffice/components/forms/category-form.tsx +++ b/src/RookieShop.BackOffice/components/forms/category-form.tsx @@ -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" @@ -100,6 +100,8 @@ export const CategoryForm: FC = ({ initialData }) => { } }, [createCategorySuccess, updateCategorySuccess]) + const [copilotText, setCopilotText] = useState("") + return ( <>
@@ -136,10 +138,11 @@ export const CategoryForm: FC = ({ initialData }) => { Description { form.setValue("description", content) }} + event={(event) => setCopilotText(event.target.value)} disabled={isDisabled} {...field} /> diff --git a/src/RookieShop.BackOffice/env.mjs b/src/RookieShop.BackOffice/env.mjs index 07f92d92..842356d9 100644 --- a/src/RookieShop.BackOffice/env.mjs +++ b/src/RookieShop.BackOffice/env.mjs @@ -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, }, diff --git a/src/RookieShop.BackOffice/lib/configs/model.config.ts b/src/RookieShop.BackOffice/lib/configs/model.config.ts new file mode 100644 index 00000000..78dcacaa --- /dev/null +++ b/src/RookieShop.BackOffice/lib/configs/model.config.ts @@ -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, + }) +} diff --git a/src/RookieShop.BackOffice/lib/fx/ai.fx.ts b/src/RookieShop.BackOffice/lib/fx/ai.fx.ts new file mode 100644 index 00000000..0422102a --- /dev/null +++ b/src/RookieShop.BackOffice/lib/fx/ai.fx.ts @@ -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 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 tags like this: + here goes the 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 = /[\s\S]*?<\/FEEDBACK>/g + const description = result.agentState.description.replace(regex, "") + return description +} diff --git a/src/RookieShop.BackOffice/types/index.d.ts b/src/RookieShop.BackOffice/types/index.d.ts index bf197fff..601cda3d 100644 --- a/src/RookieShop.BackOffice/types/index.d.ts +++ b/src/RookieShop.BackOffice/types/index.d.ts @@ -34,3 +34,10 @@ export type DataTableProps = { [key: string]: string | string[] | undefined | boolean } } + +export type AgentState = { + topic: string + searchResults?: string + description?: string + critique?: string +}