From 17a591c026ea6cfc730f8d12c818bdfb89d75840 Mon Sep 17 00:00:00 2001 From: LeviLiu Date: Wed, 11 Feb 2026 15:00:13 +0800 Subject: [PATCH 1/5] feat: refactor chat with ai-sdk and langchain tools --- apps/web/package.json | 2 + .../[locale]/(dashboard)/chat/ChatView.tsx | 285 ++++---- .../(default-layout)/comparison/page.tsx | 4 +- .../real-world-use-cases/page.tsx | 4 +- .../(default-layout)/showcase/page.tsx | 4 +- .../app/[locale]/(mdx-layout)/blog/page.tsx | 2 +- apps/web/src/app/layout.tsx | 2 +- apps/web/src/appConfig.ts | 2 +- apps/web/src/components/atoms/button.tsx | 4 +- .../components/atoms/suggestion-prompt.tsx | 29 +- .../Flow/components/DecisionNode.tsx | 2 +- .../components/molecules/chat/chat-input.tsx | 33 +- .../molecules/chat/chat-message.tsx | 158 +---- .../molecules/chat/markdown-content.tsx | 26 +- .../molecules/landing/gradient-blob.tsx | 11 +- .../organisms/Chat/ChatContainer.tsx | 262 +++++-- apps/web/src/lang/de.json | 27 +- apps/web/src/lang/en.json | 27 +- apps/web/src/lang/ja.json | 27 +- apps/web/src/lang/ko.json | 27 +- apps/web/src/lang/ru.json | 27 +- apps/web/src/lang/tw.json | 27 +- apps/web/src/lang/zh.json | 67 +- apps/web/src/lang/zh.lock.json | 22 +- apps/web/src/service/agent.ts | 209 +----- apps/web/src/styles/custom-variant.css | 6 +- apps/web/src/styles/globals.css | 158 ++--- apps/web/src/styles/mdx-prose.css | 180 +++-- pnpm-lock.yaml | 655 ++++++++++++------ prisma/schema.prisma | 87 +-- services/agent-service/.env.example | 6 + services/agent-service/README.md | 40 +- services/agent-service/package.json | 12 +- services/agent-service/src/config/index.ts | 14 +- services/agent-service/src/config/logger.ts | 6 +- services/agent-service/src/index.ts | 8 +- services/agent-service/src/routes/agents.ts | 96 +-- services/agent-service/src/routes/chat.ts | 619 ++++++++++++----- services/agent-service/src/routes/tools.ts | 167 ----- .../agent-service/src/services/chatService.ts | 348 ---------- services/agent-service/src/services/db.ts | 223 +----- .../src/services/toolExecutor.ts | 328 --------- .../agent-service/src/services/toolService.ts | 346 --------- services/agent-service/src/types/index.ts | 213 +----- .../agent-service/src/utils/serializer.ts | 15 - 45 files changed, 1804 insertions(+), 3013 deletions(-) delete mode 100644 services/agent-service/src/routes/tools.ts delete mode 100644 services/agent-service/src/services/chatService.ts delete mode 100644 services/agent-service/src/services/toolExecutor.ts delete mode 100644 services/agent-service/src/services/toolService.ts diff --git a/apps/web/package.json b/apps/web/package.json index 2235922..73ef9c5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -57,6 +57,8 @@ "@studio-freight/lenis": "^1.0.42", "@tailwindcss/typography": "^0.5.19", "@xyflow/react": "^12.8.1", + "@ai-sdk/react": "3.0.41", + "ai": "6.0.78", "better-auth": "^1.4.16", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx b/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx index 0450402..b67c0ea 100644 --- a/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx @@ -1,14 +1,28 @@ 'use client' import { useState, useRef, useEffect, useMemo } from 'react' -import { Wrench } from 'lucide-react' -import { agentService, type Agent, type ToolCall } from '@/service/agent' +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' import { useTranslations } from 'next-intl' import { ChatContainer, type Message } from '@/components/organisms' -import { ChatInputAction } from '@/components/molecules' import type { SuggestionPrompt } from '@/components/atoms' -import { type StreamChunk } from '@/service/agent' import { authClient } from '@/lib/auth-client' +import { API_BASE_URL } from '@/service/request' + +const isTextPart = (part: unknown): part is { type: 'text'; text: string } => { + return ( + !!part && + typeof part === 'object' && + (part as { type?: string }).type === 'text' && + typeof (part as { text?: unknown }).text === 'string' + ) +} + +const isRenderableMessage = ( + message: T +): message is T & { role: 'user' | 'assistant' } => { + return message.role === 'user' || message.role === 'assistant' +} export function ChatView() { const t = useTranslations('Chat') @@ -27,89 +41,128 @@ export function ChatView() { .join('') .toUpperCase() }, [session?.user?.name]) - const [messages, setMessages] = useState([]) - const [input, setInput] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [copiedId, setCopiedId] = useState(null) - const [selectedAgent, setSelectedAgent] = useState(null) - const [lastUserMessage, setLastUserMessage] = useState('') + const scrollRef = useRef(null) const textareaRef = useRef(null) - const [enableTools, setEnableTools] = useState(true) // 工具调用开关 + const [copiedId, setCopiedId] = useState(null) + const [input, setInput] = useState('') + + const { messages, status, setMessages, sendMessage, regenerate } = useChat({ + transport: new DefaultChatTransport({ + api: `${API_BASE_URL}/api/agent`, + credentials: 'include', + }), + }) const suggestionPrompts = useMemo( (): SuggestionPrompt[] => [ { - icon: '🚀', - label: t('suggestions.newProject.label'), - prompt: t('suggestions.newProject.prompt'), + icon: '🍽️', + label: t('suggestions.whatToEat.label'), + prompt: t('suggestions.whatToEat.prompt'), }, { - icon: '🐛', - label: t('suggestions.debugCode.label'), - prompt: t('suggestions.debugCode.prompt'), + icon: '🧳', + label: t('suggestions.tripList.label'), + prompt: t('suggestions.tripList.prompt'), }, { - icon: '📝', - label: t('suggestions.writeDocs.label'), - prompt: t('suggestions.writeDocs.prompt'), + icon: '💬', + label: t('suggestions.quickReply.label'), + prompt: t('suggestions.quickReply.prompt'), }, { - icon: '💡', - label: t('suggestions.creativeIdeas.label'), - prompt: t('suggestions.creativeIdeas.prompt'), + icon: '🧾', + label: t('suggestions.quickSummary.label'), + prompt: t('suggestions.quickSummary.prompt'), + }, + { + icon: '🧠', + label: t('suggestions.makeDecision.label'), + prompt: t('suggestions.makeDecision.prompt'), + }, + { + icon: '🧘', + label: t('suggestions.focusPlan.label'), + prompt: t('suggestions.focusPlan.prompt'), + }, + { + icon: '🛒', + label: t('suggestions.groceryList.label'), + prompt: t('suggestions.groceryList.prompt'), + }, + { + icon: '🏃', + label: t('suggestions.workoutPlan.label'), + prompt: t('suggestions.workoutPlan.prompt'), + }, + { + icon: '💤', + label: t('suggestions.sleepTip.label'), + prompt: t('suggestions.sleepTip.prompt'), + }, + { + icon: '📖', + label: t('suggestions.learnQuick.label'), + prompt: t('suggestions.learnQuick.prompt'), + }, + { + icon: '💰', + label: t('suggestions.saveMoney.label'), + prompt: t('suggestions.saveMoney.prompt'), + }, + { + icon: '🧽', + label: t('suggestions.homeChores.label'), + prompt: t('suggestions.homeChores.prompt'), }, ], [t] ) + const isLoading = status === 'submitted' || status === 'streaming' + + const uiMessages = useMemo((): Message[] => { + return messages.filter(isRenderableMessage).map(message => { + const textParts = Array.isArray(message.parts) + ? message.parts.filter(isTextPart).map(part => part.text) + : [] + return { + id: message.id, + role: message.role, + content: textParts.join(''), + } + }) + }, [messages]) + + const displayMessages = useMemo(() => { + if (!isLoading) return uiMessages + const last = uiMessages[uiMessages.length - 1] + if (last && last.role === 'assistant') return uiMessages + return [ + ...uiMessages, + { + id: 'pending-assistant', + role: 'assistant' as const, + content: '', + }, + ] + }, [uiMessages, isLoading]) + + const lastUserMessage = useMemo(() => { + for (let i = displayMessages.length - 1; i >= 0; i -= 1) { + if (displayMessages[i]?.role === 'user') { + return displayMessages[i]?.content || '' + } + } + return '' + }, [displayMessages]) + useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } - }, [messages]) - - // 处理工具调用事件 - const handleToolCallEvent = ( - assistantMessage: Message, - chunk: StreamChunk, - setMessages: React.Dispatch> - ) => { - if (!chunk.toolCall) return - - setMessages(prev => { - return prev.map(m => { - if (m.id !== assistantMessage.id) return m - - // 判断工具调用应该放在前面还是后面 - // 如果消息内容已经存在,说明工具调用是在内容之后产生的 - const hasContent = m.content.length > 0 - const targetKey = hasContent ? 'toolCallsAfter' : 'toolCallsBefore' - - // 获取或初始化工具调用列表 - const currentToolCalls = m[targetKey] || [] - const existingToolCallIndex = currentToolCalls.findIndex( - tc => tc.id === chunk.toolCall!.id - ) - - let updatedToolCalls: ToolCall[] - - if (existingToolCallIndex >= 0) { - // 更新现有工具调用 - updatedToolCalls = [...currentToolCalls] - updatedToolCalls[existingToolCallIndex] = { - ...updatedToolCalls[existingToolCallIndex], - ...chunk.toolCall, - } - } else { - // 添加新工具调用 - updatedToolCalls = [...currentToolCalls, chunk.toolCall!] - } - - return { ...m, [targetKey]: updatedToolCalls } - }) - }) - } + }, [displayMessages]) const handleSend = async (messageContent?: string) => { const shouldUseMessageContent = @@ -119,82 +172,17 @@ export function ChatView() { .trim() if (!contentToSend || isLoading) return - const userMessage: Message = { - id: Date.now().toString(), - role: 'user', - content: contentToSend, - timestamp: new Date(), - } - - setMessages(prev => [...prev, userMessage]) - setLastUserMessage(contentToSend) - if (!shouldUseMessageContent) setInput('') - setIsLoading(true) - - const assistantMessage: Message = { - id: (Date.now() + 1).toString(), - role: 'assistant', - content: '', - timestamp: new Date(), - toolCallsBefore: [], - toolCallsAfter: [], - } - - setMessages(prev => [...prev, assistantMessage]) - - try { - await agentService.chatStream( - contentToSend, - chunk => { - // 处理工具调用事件 - if (chunk.type?.startsWith('tool_call')) { - handleToolCallEvent(assistantMessage, chunk, setMessages) - } + setInput('') - if (chunk.done) { - setIsLoading(false) - textareaRef.current?.focus() - return - } - if (chunk.content) { - setMessages(prev => - prev.map(m => - m.id === assistantMessage.id - ? { ...m, content: m.content + chunk.content } - : m - ) - ) - } - }, - error => { - console.error('Chat error:', error) - setIsLoading(false) - setMessages(prev => - prev.map(m => - m.id === assistantMessage.id && !m.content - ? { ...m, content: t('error.message') } - : m - ) - ) - }, - { agentId: selectedAgent?.id, enableTools } - ) - } catch (error) { - console.error('Chat error:', error) - setIsLoading(false) - setMessages(prev => - prev.map(m => - m.id === assistantMessage.id && !m.content - ? { ...m, content: t('error.message') } - : m - ) - ) - } + await sendMessage({ + text: contentToSend, + }) + textareaRef.current?.focus() } const handleRetry = () => { if (!lastUserMessage || isLoading) return - handleSend(lastUserMessage) + regenerate() } const handleCopy = (content: string, id: string) => { @@ -207,26 +195,14 @@ export function ChatView() { setMessages([]) } - // 输入框操作区域 - 工具调用开关 - const inputActions = ( - } - label={t('actions.tools') || '工具调用'} - checked={enableTools} - onToggle={() => setEnableTools(!enableTools)} - variant='toggle' - size='sm' - /> - ) - return ( diff --git a/apps/web/src/app/[locale]/(default-layout)/comparison/page.tsx b/apps/web/src/app/[locale]/(default-layout)/comparison/page.tsx index a01cc82..558adf5 100644 --- a/apps/web/src/app/[locale]/(default-layout)/comparison/page.tsx +++ b/apps/web/src/app/[locale]/(default-layout)/comparison/page.tsx @@ -52,7 +52,7 @@ export default function ComparisonPage() { switch (value) { case 'yes': return ( - + ) case 'partial': return ( @@ -266,7 +266,7 @@ export default function ComparisonPage() { {/* Legend */}
- + {t('legend.yes')}
diff --git a/apps/web/src/app/[locale]/(default-layout)/real-world-use-cases/page.tsx b/apps/web/src/app/[locale]/(default-layout)/real-world-use-cases/page.tsx index 8a07d15..eaa0654 100644 --- a/apps/web/src/app/[locale]/(default-layout)/real-world-use-cases/page.tsx +++ b/apps/web/src/app/[locale]/(default-layout)/real-world-use-cases/page.tsx @@ -301,7 +301,7 @@ export default function UseCasesPage() { {t(`difficulty.${useCase.difficulty}`)} - + {useCase.timeSaved} {useCase.tags.slice(0, 3).map(tag => ( @@ -329,7 +329,7 @@ export default function UseCasesPage() { {/* Benefits */}

- + {t('details.benefits')}