From fb89fe8e096eed0abcb00d248ad1d2e1de01ce5f Mon Sep 17 00:00:00 2001 From: Yoshiki Miura Date: Mon, 30 Sep 2024 17:25:54 +0900 Subject: [PATCH 1/4] chore: Refactoring the agent's workflow --- app/actions.tsx | 9 +- lib/actions/workflow.tsx | 237 ++++---------------------------------- lib/agents/researcher.tsx | 191 ++++++++++++------------------ lib/utils/index.ts | 2 +- 4 files changed, 99 insertions(+), 340 deletions(-) diff --git a/app/actions.tsx b/app/actions.tsx index a003a72b..934a5081 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -21,6 +21,8 @@ import { VideoSearchSection } from '@/components/video-search-section' import { AnswerSection } from '@/components/answer-section' import { workflow } from '@/lib/actions/workflow' +const MAX_MESSAGES = 6 + async function submit( formData?: FormData, skip?: boolean, @@ -48,13 +50,8 @@ async function submit( return { role, content } as CoreMessage }) - const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true' - const useOllamaProvider = !!( - process.env.OLLAMA_MODEL && process.env.OLLAMA_BASE_URL - ) - const maxMessages = useSpecificAPI ? 5 : useOllamaProvider ? 1 : 10 // Limit the number of messages to the maximum - messages.splice(0, Math.max(messages.length - maxMessages, 0)) + messages.splice(0, Math.max(messages.length - MAX_MESSAGES, 0)) // Get the user input from the form data const userInput = skip ? `{"action": "skip"}` diff --git a/lib/actions/workflow.tsx b/lib/actions/workflow.tsx index d75bd261..0a93c188 100644 --- a/lib/actions/workflow.tsx +++ b/lib/actions/workflow.tsx @@ -4,21 +4,17 @@ import React from 'react' import { Spinner } from '@/components/ui/spinner' import { Section } from '@/components/section' import { FollowupPanel } from '@/components/followup-panel' -import { AnswerSection } from '@/components/answer-section' -import { ErrorCard } from '@/components/error-card' -import { transformToolMessages } from '@/lib/utils' import { querySuggestor, inquire, - researcher, taskManager, - ollamaResearcher + researcherWithOllama, + researcher } from '@/lib/agents' import { createStreamableValue, createStreamableUI } from 'ai/rsc' -import { CoreMessage, generateId, ToolResultPart } from 'ai' -import { writer } from '../agents/writer' +import { CoreMessage, generateId } from 'ai' -export async function defaultWorkflow( +export async function workflow( uiState: { uiStream: ReturnType isCollapsed: ReturnType @@ -29,11 +25,13 @@ export async function defaultWorkflow( skip: boolean ) { const { uiStream, isCollapsed, isGenerating } = uiState - // Show the spinner + const id = generateId() + + // Display spinner uiStream.append() let action = { object: { next: 'proceed' } } - // If the user skips the task, we proceed to the search + // If the user does not skip the task, run the task manager if (!skip) action = (await taskManager(messages)) ?? action if (action.object.next === 'inquire') { @@ -55,197 +53,17 @@ export async function defaultWorkflow( isCollapsed.done(false) isGenerating.done(false) - uiStream.done() - return - } - - // Set the collapsed state to true - isCollapsed.done(true) - - // Generate the answer - let answer = '' - let stopReason = '' - let toolOutputs: ToolResultPart[] = [] - let errorOccurred = false - - const streamText = createStreamableValue() - - // If ANTHROPIC_API_KEY is set, update the UI with the answer - // If not, update the UI with a div - if (process.env.ANTHROPIC_API_KEY) { - uiStream.update( - - ) - } else { - uiStream.update(
) - } - - // Determine the API usage based on environment variables - const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true' - const useOllamaProvider = !!( - process.env.OLLAMA_MODEL && process.env.OLLAMA_BASE_URL - ) - const maxMessages = useSpecificAPI ? 5 : useOllamaProvider ? 1 : 10 - // Limit the number of messages to the maximum - messages.splice(0, Math.max(messages.length - maxMessages, 0)) - - // If useSpecificAPI is enabled, only function calls will be made - // If not using a tool, this model generates the answer - while ( - useSpecificAPI - ? toolOutputs.length === 0 && answer.length === 0 && !errorOccurred - : (stopReason !== 'stop' || answer.length === 0) && !errorOccurred - ) { - // Search the web and generate the answer - const { fullResponse, hasError, toolResponses, finishReason } = - await researcher(uiStream, streamText, messages) - stopReason = finishReason || '' - answer = fullResponse - toolOutputs = toolResponses - errorOccurred = hasError - - if (toolOutputs.length > 0) { - toolOutputs.map(output => { - aiState.update({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: generateId(), - role: 'tool', - content: JSON.stringify(output.result), - name: output.toolName, - type: 'tool' - } - ] - }) - }) - } - } - - // If useSpecificAPI is enabled, generate the answer using the specific model - if (useSpecificAPI && answer.length === 0 && !errorOccurred) { - // Modify the messages to be used by the specific model - const modifiedMessages = transformToolMessages(messages) - const latestMessages = modifiedMessages.slice(maxMessages * -1) - const { response, hasError } = await writer(uiStream, latestMessages) - answer = response - errorOccurred = hasError - messages.push({ - role: 'assistant', - content: answer - }) - } - - if (!errorOccurred) { - const useGoogleProvider = process.env.GOOGLE_GENERATIVE_AI_API_KEY - const useOllamaProvider = !!( - process.env.OLLAMA_MODEL && process.env.OLLAMA_BASE_URL - ) - let processedMessages = messages - // If using Google provider, we need to modify the messages - if (useGoogleProvider) { - processedMessages = transformToolMessages(messages) - } - if (useOllamaProvider) { - processedMessages = [{ role: 'assistant', content: answer }] - } - - streamText.done() - aiState.update({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: generateId(), - role: 'assistant', - content: answer, - type: 'answer' - } - ] - }) - - // Generate related queries - const relatedQueries = await querySuggestor(uiStream, processedMessages) - // Add follow-up panel - uiStream.append( -
- -
- ) - - aiState.done({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: generateId(), - role: 'assistant', - content: JSON.stringify(relatedQueries), - type: 'related' - }, - { - id: generateId(), - role: 'assistant', - content: 'followup', - type: 'followup' - } - ] - }) - } else { - aiState.done(aiState.get()) - streamText.done() - uiStream.append( - - ) - } - - isGenerating.done(false) - uiStream.done() -} - -export const ollamaWorkflow = async ( - uiState: { - uiStream: ReturnType - isCollapsed: ReturnType - isGenerating: ReturnType - }, - aiState: any, - messages: CoreMessage[], - skip: boolean -) => { - const { uiStream, isCollapsed, isGenerating } = uiState - let action = { object: { next: 'proceed' } } - // If the user skips the task, we proceed to the search - if (!skip) action = (await taskManager(messages)) ?? action - - if (action.object.next === 'inquire') { - // Generate inquiry - const inquiry = await inquire(uiStream, messages) - isCollapsed.done(false) - isGenerating.done(false) - uiStream.done() - aiState.done({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: generateId(), - role: 'assistant', - content: `inquiry: ${inquiry?.question}`, - type: 'inquiry' - } - ] - }) return } // Set the collapsed state to true isCollapsed.done(true) - const { text, toolResults } = await ollamaResearcher(uiStream, messages) + const useOllama = process.env.OLLAMA_MODEL && process.env.OLLAMA_BASE_URL + // Select the appropriate researcher function based on the environment variables + const { text, toolResults } = useOllama + ? await researcherWithOllama(uiStream, messages) + : await researcher(uiStream, messages) aiState.update({ ...aiState.get(), @@ -259,7 +77,7 @@ export const ollamaWorkflow = async ( type: 'tool' })), { - id: generateId(), + id, role: 'assistant', content: text, type: 'answer' @@ -267,8 +85,16 @@ export const ollamaWorkflow = async ( ] }) + const messagesWithAnswer: CoreMessage[] = [ + ...messages, + { + role: 'assistant', + content: text + } + ] + // Generate related queries - const relatedQueries = await querySuggestor(uiStream, messages) + const relatedQueries = await querySuggestor(uiStream, messagesWithAnswer) // Add follow-up panel uiStream.append(
@@ -295,20 +121,3 @@ export const ollamaWorkflow = async ( ] }) } - -export async function workflow( - uiState: { - uiStream: ReturnType - isCollapsed: ReturnType - isGenerating: ReturnType - }, - aiState: any, - messages: CoreMessage[], - skip: boolean -) { - if (process.env.OLLAMA_MODEL && process.env.OLLAMA_BASE_URL) { - return ollamaWorkflow(uiState, aiState, messages, skip) - } - - return defaultWorkflow(uiState, aiState, messages, skip) -} diff --git a/lib/agents/researcher.tsx b/lib/agents/researcher.tsx index 26a459e1..c95a62da 100644 --- a/lib/agents/researcher.tsx +++ b/lib/agents/researcher.tsx @@ -1,13 +1,7 @@ import { createStreamableUI, createStreamableValue } from 'ai/rsc' -import { - CoreMessage, - ToolCallPart, - ToolResultPart, - generateText, - streamText -} from 'ai' +import { CoreMessage, generateText, streamText } from 'ai' import { getTools } from './tools' -import { getModel, transformToolMessages } from '../utils' +import { getModel } from '../utils' import { AnswerSection } from '@/components/answer-section' const SYSTEM_PROMPT = `As a professional search expert, you possess the ability to search for any information on the web. @@ -17,134 +11,93 @@ Aim to directly address the user's question, augmenting your response with insig export async function researcher( uiStream: ReturnType, - streamableText: ReturnType>, messages: CoreMessage[] ) { - let fullResponse = '' - let hasError = false - let finishReason = '' + try { + let fullResponse = '' + const streamableText = createStreamableValue() + let toolResults: any[] = [] - // Transform the messages if using Ollama provider - let processedMessages = messages - const useOllamaProvider = !!( - process.env.OLLAMA_MODEL && process.env.OLLAMA_BASE_URL - ) - const useAnthropicProvider = !!process.env.ANTHROPIC_API_KEY - if (useOllamaProvider) { - processedMessages = transformToolMessages(messages) - } - const includeToolResponses = messages.some(message => message.role === 'tool') - const useSubModel = useOllamaProvider && includeToolResponses - - const streamableAnswer = createStreamableValue('') - const answerSection = - - const currentDate = new Date().toLocaleString() - const result = await streamText({ - model: getModel(useSubModel), - maxTokens: 2500, - system: `${SYSTEM_PROMPT} Current date and time: ${currentDate}`, - messages: processedMessages, - tools: getTools({ - uiStream, - fullResponse - }), - onFinish: async event => { - finishReason = event.finishReason - fullResponse = event.text - streamableAnswer.done() - } - }).catch(err => { - hasError = true - fullResponse = 'Error: ' + err.message - streamableText.update(fullResponse) - }) - - // If the result is not available, return an error response - if (!result) { - return { result, fullResponse, hasError, toolResponses: [] } - } - - const hasToolResult = messages.some(message => message.role === 'tool') - if (!useAnthropicProvider || hasToolResult) { - uiStream.append(answerSection) - } - - // Process the response - const toolCalls: ToolCallPart[] = [] - const toolResponses: ToolResultPart[] = [] - for await (const delta of result.fullStream) { - switch (delta.type) { - case 'text-delta': - if (delta.textDelta) { - fullResponse += delta.textDelta - if (useAnthropicProvider && !hasToolResult) { - streamableText.update(fullResponse) + const currentDate = new Date().toLocaleString() + const result = await streamText({ + model: getModel(), + system: `${SYSTEM_PROMPT} Current date and time: ${currentDate}`, + messages: messages, + tools: getTools({ + uiStream, + fullResponse + }), + maxSteps: 5, + onStepFinish: async event => { + if (event.stepType === 'initial') { + if (event.toolCalls && event.toolCalls.length > 0) { + uiStream.append() + toolResults = event.toolResults } else { - streamableAnswer.update(fullResponse) + uiStream.update() } } - break - case 'tool-call': - toolCalls.push(delta) - break - case 'tool-result': - if (!delta.result) { - hasError = true - } - toolResponses.push(delta) - break - case 'error': - console.log('Error: ' + delta.error) - hasError = true - fullResponse += `\nError occurred while executing the tool` - break + } + }) + + for await (const delta of result.fullStream) { + if (delta.type === 'text-delta' && delta.textDelta) { + fullResponse += delta.textDelta + streamableText.update(fullResponse) + } } - } - messages.push({ - role: 'assistant', - content: [{ type: 'text', text: fullResponse }, ...toolCalls] - }) - if (toolResponses.length > 0) { - // Add tool responses to the messages - messages.push({ role: 'tool', content: toolResponses }) - } + streamableText.done(fullResponse) - return { result, fullResponse, hasError, toolResponses, finishReason } + return { text: fullResponse, toolResults } + } catch (error) { + console.error('Error in researcher:', error) + return { + text: 'An error has occurred. Please try again.', + toolResults: [] + } + } } -export async function ollamaResearcher( +export async function researcherWithOllama( uiStream: ReturnType, messages: CoreMessage[] ) { - const fullResponse = '' - const streamableText = createStreamableValue() - let toolResults: any[] = [] + try { + const fullResponse = '' + const streamableText = createStreamableValue() + let toolResults: any[] = [] - const currentDate = new Date().toLocaleString() - const result = await generateText({ - model: getModel(), - system: `${SYSTEM_PROMPT} Current date and time: ${currentDate}`, - messages: messages, - tools: getTools({ - uiStream, - fullResponse - }), - maxSteps: 5, - onStepFinish: async event => { - if (event.stepType === 'initial') { - if (event.toolCalls) { - uiStream.append() - toolResults = event.toolResults - } else { - uiStream.update() + const currentDate = new Date().toLocaleString() + const result = await generateText({ + model: getModel(), + system: `${SYSTEM_PROMPT} Current date and time: ${currentDate}`, + messages: messages, + tools: getTools({ + uiStream, + fullResponse + }), + maxSteps: 5, + onStepFinish: async event => { + if (event.stepType === 'initial') { + if (event.toolCalls) { + uiStream.append() + toolResults = event.toolResults + } else { + uiStream.update() + } } } - } - }) + }) - streamableText.done(result.text) + streamableText.done(result.text) - return { text: result.text, toolResults } + return { text: result.text, toolResults } + } catch (error) { + console.error('Error in researcherWithOllama:', error) + return { + text: 'An error has occurred. Please try again.', + toolResults: [] + } + } } diff --git a/lib/utils/index.ts b/lib/utils/index.ts index a76bab99..fcd4a7ae 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -47,7 +47,7 @@ export function getModel(useSubModel = false) { } if (googleApiKey) { - return google('models/gemini-1.5-pro-latest') + return google('gemini-1.5-pro-002') } if (anthropicApiKey) { From 34e0425e485f620510414f46b9a78c2427a7db9a Mon Sep 17 00:00:00 2001 From: Yoshiki Miura Date: Mon, 30 Sep 2024 17:33:52 +0900 Subject: [PATCH 2/4] chore: Remove unused writer.tsx file --- lib/agents/writer.tsx | 53 ------------------------------------------- 1 file changed, 53 deletions(-) delete mode 100644 lib/agents/writer.tsx diff --git a/lib/agents/writer.tsx b/lib/agents/writer.tsx deleted file mode 100644 index 0b1496eb..00000000 --- a/lib/agents/writer.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { createStreamableUI, createStreamableValue } from 'ai/rsc' -import { CoreMessage, streamText } from 'ai' -import { createOpenAI } from '@ai-sdk/openai' -import { AnswerSection } from '@/components/answer-section' -import { AnswerSectionGenerated } from '@/components/answer-section-generated' - -export async function writer( - uiStream: ReturnType, - messages: CoreMessage[] -) { - let fullResponse = '' - let hasError = false - const streamableAnswer = createStreamableValue('') - const answerSection = - uiStream.append(answerSection) - - const openai = createOpenAI({ - baseURL: process.env.SPECIFIC_API_BASE, - apiKey: process.env.SPECIFIC_API_KEY, - organization: '' // optional organization - }) - - await streamText({ - model: openai!.chat(process.env.SPECIFIC_API_MODEL || 'llama3-70b-8192'), - maxTokens: 2500, - system: `As a professional writer, your job is to generate a comprehensive and informative, yet concise answer of 400 words or less for the given question based solely on the provided search results (URL and content). You must only use information from the provided search results. Use an unbiased and journalistic tone. Combine search results together into a coherent answer. Do not repeat text. If there are any images relevant to your answer, be sure to include them as well. Aim to directly address the user's question, augmenting your response with insights gleaned from the search results. - Whenever quoting or referencing information from a specific URL, always cite the source URL explicitly. Please match the language of the response to the user's language. - Always answer in Markdown format. Links and images must follow the correct format. - Link format: [link text](url) - Image format: ![alt text](url) - `, - messages, - onFinish: event => { - fullResponse = event.text - streamableAnswer.done(event.text) - } - }) - .then(async result => { - for await (const text of result.textStream) { - if (text) { - fullResponse += text - streamableAnswer.update(fullResponse) - } - } - }) - .catch(err => { - hasError = true - fullResponse = 'Error: ' + err.message - streamableAnswer.update(fullResponse) - }) - - return { response: fullResponse, hasError } -} From 7bb1d10ad179cad29f49749cc5dcbe21eda70f73 Mon Sep 17 00:00:00 2001 From: Yoshiki Miura Date: Mon, 30 Sep 2024 17:38:25 +0900 Subject: [PATCH 3/4] chore: Update README and .env.local.example --- .env.local.example | 6 ------ README.md | 10 ---------- 2 files changed, 16 deletions(-) diff --git a/.env.local.example b/.env.local.example index 719ae3d8..ef3c76fe 100644 --- a/.env.local.example +++ b/.env.local.example @@ -61,12 +61,6 @@ SEARXNG_SAFESEARCH=0 # Safe search setting: 0 (off), 1 (moderate), 2 (strict) # OLLAMA_MODEL=[YOUR_OLLAMA_MODEL] # The main model to use. Currently compatible: qwen2.5 # OLLAMA_BASE_URL=[YOUR_OLLAMA_URL] # The base URL to use. e.g. http://localhost:11434 -# Only writers can set a specific model. It must be compatible with the OpenAI API. -# USE_SPECIFIC_API_FOR_WRITER=true -# SPECIFIC_API_BASE=[YOUR_API_BASE] # e.g. https://api.groq.com/openai/v1 -# SPECIFIC_API_KEY=[YOUR_API_KEY] -# SPECIFIC_API_MODEL=[YOUR_API_MODEL] # e.g. llama-3.1-70b-versatile - # enable the share feature # If you enable this feature, separate account management implementation is required. # ENABLE_SHARE=true diff --git a/README.md b/README.md index 232de581..e76a5d27 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,6 @@ An AI-powered search engine with a generative UI. - Azure OpenAI Provider [※](https://github.com/miurla/morphic/issues/13) - Anthropic Provider [※](https://github.com/miurla/morphic/pull/239) - Ollama Provider [※](https://github.com/miurla/morphic/issues/215#issuecomment-2381902347) -- Specify the model to generate answers - - Groq API support [※](https://github.com/miurla/morphic/pull/58) - Local Redis support - SearXNG Search API support with customizable depth (basic or advanced) - Configurable search depth (basic or advanced) @@ -235,11 +233,3 @@ engines: - Claude 3.5 Sonnet - Ollama - qwen2.5 - -### List of verified models that can be specified to writers: - -- [Groq](https://console.groq.com/docs/models) - - LLaMA3.1 8b - - LLaMA3.1 70B - - LLaMA3 8b - - LLaMA3 70b From ba6e64e5fae98d4202ac2b1514b7062ecafebbd9 Mon Sep 17 00:00:00 2001 From: Yoshiki Miura Date: Mon, 30 Sep 2024 18:19:41 +0900 Subject: [PATCH 4/4] chore: Refactor workflow to use generated IDs for messages --- lib/actions/workflow.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/actions/workflow.tsx b/lib/actions/workflow.tsx index 0a93c188..33d1b0d4 100644 --- a/lib/actions/workflow.tsx +++ b/lib/actions/workflow.tsx @@ -70,7 +70,7 @@ export async function workflow( messages: [ ...aiState.get().messages, ...toolResults.map((toolResult: any) => ({ - id: generateId(), + id, role: 'tool', content: JSON.stringify(toolResult.result), name: toolResult.toolName, @@ -107,13 +107,13 @@ export async function workflow( messages: [ ...aiState.get().messages, { - id: generateId(), + id, role: 'assistant', content: JSON.stringify(relatedQueries), type: 'related' }, { - id: generateId(), + id, role: 'assistant', content: 'followup', type: 'followup'