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)/agents/components/AgentCard.tsx b/apps/web/src/app/[locale]/(dashboard)/agents/components/AgentCard.tsx index dfff6b1..e8df455 100644 --- a/apps/web/src/app/[locale]/(dashboard)/agents/components/AgentCard.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/agents/components/AgentCard.tsx @@ -122,13 +122,6 @@ export function AgentCard({ agent, onUpdate }: AgentCardProps) { - {/* System Prompt Preview */} -
-

- {agent.system_prompt} -

-
- {/* Metadata */}
diff --git a/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx b/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx index 0450402..551fe6b 100644 --- a/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx +++ b/apps/web/src/app/[locale]/(dashboard)/chat/ChatView.tsx @@ -1,14 +1,114 @@ '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' +} + +type ToolCallItem = NonNullable[number] +type ContentPartItem = NonNullable[number] + +const stringifyPartValue = (value: unknown) => { + if (value === null || value === undefined) return undefined + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + try { + return JSON.stringify(value) + } catch { + return undefined + } +} + +const mapToolState = (state: unknown): ToolCallItem['state'] => { + if (state === 'output-available') return 'success' + if (state === 'output-error' || state === 'output-denied') return 'error' + return 'running' +} + +const parseToolCallPart = (part: unknown): ToolCallItem | null => { + if (!part || typeof part !== 'object') return null + const raw = part as Record + const toolCallId = + typeof raw.toolCallId === 'string' ? raw.toolCallId : undefined + if (!toolCallId) return null + + const type = typeof raw.type === 'string' ? raw.type : '' + const fallbackToolName = type.startsWith('tool-') ? type.slice(5) : 'tool' + const toolName = + typeof raw.toolName === 'string' && raw.toolName.trim() + ? raw.toolName + : fallbackToolName + + return { + toolCallId, + toolName, + state: mapToolState(raw.state), + inputText: stringifyPartValue(raw.input), + outputText: stringifyPartValue(raw.output), + errorText: typeof raw.errorText === 'string' ? raw.errorText : undefined, + } +} + +const extractAssistantContentParts = (parts: unknown[]): ContentPartItem[] => { + const result: ContentPartItem[] = [] + const toolIndexById = new Map() + let textBuffer = '' + + const flushText = () => { + if (!textBuffer.trim()) { + textBuffer = '' + return + } + result.push({ type: 'text', text: textBuffer }) + textBuffer = '' + } + + parts.forEach(part => { + if (isTextPart(part)) { + textBuffer += part.text + return + } + + const tool = parseToolCallPart(part) + if (!tool) return + + flushText() + + const existingIndex = toolIndexById.get(tool.toolCallId) + const toolPart: ContentPartItem = { type: 'tool', tool } + + if (existingIndex !== undefined) { + result[existingIndex] = toolPart + } else { + toolIndexById.set(tool.toolCallId, result.length) + result.push(toolPart) + } + }) + + flushText() + return result +} export function ChatView() { const t = useTranslations('Chat') @@ -27,89 +127,143 @@ 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.tripList.label'), + prompt: t('suggestions.tripList.prompt'), }, { - icon: '🐛', - label: t('suggestions.debugCode.label'), - prompt: t('suggestions.debugCode.prompt'), + icon: '💬', + label: t('suggestions.quickReply.label'), + prompt: t('suggestions.quickReply.prompt'), }, { - icon: '📝', - label: t('suggestions.writeDocs.label'), - prompt: t('suggestions.writeDocs.prompt'), + icon: '🧾', + label: t('suggestions.quickSummary.label'), + prompt: t('suggestions.quickSummary.prompt'), }, { - icon: '💡', - label: t('suggestions.creativeIdeas.label'), - prompt: t('suggestions.creativeIdeas.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 textContent = Array.isArray(message.parts) + ? message.parts + .filter(isTextPart) + .map(part => part.text) + .join('') + : '' + + const assistantContentParts = + message.role === 'assistant' && Array.isArray(message.parts) + ? extractAssistantContentParts(message.parts) + : [] + const toolCalls = assistantContentParts + .filter(part => part.type === 'tool') + .map(part => part.tool) + + return { + id: message.id, + role: message.role, + content: textContent, + contentParts: + message.role === 'assistant' ? assistantContentParts : undefined, + toolCalls: message.role === 'assistant' ? toolCalls : undefined, + } + }) + }, [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 +273,17 @@ export function ChatView() { .trim() if (!contentToSend || isLoading) return - const userMessage: Message = { - id: Date.now().toString(), - role: 'user', - content: contentToSend, - timestamp: new Date(), - } + setInput('') - 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) - } - - 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 +296,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')}