Skip to content

Commit 53258ee

Browse files
RainyNight9medcl
andauthored
feat: add support for switching AI assistants (#395)
* feat: add assistant * build: build warning * fix: filter http query_args and convert only supported values * chore: server name truncate * feat: add support for AI assistant * feat: add support for AI assistant --------- Co-authored-by: medcl <m@medcl.net>
1 parent e8d197f commit 53258ee

19 files changed

Lines changed: 530 additions & 304 deletions

File tree

docs/content.en/docs/release-notes/_index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Information about release notes of Coco Server is provided here.
2424
- feat: add application management to the plugin #374
2525
- feat: add keyboard-only operation to history list #385
2626
- feat: add error notification #386
27+
- feat: add support for AI assistant #394
2728

2829
### Bug fix
2930

src-tauri/src/assistant/mod.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ pub async fn chat_history<R: Runtime>(
3535
format!("Error get history: {}", e)
3636
})?;
3737

38-
3938
common::http::get_response_body_text(response).await
4039
}
4140

@@ -135,17 +134,18 @@ pub async fn new_chat<R: Runtime>(
135134
let mut headers = HashMap::new();
136135
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
137136

138-
let response = HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body)
139-
.await
140-
.map_err(|e| format!("Error sending message: {}", e))?;
137+
let response =
138+
HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body)
139+
.await
140+
.map_err(|e| format!("Error sending message: {}", e))?;
141141

142142
let text = response
143143
.text()
144144
.await
145145
.map_err(|e| format!("Failed to read response body: {}", e))?;
146146

147-
let chat_response: GetResponse = serde_json::from_str(&text)
148-
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
147+
let chat_response: GetResponse =
148+
serde_json::from_str(&text).map_err(|e| format!("Failed to parse response JSON: {}", e))?;
149149

150150
if chat_response.result != "created" {
151151
return Err(format!("Unexpected result: {}", chat_response.result));
@@ -179,8 +179,8 @@ pub async fn send_message<R: Runtime>(
179179
query_params,
180180
Some(body),
181181
)
182-
.await
183-
.map_err(|e| format!("Error cancel session: {}", e))?;
182+
.await
183+
.map_err(|e| format!("Error cancel session: {}", e))?;
184184

185185
common::http::get_response_body_text(response).await
186186
}
@@ -222,8 +222,20 @@ pub async fn update_session_chat(
222222
None,
223223
Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())),
224224
)
225-
.await
226-
.map_err(|e| format!("Error updating session: {}", e))?;
225+
.await
226+
.map_err(|e| format!("Error updating session: {}", e))?;
227227

228228
Ok(response.status().is_success())
229229
}
230+
231+
#[tauri::command]
232+
pub async fn assistant_search<R: Runtime>(
233+
_app_handle: AppHandle<R>,
234+
server_id: String,
235+
) -> Result<String, String> {
236+
let response = HttpClient::get(&server_id, "/assistant/_search", None)
237+
.await
238+
.map_err(|e| format!("Error searching assistants: {}", e))?;
239+
240+
common::http::get_response_body_text(response).await
241+
}

