feat(hooks): 添加核心业务逻辑 hooks
- useConversations: 会话列表 CRUD 管理 - useSettings: 用户设置读取和更新 - useStreamChat: 流式聊天消息处理 - 支持 SSE 流式响应 - 支持思考内容显示 - 支持错误处理和重试
This commit is contained in:
parent
3a244eb989
commit
e4cdcc5141
190
src/hooks/useConversations.ts
Normal file
190
src/hooks/useConversations.ts
Normal file
@ -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<Conversation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<ConversationWithMessages | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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,
|
||||
};
|
||||
}
|
||||
178
src/hooks/useSettings.ts
Normal file
178
src/hooks/useSettings.ts
Normal file
@ -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<string, unknown>;
|
||||
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<Settings>(defaultSettings);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<Settings & { cchApiKey?: string }>) => {
|
||||
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<Tool[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<Model[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
233
src/hooks/useStreamChat.ts
Normal file
233
src/hooks/useStreamChat.ts
Normal file
@ -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<ChatMessage[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user