chore: add websocket & http api about chat AI (#7)

* chore: add websocket & http api about chat AI

* feat: Chat AI API debug

* chore: modify the config
This commit is contained in:
BiggerRain
2024-11-09 11:30:36 +08:00
committed by GitHub
parent b52c604a2d
commit 76d06dc3fe
20 changed files with 1297 additions and 154 deletions

View File

@@ -12,8 +12,10 @@
"dependencies": { "dependencies": {
"@headlessui/react": "^2.1.10", "@headlessui/react": "^2.1.10",
"@tauri-apps/api": ">=2.0.0", "@tauri-apps/api": ">=2.0.0",
"@tauri-apps/plugin-http": "~2", "@tauri-apps/plugin-http": "~2.0.1",
"@tauri-apps/plugin-shell": ">=2.0.0", "@tauri-apps/plugin-shell": ">=2.0.0",
"@tauri-apps/plugin-websocket": "~2",
"axios": "^1.7.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.11.11", "framer-motion": "^11.11.11",
"i18next": "^23.16.2", "i18next": "^23.16.2",
@@ -23,6 +25,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.5.1", "react-hotkeys-hook": "^4.5.1",
"react-i18next": "^15.1.0", "react-i18next": "^15.1.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.27.0", "react-router-dom": "^6.27.0",
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },

751
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

73
src-tauri/Cargo.lock generated
View File

@@ -350,6 +350,7 @@ dependencies = [
"tauri-nspanel", "tauri-nspanel",
"tauri-plugin-http", "tauri-plugin-http",
"tauri-plugin-shell", "tauri-plugin-shell",
"tauri-plugin-websocket",
] ]
[[package]] [[package]]
@@ -591,6 +592,12 @@ dependencies = [
"syn 2.0.79", "syn 2.0.79",
] ]
[[package]]
name = "data-encoding"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2"
[[package]] [[package]]
name = "data-url" name = "data-url"
version = "0.3.1" version = "0.3.1"
@@ -3043,6 +3050,17 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.8" version = "0.10.8"
@@ -3547,6 +3565,25 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tauri-plugin-websocket"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2793b69e1dd494beed1e0a29865f38bd00011e7ccc35a3cfde8e3939328b790"
dependencies = [
"futures-util",
"http",
"log",
"rand 0.8.5",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror",
"tokio",
"tokio-tungstenite",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.1.0" version = "2.1.0"
@@ -3759,6 +3796,22 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.12" version = "0.7.12"
@@ -3883,6 +3936,26 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.5",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror",
"utf-8",
]
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.2" version = "1.0.2"

View File

@@ -25,6 +25,7 @@ serde_json = "1"
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", rev = "005240c" } tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", rev = "005240c" }
tauri-plugin-websocket = "2"
[profile.dev] [profile.dev]
incremental = true # Compile your binary in smaller steps. incremental = true # Compile your binary in smaller steps.

View File

@@ -5,7 +5,7 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:event:allow-listen", "core:event:allow-listen",
"core:window:default", "core:window:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:webview:allow-create-webview", "core:webview:allow-create-webview",
"core:window:allow-show", "core:window:allow-show",
@@ -24,8 +24,15 @@
"http:allow-fetch-send", "http:allow-fetch-send",
{ {
"identifier": "http:default", "identifier": "http:default",
"allow": [{ "url": "https://*.tauri.app" }], "allow": [
"deny": [{ "url": "https://private.tauri.app" }] {
} "url": "http://localhost:2900"
}
],
"deny": []
},
"websocket:default",
"websocket:allow-connect",
"websocket:allow-send"
] ]
} }

View File

