feat(hooks): 添加核心业务逻辑 hooks

- useConversations: 会话列表 CRUD 管理
- useSettings: 用户设置读取和更新
- useStreamChat: 流式聊天消息处理
  - 支持 SSE 流式响应
  - 支持思考内容显示
  - 支持错误处理和重试
This commit is contained in:
gaoziman 2025-12-18 11:30:42 +08:00
parent 3a244eb989
commit e4cdcc5141
3 changed files with 601 additions and 0 deletions

View 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
View 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
View 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,
};
}