src-tauri/src/common/search.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ where
5050
{
5151
let body_text = get_response_body_text(response).await?;
5252

53-
dbg!(&body_text);
53+
// dbg!(&body_text);
5454

5555
let search_response: SearchResponse<T> = serde_json::from_str(&body_text)
5656
.map_err(|e| format!("Failed to deserialize search response: {}", e))?;

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ pub fn run() {
123123
assistant::cancel_session_chat,
124124
assistant::delete_session_chat,
125125
assistant::update_session_chat,
126+
assistant::assistant_search,
126127
// server::get_coco_server_datasources,
127128
// server::get_coco_server_connectors,
128129
server::websocket::connect_to_server,

src/commands/servers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ export const update_session_chat = (payload: {
248248
return invokeWithErrorHandler<boolean>("update_session_chat", payload);
249249
};
250250

251+
export const assistant_search = (payload: {
252+
serverId: string;
253+
}): Promise<boolean> => {
254+
return invokeWithErrorHandler<boolean>("assistant_search", payload);
255+
};
256+
251257
export const upload_attachment = async (payload: UploadAttachmentPayload) => {
252258
const response = await invokeWithErrorHandler<UploadAttachmentResponse>("upload_attachment", {
253259
...payload,
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { useEffect, useState, useRef, useCallback } from "react";
2+
import { ChevronDownIcon, RefreshCw, Check } from "lucide-react";
3+
import { useTranslation } from "react-i18next";
4+
5+
import { useAppStore } from "@/stores/appStore";
6+
import logoImg from "@/assets/icon.svg";
7+
import platformAdapter from "@/utils/platformAdapter";
8+
import { useClickAway } from "@/hooks/useClickAway";
9+
import VisibleKey from "@/components/Common/VisibleKey";
10+
import { useConnectStore } from "@/stores/connectStore";
11+
import FontIcon from "@/components/Common/Icons/FontIcon";
12+
13+
interface AssistantListProps {
14+
showChatHistory?: boolean;
15+
}
16+
17+
export function AssistantList({ showChatHistory = true }: AssistantListProps) {
18+
const { t } = useTranslation();
19+
const isTauri = useAppStore((state) => state.isTauri);
20+
const currentService = useConnectStore((state) => state.currentService);
21+
const currentAssistant = useConnectStore((state) => state.currentAssistant);
22+
const setCurrentAssistant = useConnectStore((state) => state.setCurrentAssistant);
23+
24+
const [isOpen, setIsOpen] = useState(false);
25+
const [isRefreshing, setIsRefreshing] = useState(false);
26+
27+
const menuRef = useRef<HTMLDivElement>(null);
28+
29+
useClickAway(menuRef, () => setIsOpen(false));
30+
const [assistants, setAssistants] = useState<any[]>([]);
31+
32+
33+
const fetchAssistant = useCallback(async () => {
34+
if (!isTauri) return;
35+
if (!currentService?.id) return;
36+
platformAdapter
37+
.commands("assistant_search", {
38+
serverId: currentService?.id,
39+
})
40+
.then((res: any) => {
41+
res = res ? JSON.parse(res) : null;
42+
console.log("assistant_search", res);
43+
const assistantList = res?.hits?.hits || [];
44+
setAssistants(assistantList);
45+
if (assistantList.length > 0 && !currentAssistant) {
46+
setCurrentAssistant(assistantList[0]);
47+
}
48+
});
49+
}, []);
50+
51+
useEffect(() => {
52+
fetchAssistant();
53+
}, []);
54+
55+
const handleRefresh = async () => {
56+
setIsRefreshing(true);
57+
await fetchAssistant();
58+
setTimeout(() => setIsRefreshing(false), 1000);
59+
};
60+
61+
return (
62+
<div className="relative" ref={menuRef}>
63+
<button
64+
onClick={() => setIsOpen(!isOpen)}
65+
className="h-6 p-1 px-1.5 flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] text-sm/6 font-semibold text-gray-800 dark:text-[#d8d8d8] border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
66+
>
67+
<div className="w-4 h-4 flex justify-center items-center bg-white rounded-full">
68+
{currentAssistant?._source?.icon?.startsWith("font_") ? (
69+
<FontIcon
70+
name={currentAssistant._source.icon}
71+
className="w-3 h-3"
72+
/>
73+
) : (
74+
<img
75+
src={logoImg}
76+
className="w-3 h-3"
77+
alt={t("assistant.message.logo")}
78+
/>
79+
)}
80+
</div>
81+
<div className="max-w-[100px] truncate">
82+
{currentAssistant?._source?.name || "Coco AI"}
83+
</div>
84+
{showChatHistory && isTauri && (
85+
<ChevronDownIcon
86+
className={`size-4 text-gray-500 dark:text-gray-400 transition-transform ${
87+
isOpen ? "rotate-180" : ""
88+
}`}
89+
/>
90+
)}
91+
</button>
92+
93+
{showChatHistory && isTauri && isOpen && (
94+
<div className="absolute z-50 top-full mt-1 left-0 w-64 rounded-xl bg-white dark:bg-[#202126] p-2 text-sm/6 text-gray-800 dark:text-white shadow-lg border border-gray-200 dark:border-gray-700 focus:outline-none max-h-[calc(100vh-80px)] overflow-y-auto">
95+
<div className="sticky top-0 mb-2 px-2 py-1 text-sm font-medium text-gray-900 dark:text-white bg-white dark:bg-[#202126] flex justify-between">
96+
<div>AI Assistant</div>
97+
<button
98+
onClick={handleRefresh}
99+
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
100+
disabled={isRefreshing}
101+
>
102+
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
103+
<RefreshCw
104+
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
105+
isRefreshing ? "animate-spin" : ""
106+
}`}
107+
/>
108+
</VisibleKey>
109+
</button>
110+
</div>
111+
{assistants.map((assistant) => (
112+
<button
113+
key={assistant._id}
114+
onClick={() => {
115+
setCurrentAssistant(assistant);
116+
setIsOpen(false);
117+
}}
118+
className={`w-full flex items-center gap-2 rounded-lg p-1 py-1.5 mb-1 ${
119+
currentAssistant?._id === assistant._id
120+
? "bg-[#F3F4F6] dark:bg-[#1F2937]"
121+
: "hover:bg-[#F3F4F6] dark:hover:bg-[#1F2937]"
122+
}
123+
}`}
124+
>
125+
{assistant._source?.icon?.startsWith("font_") ? (
126+
<FontIcon name={assistant._source?.icon} className="w-4 h-4" />
127+
) : (
128+
<img
129+
src={logoImg}
130+
className="w-4 h-4 rounded-full"
131+
alt={assistant.name}
132+
/>
133+
)}
134+
<div className="text-left flex-1 min-w-0">
135+
<div className="font-medium text-gray-900 dark:text-white truncate">
136+
{assistant._source?.name || "-"}
137+
</div>
138+
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
139+
{assistant._source?.description || ""}
140+
</div>
141+
</div>
142+
{currentAssistant?._id === assistant._id && (
143+
<div className="flex items-center">
144+
<VisibleKey
145+
shortcut="↓↑"
146+
shortcutClassName="w-6 -translate-x-4"
147+
>
148+
<Check className="w-4 h-4 text-gray-500 dark:text-gray-400" />
149+
</VisibleKey>
150+
</div>
151+
)}
152+
</button>
153+
))}
154+
</div>
155+
)}
156+
</div>
157+
);
158+
}

0 commit comments

Comments
 (0)