@@ -12,6 +12,8 @@ fn main() {
coco_lib::run(); coco_lib::run();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_nspanel::init()) .plugin(tauri_nspanel::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
show_panel, show_panel,

View File

@@ -14,15 +14,13 @@
"windows": [ "windows": [
{ {
"title": "Coco AI", "title": "Coco AI",
"width": 800, "width": 900,
"height": 110, "height": 800,
"maxHeight": 600, "maxHeight": 800,
"transparent": true, "transparent": true,
"resizable": true, "resizable": true,
"fullscreen": false, "fullscreen": false,
"decorations": false, "decorations": false
"label": "main",
"url": "/"
} }
], ],
"security": { "security": {
@@ -46,6 +44,7 @@
] ]
}, },
"plugins": { "plugins": {
"window": {} "window": {},
"websocket": {}
} }
} }

View File

@@ -0,0 +1,73 @@
import { fetch } from "@tauri-apps/plugin-http";
const baseURL = "http://localhost:2900";
interface FetchRequestConfig {
url: string;
method?: "GET" | "POST" | "PUT" | "DELETE";
headers?: Record<string, string>;
body?: any;
timeout?: number;
parseAs?: "json" | "text" | "binary";
}
interface FetchResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Headers;
}
const timeoutPromise = (ms: number) => {
return new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Request timed out after ${ms} ms`)), ms)
);
};
export const tauriFetch = async <T = any>({
url,
method = "GET",
headers = {},
body,
timeout = 30,
parseAs = "json",
}: FetchRequestConfig): Promise<FetchResponse<T>> => {
try {
url = baseURL + url;
if (method !== "GET") {
headers["Content-Type"] = "application/json";
}
const fetchPromise = fetch(url, {
method,
headers,
body,
});
const response = await Promise.race([
fetchPromise,
timeoutPromise(timeout * 1000),
]);
const statusText = response.ok ? "OK" : "Error";
let data: any;
if (parseAs === "json") {
data = await response.json();
} else if (parseAs === "text") {
data = await response.text();
} else {
data = await response.arrayBuffer();
}
return {
data,
status: response.status,
statusText,
headers: response.headers,
};
} catch (error) {
console.error("Request failed:", error);
throw error;
}
};

View File

@@ -1,4 +1,4 @@
import { SendHorizontal } from "lucide-react"; import { SendHorizontal, OctagonX } from "lucide-react";
import { import {
useState, useState,
type FormEvent, type FormEvent,
@@ -9,10 +9,15 @@ import {
interface ChatInputProps { interface ChatInputProps {
onSend: (message: string) => void; onSend: (message: string) => void;
disabled?: boolean; disabled: boolean;
disabledChange: (disabled: boolean) => void;
} }
export function ChatInput({ onSend, disabled }: ChatInputProps) { export function ChatInput({
onSend,
disabled,
disabledChange,
}: ChatInputProps) {
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -62,13 +67,25 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
className="w-full resize-none rounded-lg border-0 bg-gray-50 dark:bg-gray-800/50 py-3 pl-4 pr-12 text-sm leading-6 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-700 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-shadow" className="w-full resize-none rounded-lg border-0 bg-gray-50 dark:bg-gray-800/50 py-3 pl-4 pr-12 text-sm leading-6 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-700 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-shadow"
disabled={disabled} disabled={disabled}
/> />
<button {disabled ? (
type="submit" <button
disabled={disabled || !input.trim()} type="submit"
className="absolute right-2 bottom-2.5 rounded-md p-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className="absolute right-2 bottom-2.5 rounded-md p-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
> >
<SendHorizontal className="h-5 w-5" /> <OctagonX
</button> className="h-5 w-5"
onClick={() => disabledChange(false)}
/>
</button>
) : (
<button
type="submit"
disabled={disabled || !input.trim()}
className="absolute right-2 bottom-2.5 rounded-md p-2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<SendHorizontal className="h-5 w-5" />
</button>
)}
</div> </div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400"> <p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Press Enter to send, Shift + Enter for new line Press Enter to send, Shift + Enter for new line

View File

@@ -11,15 +11,18 @@ interface ChatMessageProps {
export function ChatMessage({ message, isTyping }: ChatMessageProps) { export function ChatMessage({ message, isTyping }: ChatMessageProps) {
const [isAnimationComplete, setIsAnimationComplete] = useState(!isTyping); const [isAnimationComplete, setIsAnimationComplete] = useState(!isTyping);
const isAssistant = message.role === "assistant"; const isAssistant = message._source?.type === "assistant";
return ( return (
<div <div
className={`py-8 ${ className={`py-8 flex ${isAssistant ? "justify-start" : "justify-end"}`}
isAssistant ? "bg-gray-50/50 dark:bg-gray-800/30" : ""
}`}
> >
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 flex gap-6"> <div
className={`max-w-3xl px-4 sm:px-6 lg:px-8 flex gap-4 ${
isAssistant ? "" : "flex-row-reverse"
}`}
>
{/* 头像部分 */}
<div <div
className={`flex-shrink-0 h-8 w-8 rounded-lg flex items-center justify-center ${ className={`flex-shrink-0 h-8 w-8 rounded-lg flex items-center justify-center ${
isAssistant isAssistant
@@ -33,7 +36,13 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
<User className="h-5 w-5 text-white" /> <User className="h-5 w-5 text-white" />
)} )}
</div> </div>
<div className="flex-1 space-y-2">
{/* 消息内容 */}
<div
className={`flex-1 space-y-2 ${
isAssistant ? "text-left" : "text-right"
}`}
>
<p className="font-medium text-sm text-gray-900 dark:text-gray-100"> <p className="font-medium text-sm text-gray-900 dark:text-gray-100">
{isAssistant ? "Assistant" : "You"} {isAssistant ? "Assistant" : "You"}
</p> </p>
@@ -42,7 +51,7 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
{isTyping && isAssistant ? ( {isTyping && isAssistant ? (
<> <>
<TypingAnimation <TypingAnimation
text={message.content} text={message._source?.message || ""}
onComplete={() => setIsAnimationComplete(true)} onComplete={() => setIsAnimationComplete(true)}
/> />
{!isAnimationComplete && ( {!isAnimationComplete && (
@@ -50,7 +59,7 @@ export function ChatMessage({ message, isTyping }: ChatMessageProps) {
)} )}
</> </>
) : ( ) : (
message.content message._source?.message || ""
)} )}
</p> </p>
</div> </div>

View File

@@ -42,9 +42,9 @@ export function Sidebar({
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-1"> <div className="flex-1 overflow-y-auto px-3 pb-3 space-y-1">
{chats.map((chat) => ( {chats.map((chat) => (
<div <div
key={chat.id} key={chat._id}
className={`group relative rounded-lg transition-all ${ className={`group relative rounded-lg transition-all ${
activeChat.id === chat.id activeChat._id === chat._id
? isDark ? isDark
? "bg-gray-700/50 text-white" ? "bg-gray-700/50 text-white"
: "bg-white text-gray-900 shadow-sm" : "bg-white text-gray-900 shadow-sm"
@@ -59,7 +59,7 @@ export function Sidebar({
> >
<MessageSquare <MessageSquare
className={`h-4 w-4 flex-shrink-0 ${ className={`h-4 w-4 flex-shrink-0 ${
activeChat.id === chat.id activeChat._id === chat._id
? isDark ? isDark
? "text-indigo-400" ? "text-indigo-400"
: "text-indigo-600" : "text-indigo-600"
@@ -68,11 +68,11 @@ export function Sidebar({
: "text-gray-500" : "text-gray-500"
}`} }`}
/> />
<span className="truncate">{chat.title}</span> <span className="truncate">{chat.title || chat._id}</span>
</button> </button>
{chats.length > 1 && ( {/* {chats.length > 1 && (
<button <button
onClick={() => onDeleteChat(chat.id)} onClick={() => onDeleteChat(chat._id)}
className={`absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-md opacity-0 group-hover:opacity-100 transition-all ${ className={`absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-md opacity-0 group-hover:opacity-100 transition-all ${
isDark isDark
? "hover:bg-gray-600 text-gray-400 hover:text-red-400" ? "hover:bg-gray-600 text-gray-400 hover:text-red-400"
@@ -81,8 +81,8 @@ export function Sidebar({
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
)} )} */}
{activeChat.id === chat.id && ( {activeChat._id === chat._id && (
<div <div
className={`absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 rounded-full ${ className={`absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 rounded-full ${
isDark ? "bg-indigo-400" : "bg-indigo-600" isDark ? "bg-indigo-400" : "bg-indigo-600"

View File

@@ -1,5 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import "./index.css";
interface TypingAnimationProps { interface TypingAnimationProps {
text: string; text: string;
onComplete?: () => void; onComplete?: () => void;
@@ -27,5 +29,7 @@ export function TypingAnimation({
} }
}, [currentIndex, text, speed, onComplete]); }, [currentIndex, text, speed, onComplete]);
return <>{displayedText}</>; // console.log("text", text);
return <ReactMarkdown>{text}</ReactMarkdown>;
} }

View File

@@ -0,0 +1,8 @@
.markdown-content p {
margin: 1em 0;
line-height: 1.6;
}
.markdown-content strong {
font-weight: bold;
}

View File

@@ -1,41 +1,114 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Menu } from "lucide-react"; import { Menu, Loader } from "lucide-react";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { ChatMessage } from "./ChatMessage"; import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput"; import { ChatInput } from "./ChatInput";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import type { Message, Chat } from "./types"; import type { Chat, Message } from "./types";
import { useTheme } from "../ThemeProvider"; import { useTheme } from "../ThemeProvider";
import ChatSwitch from "../SearchChat/ChatSwitch"; import ChatSwitch from "../SearchChat/ChatSwitch";
import { Footer } from "../SearchChat/Footer"; import { Footer } from "../SearchChat/Footer";
import { tauriFetch } from "../../api/tauriFetchClient";
const INITIAL_CHAT: Chat = { import { useWebSocket } from "../../hooks/useWebSocket";
id: "1",
title: "New Chat",
messages: [
{
id: "1",
role: "assistant",
content: "Hello! How can I help you today?",
timestamp: new Date(),
},
],
createdAt: new Date(),
};
interface ChatAIProps { interface ChatAIProps {
changeMode: (isChatMode: boolean) => void; changeMode: (isChatMode: boolean) => void;
} }
export default function ChatAI({ changeMode }: ChatAIProps) { export default function ChatAI({ changeMode }: ChatAIProps) {
const [chats, setChats] = useState<Chat[]>([INITIAL_CHAT]); const [chats, setChats] = useState<Chat[]>([]);
const [activeChat, setActiveChat] = useState<Chat>(INITIAL_CHAT); const [activeChat, setActiveChat] = useState<Chat>();
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isTyping, setIsTyping] = useState(false); const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const { theme } = useTheme(); const { theme } = useTheme();
const [websocketId, setWebsocketId] = useState("");
const [curMessage, setCurMessage] = useState("");
const [curChatEnd, setCurChatEnd] = useState(true);
const { messages, setMessages } = useWebSocket(
"ws://localhost:2900/ws",
(msg) => {
if (msg.includes("websocket_session_id")) {
const array = msg.split(" ");
setWebsocketId(array[2]);
}
if (msg.includes("PRIVATE")) {
if (msg.includes("assistant finished output")) {
setCurChatEnd(true);
} else {
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
const chunkData = JSON.parse(cleanedData);
setCurMessage((prev) => prev + chunkData.message_chunk);
return chunkData.message_chunk;
} catch (error) {
console.error("JSON Parse error:", error);
}
return "";
}
}
return "";
}
);
// websocket
// websocket
useEffect(() => {
if (messages.length === 0 || !activeChat?._id) return;
const simulateAssistantResponse = () => {
console.log("messages", messages);
const assistantMessage: Message = {
_id: activeChat._id,
_source: {
type: "assistant",
message: messages,
},
};
const updatedChat = {
...activeChat,
messages: [...(activeChat.messages || []), assistantMessage],
};
setMessages("");
setCurMessage("");
setActiveChat(updatedChat);
setTimeout(() => setIsTyping(false), 1000);
};
if (curChatEnd) {
simulateAssistantResponse();
}
}, [messages, isTyping, curChatEnd]);
// getChatHistory
useEffect(() => {
getChatHistory();
}, []);
const getChatHistory = async () => {
try {
const response = await tauriFetch({
url: "/chat/_history",
method: "GET",
});
console.log("_history", response);
const hits = response.data?.hits?.hits || [];
setChats(hits);
if (hits[0]) {
onSelectChat(hits[0]);
} else {
createNewChat();
}
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
const scrollToBottom = () => { const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ messagesEndRef.current?.scrollIntoView({
behavior: "smooth", behavior: "smooth",
@@ -45,31 +118,28 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [activeChat.messages, isTyping]); }, [activeChat?.messages, isTyping, curMessage]);
const createNewChat = () => { const createNewChat = async () => {
const newChat: Chat = { try {
id: Date.now().toString(), const response = await tauriFetch({
title: "New Chat", url: "/chat/_new",
messages: [ method: "POST",
{ });
id: "1", console.log("_new", response);
role: "assistant", const newChat: Chat = response.data;
content: "Hello! How can I help you today?", setChats((prev) => [newChat, ...prev]);
timestamp: new Date(), setActiveChat(newChat);
}, setIsSidebarOpen(false);
], } catch (error) {
createdAt: new Date(), console.error("Failed to fetch user data:", error);
}; }
setChats((prev) => [newChat, ...prev]);
setActiveChat(newChat);
setIsSidebarOpen(false);
}; };
const deleteChat = (chatId: string) => { const deleteChat = (chatId: string) => {
setChats((prev) => prev.filter((chat) => chat.id !== chatId)); setChats((prev) => prev.filter((chat) => chat._id !== chatId));
if (activeChat.id === chatId) { if (activeChat?._id === chatId) {
const remainingChats = chats.filter((chat) => chat.id !== chatId); const remainingChats = chats.filter((chat) => chat._id !== chatId);
if (remainingChats.length > 0) { if (remainingChats.length > 0) {
setActiveChat(remainingChats[0]); setActiveChat(remainingChats[0]);
} else { } else {
@@ -78,54 +148,79 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
} }
}; };
const handleSendMessage = (content: string) => { const handleSendMessage = async (content: string) => {
const newMessage: Message = { if (!activeChat?._id) return;
id: Date.now().toString(), try {
role: "user", const response = await tauriFetch({
content, url: `/chat/${activeChat?._id}/_send`,
timestamp: new Date(), method: "POST",
}; headers: {
WEBSOCKET_SESSION_ID: websocketId,
const updatedChat = { },
...activeChat, body: JSON.stringify({ message: content }),
title: });
activeChat.messages.length === 1 console.log("_send", response, websocketId);
? content.slice(0, 30) + "..." const updatedChat: Chat = {
: activeChat.title, ...activeChat,
messages: [...activeChat.messages, newMessage], messages: [...(activeChat?.messages || []), ...(response.data || [])],
};
setActiveChat(updatedChat);
setChats((prev) =>
prev.map((chat) => (chat.id === activeChat.id ? updatedChat : chat))
);
setIsTyping(true);
// Simulate assistant response
setTimeout(() => {
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: "assistant",
content:
"This is a simulated response. In a real application, this would be connected to an AI backend.",
timestamp: new Date(),
}; };
const finalChat = { setActiveChat(updatedChat);
...updatedChat, setIsTyping(true);
messages: [...updatedChat.messages, assistantMessage], setCurChatEnd(false);
}; } catch (error) {
console.error("Failed to fetch user data:", error);
}
};
setActiveChat(finalChat); const chatHistory = async (chat: Chat) => {
setChats((prev) => try {
prev.map((chat) => (chat.id === activeChat.id ? finalChat : chat)) const response = await tauriFetch({
); url: `/chat/${chat._id}/_history`,
setTimeout(() => setIsTyping(false), 500); method: "GET",
}, 1000); });
console.log("id_history", response);
const hits = response.data?.hits?.hits || [];
const updatedChat: Chat = {
...chat,
messages: hits,
};
setActiveChat(updatedChat);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
const chatClose = async () => {
if (!activeChat?._id) return;
try {
const response = await tauriFetch({
url: `/chat/${activeChat._id}/_close`,
method: "POST",
});
console.log("_close", response);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
const onSelectChat = async (chat: any) => {
chatClose();
try {
const response = await tauriFetch({
url: `/chat/${chat._id}/_open`,
method: "POST",
});
console.log("_open", response);
chatHistory(response.data);
setIsSidebarOpen(false);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
}; };
return ( return (
<div className="h-screen pb-8"> <div className="h-screen pb-8 rounded-xl overflow-hidden">
<div className="h-[100%] flex"> <div className="h-[100%] flex">
{/* Sidebar */} {/* Sidebar */}
<div <div
@@ -135,17 +230,16 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
theme === "dark" ? "bg-gray-800" : "bg-gray-100" theme === "dark" ? "bg-gray-800" : "bg-gray-100"
}`} }`}
> >
<Sidebar {activeChat ? (
chats={chats} <Sidebar
activeChat={activeChat} chats={chats}
isDark={theme === "dark"} activeChat={activeChat}
onNewChat={createNewChat} isDark={theme === "dark"}
onSelectChat={(chat: any) => { onNewChat={createNewChat}
setActiveChat(chat); onSelectChat={onSelectChat}
setIsSidebarOpen(false); onDeleteChat={deleteChat}
}} />
onDeleteChat={deleteChat} ) : null}
/>
</div> </div>
{/* Main content */} {/* Main content */}
@@ -178,18 +272,44 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
</header> </header>
{/* Chat messages */} {/* Chat messages */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto custom-scrollbar">
{activeChat.messages.map((message, index) => ( {activeChat?.messages?.map((message, index) => (
<ChatMessage <ChatMessage
key={message.id} key={message._id + index}
message={message} message={message}
isTyping={ isTyping={
isTyping && isTyping &&
index === activeChat.messages.length - 1 && index === (activeChat.messages?.length || 0) - 1 &&
message.role === "assistant" message._source?.type === "assistant"
} }
/> />
))} ))}
{!curChatEnd && activeChat?._id ? (
<ChatMessage
key={"last"}
message={{
_id: activeChat?._id,
_source: {
type: "assistant",
message: curMessage,
},
}}
isTyping={isTyping}
/>
) : null}
{/* Loading */}
{/* {isTyping && (
<div className="flex pt-0 pb-4 pl-20">
<Loader className="animate-spin h-5 w-5 text-gray-500" />
</div>
)} */}
{isTyping && (
<div className="flex pt-0 pb-4 pl-20 gap-2 items-center text-gray-500 dark:text-gray-400">
<div className="w-2 h-2 rounded-full bg-current animate-bounce" />
<div className="w-2 h-2 rounded-full bg-current animate-bounce [animation-delay:0.2s]" />
<div className="w-2 h-2 rounded-full bg-current animate-bounce [animation-delay:0.4s]" />
</div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
@@ -199,12 +319,16 @@ export default function ChatAI({ changeMode }: ChatAIProps) {
theme === "dark" ? "border-gray-800" : "border-gray-200" theme === "dark" ? "border-gray-800" : "border-gray-200"
}`} }`}
> >
<ChatInput onSend={handleSendMessage} disabled={isTyping} /> <ChatInput
onSend={handleSendMessage}
disabled={isTyping}
disabledChange={setIsTyping}
/>
</div> </div>
</div> </div>
</div> </div>
<Footer isChat={true}/> <Footer isChat={true} />
</div> </div>
); );
} }

View File

@@ -1,13 +1,26 @@
export interface Message { export interface Message {
id: string; _id: string;
role: 'user' | 'assistant'; _source: ISource;
content: string; [key: string]: any;
timestamp: Date;
} }
export interface ISource {
id?: string;
created?: string;
updated?: string;
status?: string;
session_id?: string;
type?: string;
message?: any;
}
export interface Chat { export interface Chat {
id: string; _id: string;
title: string; _index?: string;
messages: Message[]; _type?: string;
createdAt: Date; _source?: ISource;
} _score?: number;
found?: boolean;
title?: string;
messages?: any[];
[key: string]: any;
}

View File

@@ -44,7 +44,7 @@ export const Footer = ({ isChat }: FooterProps) => {
return ( return (
<div <div
style={{ zIndex: 999 }} style={{ zIndex: 999 }}
className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-4 h-8 flex items-center justify-between" className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-4 h-8 flex items-center justify-between rounded-xl rounded-t-none overflow-hidden"
> >
<div className="flex items-center"> <div className="flex items-center">
<Menu as="div" className="relative"> <Menu as="div" className="relative">

View File

@@ -6,14 +6,13 @@ import Search from "./Search";
import ChatAI from "../ChatAI"; import ChatAI from "../ChatAI";
export default function SearchChat() { export default function SearchChat() {
const [isChatMode, setIsChatMode] = useState(false); const [isChatMode, setIsChatMode] = useState(true);
async function changeMode(value: boolean) { async function changeMode(value: boolean) {
console.log(11111, value);
if (value) { if (value) {
await getCurrentWebviewWindow().setSize(new LogicalSize(900, 700)); await getCurrentWebviewWindow()?.setSize(new LogicalSize(900, 800));
} else { } else {
await getCurrentWebviewWindow().setSize(new LogicalSize(800, 110)); await getCurrentWebviewWindow()?.setSize(new LogicalSize(900, 110));
} }
setIsChatMode(value); setIsChatMode(value);
} }

55
src/hooks/useWebSocket.ts Normal file
View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
export function useWebSocket(
url: string,
filterMessages?: (message: string) => string
) {
const [ws, setWs] = useState<WebSocket | null>(null);
const [messages, setMessages] = useState<string>("");
const [connected, setConnected] = useState(false);
useEffect(() => {
// 创建 WebSocket 连接
const websocket = new WebSocket(url);
websocket.onopen = () => {
console.log("WebSocket 连接成功");
setConnected(true);
};
websocket.onmessage = (event) => {
// console.log("收到消息:", event.data);
const data = filterMessages ? filterMessages(event.data) : event.data;
if (data) {
setMessages((prevMessages) => prevMessages + data);
}
};
websocket.onclose = () => {
console.log("WebSocket 连接关闭");
setConnected(false);
};
websocket.onerror = (error) => {
console.error("WebSocket 连接错误:", error);
};
// 将 WebSocket 实例保存在状态中
setWs(websocket);
// 在组件卸载时关闭 WebSocket 连接
return () => {
websocket.close();
};
}, [url]);
// 发送消息的函数
const sendMessage = (message: string) => {
if (ws && connected) {
ws.send(message);
console.log("发送消息:", message);
}
};
return { messages, connected, sendMessage, setMessages };
}

View File

@@ -26,7 +26,7 @@
} }
.dark body { .dark body {
@apply text-gray-100; @apply text-gray-100 rounded-lg shadow-lg overflow-hidden antialiased;
} }
} }

View File

@@ -27,5 +27,12 @@ export default defineConfig(async () => ({
// 3. tell vite to ignore watching `src-tauri` // 3. tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"], ignored: ["**/src-tauri/**"],
}, },
proxy: {
"/chat": {
target: "http://localhost:2900",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/chat/, ""),
},
},
}, },
})); }));