diff --git a/src/hooks/useConversations.ts b/src/hooks/useConversations.ts new file mode 100644 index 0000000..d65e22d --- /dev/null +++ b/src/hooks/useConversations.ts @@ -0,0 +1,190 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import type { Conversation, Message } from '@/drizzle/schema'; + +export interface ConversationWithMessages extends Conversation { + messages: Message[]; +} + +export function useConversations() { + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchConversations = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await fetch('/api/conversations'); + if (!response.ok) { + throw new Error('Failed to fetch conversations'); + } + const data = await response.json(); + setConversations(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + const createConversation = useCallback(async (data: { + title?: string; + model: string; + tools?: string[]; + enableThinking?: boolean; + }) => { + try { + const response = await fetch('/api/conversations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!response.ok) { + throw new Error('Failed to create conversation'); + } + const newConversation = await response.json(); + setConversations((prev) => [newConversation, ...prev]); + return newConversation; + } catch (err) { + throw err; + } + }, []); + + const updateConversation = useCallback(async ( + conversationId: string, + data: { title?: string; isPinned?: boolean; isArchived?: boolean } + ) => { + try { + const response = await fetch(`/api/conversations/${conversationId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!response.ok) { + throw new Error('Failed to update conversation'); + } + const updated = await response.json(); + setConversations((prev) => + prev.map((c) => (c.conversationId === conversationId ? updated : c)) + ); + return updated; + } catch (err) { + throw err; + } + }, []); + + const deleteConversation = useCallback(async (conversationId: string) => { + try { + const response = await fetch(`/api/conversations/${conversationId}`, { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error('Failed to delete conversation'); + } + setConversations((prev) => + prev.filter((c) => c.conversationId !== conversationId) + ); + } catch (err) { + throw err; + } + }, []); + + const clearAllConversations = useCallback(async () => { + try { + const ids = conversations.map((c) => c.conversationId); + const response = await fetch('/api/conversations', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ conversationIds: ids }), + }); + if (!response.ok) { + throw new Error('Failed to clear conversations'); + } + setConversations([]); + } catch (err) { + throw err; + } + }, [conversations]); + + useEffect(() => { + fetchConversations(); + }, [fetchConversations]); + + return { + conversations, + loading, + error, + refetch: fetchConversations, + createConversation, + updateConversation, + deleteConversation, + clearAllConversations, + }; +} + +export function useConversation(conversationId: string | null) { + const [conversation, setConversation] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchConversation = useCallback(async () => { + if (!conversationId) { + setConversation(null); + return; + } + + try { + setLoading(true); + setError(null); + const response = await fetch(`/api/conversations/${conversationId}`); + if (!response.ok) { + throw new Error('Failed to fetch conversation'); + } + const data = await response.json(); + setConversation(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, [conversationId]); + + const addMessage = useCallback((message: Message) => { + setConversation((prev) => { + if (!prev) return prev; + return { + ...prev, + messages: [...prev.messages, message], + }; + }); + }, []); + + const updateLastMessage = useCallback((content: string, thinkingContent?: string) => { + setConversation((prev) => { + if (!prev || prev.messages.length === 0) return prev; + const messages = [...prev.messages]; + const lastIndex = messages.length - 1; + messages[lastIndex] = { + ...messages[lastIndex], + content, + thinkingContent: thinkingContent || messages[lastIndex].thinkingContent, + }; + return { ...prev, messages }; + }); + }, []); + + useEffect(() => { + fetchConversation(); + }, [fetchConversation]); + + return { + conversation, + loading, + error, + refetch: fetchConversation, + addMessage, + updateLastMessage, + }; +} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 0000000..bae0661 --- /dev/null +++ b/src/hooks/useSettings.ts @@ -0,0 +1,178 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +export interface Settings { + cchUrl: string; + cchApiKeyConfigured: boolean; + defaultModel: string; + defaultTools: string[]; + systemPrompt: string; + temperature: string; + theme: string; + language: string; + enableThinking: boolean; + saveChatHistory: boolean; +} + +export interface Tool { + id: number; + toolId: string; + name: string; + displayName: string; + description: string | null; + icon: string | null; + inputSchema: Record; + isEnabled: boolean; + isDefault: boolean; + sortOrder: number; +} + +export interface Model { + id: number; + modelId: string; + name: string; + displayName: string; + description: string | null; + supportsTools: boolean; + supportsThinking: boolean; + supportsVision: boolean; + maxTokens: number; + contextWindow: number; + isEnabled: boolean; + isDefault: boolean; + sortOrder: number; +} + +const defaultSettings: Settings = { + cchUrl: 'http://localhost:13500', + cchApiKeyConfigured: false, + defaultModel: 'claude-sonnet-4-20250514', + defaultTools: ['web_search', 'code_execution', 'web_fetch'], + systemPrompt: '', + temperature: '0.7', + theme: 'light', + language: 'zh-CN', + enableThinking: false, + saveChatHistory: true, +}; + +export function useSettings() { + const [settings, setSettings] = useState(defaultSettings); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + // 获取设置 + const fetchSettings = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await fetch('/api/settings'); + if (!response.ok) { + throw new Error('Failed to fetch settings'); + } + const data = await response.json(); + setSettings(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + // 更新设置 + const updateSettings = useCallback(async (updates: Partial) => { + try { + setSaving(true); + setError(null); + const response = await fetch('/api/settings', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updates), + }); + if (!response.ok) { + throw new Error('Failed to update settings'); + } + const data = await response.json(); + setSettings(data); + return data; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + setError(message); + throw err; + } finally { + setSaving(false); + } + }, []); + + // 初始加载 + useEffect(() => { + fetchSettings(); + }, [fetchSettings]); + + return { + settings, + loading, + error, + saving, + updateSettings, + refetch: fetchSettings, + }; +} + +export function useTools() { + const [tools, setTools] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchTools() { + try { + setLoading(true); + const response = await fetch('/api/tools'); + if (!response.ok) { + throw new Error('Failed to fetch tools'); + } + const data = await response.json(); + setTools(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + } + fetchTools(); + }, []); + + return { tools, loading, error }; +} + +export function useModels() { + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchModels() { + try { + setLoading(true); + const response = await fetch('/api/models'); + if (!response.ok) { + throw new Error('Failed to fetch models'); + } + const data = await response.json(); + setModels(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + } + fetchModels(); + }, []); + + return { models, loading, error }; +} diff --git a/src/hooks/useStreamChat.ts b/src/hooks/useStreamChat.ts new file mode 100644 index 0000000..65d6018 --- /dev/null +++ b/src/hooks/useStreamChat.ts @@ -0,0 +1,233 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; + +export interface StreamMessage { + type: 'thinking' | 'text' | 'tool_use_start' | 'done' | 'error'; + content?: string; + id?: string; + name?: string; + messageId?: string; + inputTokens?: number; + outputTokens?: number; + error?: string; +} + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + thinkingContent?: string; + status: 'pending' | 'streaming' | 'completed' | 'error'; + error?: string; + inputTokens?: number; + outputTokens?: number; +} + +export function useStreamChat() { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [error, setError] = useState(null); + const abortControllerRef = useRef(null); + + // 发送消息 + const sendMessage = useCallback(async (options: { + conversationId: string; + message: string; + model?: string; + tools?: string[]; + enableThinking?: boolean; + }) => { + const { conversationId, message, model, tools, enableThinking } = options; + + // 添加用户消息 + const userMessage: ChatMessage = { + id: `user-${Date.now()}`, + role: 'user', + content: message, + status: 'completed', + }; + + // 添加 AI 消息占位 + const assistantMessage: ChatMessage = { + id: `assistant-${Date.now()}`, + role: 'assistant', + content: '', + thinkingContent: '', + status: 'streaming', + }; + + setMessages((prev) => [...prev, userMessage, assistantMessage]); + setIsStreaming(true); + setError(null); + + // 创建 AbortController + abortControllerRef.current = new AbortController(); + + try { + const response = await fetch('/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + conversationId, + message, + model, + tools, + enableThinking, + }), + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to send message'); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + let fullContent = ''; + let thinkingContent = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') continue; + + try { + const event: StreamMessage = JSON.parse(data); + + if (event.type === 'thinking') { + thinkingContent += event.content || ''; + setMessages((prev) => { + const updated = [...prev]; + const lastIndex = updated.length - 1; + if (updated[lastIndex]?.role === 'assistant') { + updated[lastIndex] = { + ...updated[lastIndex], + thinkingContent, + }; + } + return updated; + }); + } else if (event.type === 'text') { + fullContent += event.content || ''; + setMessages((prev) => { + const updated = [...prev]; + const lastIndex = updated.length - 1; + if (updated[lastIndex]?.role === 'assistant') { + updated[lastIndex] = { + ...updated[lastIndex], + content: fullContent, + }; + } + return updated; + }); + } else if (event.type === 'done') { + setMessages((prev) => { + const updated = [...prev]; + const lastIndex = updated.length - 1; + if (updated[lastIndex]?.role === 'assistant') { + updated[lastIndex] = { + ...updated[lastIndex], + id: event.messageId || updated[lastIndex].id, + status: 'completed', + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + }; + } + return updated; + }); + } else if (event.type === 'error') { + throw new Error(event.error || 'Unknown error'); + } + } catch (e) { + if (e instanceof SyntaxError) { + // 忽略 JSON 解析错误 + continue; + } + throw e; + } + } + } + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + // 用户取消 + setMessages((prev) => { + const updated = [...prev]; + const lastIndex = updated.length - 1; + if (updated[lastIndex]?.role === 'assistant') { + updated[lastIndex] = { + ...updated[lastIndex], + status: 'completed', + content: updated[lastIndex].content || '(已取消)', + }; + } + return updated; + }); + } else { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + setError(errorMessage); + setMessages((prev) => { + const updated = [...prev]; + const lastIndex = updated.length - 1; + if (updated[lastIndex]?.role === 'assistant') { + updated[lastIndex] = { + ...updated[lastIndex], + status: 'error', + error: errorMessage, + content: updated[lastIndex].content || '', + }; + } + return updated; + }); + } + } finally { + setIsStreaming(false); + abortControllerRef.current = null; + } + }, []); + + // 停止生成 + const stopGeneration = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }, []); + + // 清空消息 + const clearMessages = useCallback(() => { + setMessages([]); + setError(null); + }, []); + + // 设置初始消息 + const setInitialMessages = useCallback((initialMessages: ChatMessage[]) => { + setMessages(initialMessages); + }, []); + + return { + messages, + isStreaming, + error, + sendMessage, + stopGeneration, + clearMessages, + setInitialMessages, + }; +}