feat: support sending files in chat messages (#764)

* feat: support sending files in chat messages

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* update

* docs: update changelog
This commit is contained in:
ayangweb
2025-07-31 15:36:03 +08:00
committed by GitHub
parent d2735ec13b
commit 855fb2a168
31 changed files with 605 additions and 328 deletions

View File

@@ -15,6 +15,7 @@ Information about release notes of Coco App is provided here.
- feat: enhance ui for skipped version #834
- feat: support installing local extensions #749
- feat: support sending files in chat messages #764
### 🐛 Bug fix

View File

@@ -115,21 +115,34 @@ pub async fn cancel_session_chat(
pub async fn chat_create(
app_handle: AppHandle,
server_id: String,
message: String,
message: Option<String>,
attachments: Option<Vec<String>>,
query_params: Option<HashMap<String, Value>>,
client_id: String,
) -> Result<(), String> {
let body = if !message.is_empty() {
let message = ChatRequestMessage {
message: Some(message),
println!("chat_create message: {:?}", message);
println!("chat_create attachments: {:?}", attachments);
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
if message_empty && attachments_empty {
return Err("Message and attachments are empty".to_string());
}
let body = {
let request_message: ChatRequestMessage = ChatRequestMessage {
message,
attachments,
};
println!("chat_create body: {:?}", request_message);
Some(
serde_json::to_string(&message)
serde_json::to_string(&request_message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else {
None
};
let response = HttpClient::advanced_post(
@@ -165,8 +178,6 @@ pub async fn chat_create(
if let Err(err) = app_handle.emit(&client_id, line) {
log::error!("Emit failed: {:?}", err);
print!("Error sending message: {:?}", err);
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
}
}
@@ -179,21 +190,34 @@ pub async fn chat_chat(
app_handle: AppHandle,
server_id: String,
session_id: String,
message: String,
message: Option<String>,
attachments: Option<Vec<String>>,
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
client_id: String,
) -> Result<(), String> {
let body = if !message.is_empty() {
let message = ChatRequestMessage {
message: Some(message),
println!("chat_chat message: {:?}", message);
println!("chat_chat attachments: {:?}", attachments);
let message_empty = message.as_ref().map_or(true, |m| m.is_empty());
let attachments_empty = attachments.as_ref().map_or(true, |a| a.is_empty());
if message_empty && attachments_empty {
return Err("Message and attachments are empty".to_string());
}
let body = {
let request_message = ChatRequestMessage {
message,
attachments,
};
println!("chat_chat body: {:?}", request_message);
Some(
serde_json::to_string(&message)
serde_json::to_string(&request_message)
.map_err(|e| format!("Failed to serialize message: {}", e))?
.into(),
)
} else {
None
};
let path = format!("/chat/{}/_chat", session_id);
@@ -235,6 +259,9 @@ pub async fn chat_chat(
if let Err(err) = app_handle.emit(&client_id, line) {
log::error!("Emit failed: {:?}", err);
print!("Error sending message: {:?}", err);
let _ = app_handle.emit("chat-create-error", format!("Emit failed: {:?}", err));
}
}

View File

@@ -3,7 +3,10 @@ use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequestMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachments: Option<Vec<String>>,
}
#[allow(dead_code)]

View File

@@ -145,7 +145,7 @@ pub fn run() {
// server::get_coco_server_connectors,
get_app_search_source,
server::attachment::upload_attachment,
server::attachment::get_attachment,
server::attachment::get_attachment_by_ids,
server::attachment::delete_attachment,
server::transcription::transcription,
server::system_settings::get_system_settings,

View File

@@ -72,11 +72,19 @@ pub async fn upload_attachment(
}
#[command]
pub async fn get_attachment(server_id: String, session_id: String) -> Result<Value, String> {
let mut query_params = Vec::new();
query_params.push(format!("session={}", session_id));
pub async fn get_attachment_by_ids(
server_id: String,
attachments: Vec<String>,
) -> Result<Value, String> {
println!("get_attachment_by_ids server_id: {}", server_id);
println!("get_attachment_by_ids attachments: {:?}", attachments);
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params))
let request_body = serde_json::json!({
"attachments": attachments
});
let body = reqwest::Body::from(serde_json::to_string(&request_body).unwrap());
let response = HttpClient::post(&server_id, "/attachment/_search", None, Some(body))
.await
.map_err(|e| format!("Request error: {}", e))?;

View File

@@ -8,7 +8,7 @@ import {
GetResponse,
UploadAttachmentPayload,
UploadAttachmentResponse,
GetAttachmentPayload,
GetAttachmentByIdsPayload,
GetAttachmentResponse,
DeleteAttachmentPayload,
TranscriptionPayload,
@@ -255,17 +255,20 @@ export function cancel_session_chat({
export function chat_create({
serverId,
message,
attachments,
queryParams,
clientId,
}: {
serverId: string;
message: string;
attachments: string[];
queryParams?: Record<string, any>;
clientId: string;
}): Promise<GetResponse> {
return invokeWithErrorHandler(`chat_create`, {
serverId,
message,
attachments,
queryParams,
clientId,
});
@@ -275,12 +278,14 @@ export function chat_chat({
serverId,
sessionId,
message,
attachments,
queryParams,
clientId,
}: {
serverId: string;
sessionId: string;
message: string;
attachments: string[];
queryParams?: Record<string, any>;
clientId: string;
}): Promise<string> {
@@ -288,6 +293,7 @@ export function chat_chat({
serverId,
sessionId,
message,
attachments,
queryParams,
clientId,
});
@@ -342,10 +348,13 @@ export const upload_attachment = async (payload: UploadAttachmentPayload) => {
}
};
export const get_attachment = (payload: GetAttachmentPayload) => {
return invokeWithErrorHandler<GetAttachmentResponse>("get_attachment", {
...payload,
});
export const get_attachment_by_ids = (payload: GetAttachmentByIdsPayload) => {
return invokeWithErrorHandler<GetAttachmentResponse>(
"get_attachment_by_ids",
{
...payload,
}
);
};
export const delete_attachment = (payload: DeleteAttachmentPayload) => {

View File

@@ -0,0 +1,182 @@
import { FC, useEffect, useMemo } from "react";
import { X } from "lucide-react";
import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next";
import { useChatStore, UploadAttachments } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import Tooltip2 from "../Common/Tooltip2";
import FileIcon from "../Common/Icons/FileIcon";
import { filesize } from "@/utils";
const AttachmentList = () => {
const { uploadAttachments, setUploadAttachments } = useChatStore();
const { currentService } = useConnectStore();
const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
useEffect(() => {
return () => {
setUploadAttachments([]);
};
}, []);
const uploadAttachment = async (data: UploadAttachments) => {
const { uploading, uploaded, uploadFailed, path } = data;
if (uploading || uploaded || uploadFailed) return;
const { uploadAttachments } = useChatStore.getState();
const matched = uploadAttachments.find((item) => item.id === data.id);
if (matched) {
matched.uploading = true;
setUploadAttachments(uploadAttachments);
}
try {
const attachmentIds: any = await platformAdapter.commands(
"upload_attachment",
{
serverId,
filePaths: [path],
}
);
if (!attachmentIds) {
throw new Error("Failed to get attachment id");
} else {
Object.assign(data, {
uploaded: true,
attachmentId: attachmentIds[0],
});
}
} catch (error) {
Object.assign(data, {
uploadFailed: true,
failedMessage: String(error),
});
} finally {
Object.assign(data, {
uploading: false,
});
setUploadAttachments(uploadAttachments);
}
};
useAsyncEffect(async () => {
if (uploadAttachments.length === 0) return;
for (const item of uploadAttachments) {
uploadAttachment(item);
}
}, [uploadAttachments]);
const deleteFile = async (id: string) => {
const { uploadAttachments } = useChatStore.getState();
const matched = uploadAttachments.find((item) => item.id === id);
if (!matched) return;
const { uploadFailed, attachmentId } = matched;
setUploadAttachments(uploadAttachments.filter((file) => file.id !== id));
if (uploadFailed) return;
platformAdapter.commands("delete_attachment", {
serverId,
id: attachmentId,
});
};
return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadAttachments.map((file) => {
return (
<AttachmentItem
key={file.id}
{...file}
deletable
onDelete={deleteFile}
/>
);
})}
</div>
);
};
interface AttachmentItemProps extends UploadAttachments {
deletable?: boolean;
onDelete?: (id: string) => void;
}
export const AttachmentItem: FC<AttachmentItemProps> = (props) => {
const {
id,
name,
path,
extname,
size,
uploaded,
attachmentId,
uploadFailed,
failedMessage,
deletable,
onDelete,
} = props;
const { t } = useTranslation();
return (
<div key={id} className="w-1/3 px-1">
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
{(uploadFailed || attachmentId) && deletable && (
<div
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
onClick={() => {
onDelete?.(id);
}}
>
<X className="size-[10px] text-white" />
</div>
)}
<FileIcon path={path} />
<div className="flex flex-col justify-between overflow-hidden">
<div className="truncate text-sm text-[#333333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs">
{uploadFailed && failedMessage ? (
<Tooltip2 content={failedMessage}>
<span className="text-red-500">Upload Failed</span>
</Tooltip2>
) : (
<div className="text-[#999]">
{uploaded ? (
<div className="flex gap-2">
{extname && <span>{extname}</span>}
<span>{filesize(size)}</span>
</div>
) : (
<span>{t("assistant.fileList.uploading")}</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default AttachmentList;

View File

@@ -43,8 +43,13 @@ interface ChatAIProps {
instanceId?: string;
}
export interface SendMessageParams {
message?: string;
attachments?: string[];
}
export interface ChatAIRef {
init: (value: string) => void;
init: (params: SendMessageParams) => void;
cancelChat: () => void;
clearChat: () => void;
}
@@ -188,7 +193,7 @@ const ChatAI = memo(
isDeepThinkActive,
isMCPActive,
changeInput,
showChatHistory,
showChatHistory
);
const { dealMsg } = useMessageHandler(
@@ -225,7 +230,7 @@ const ChatAI = memo(
}, [activeChat, chatClose]);
const init = useCallback(
async (value: string) => {
async (params: SendMessageParams) => {
try {
//console.log("init", curChatEnd, activeChat?._id);
if (!isCurrentLogin) {
@@ -237,9 +242,9 @@ const ChatAI = memo(
return;
}
if (!activeChat?._id) {
await createNewChat(value);
await createNewChat(params);
} else {
await handleSendMessage(value, activeChat);
await handleSendMessage(activeChat, params);
}
} catch (error) {
console.error("Failed to initialize chat:", error);
@@ -285,7 +290,10 @@ const ChatAI = memo(
if (updatedChats.length > 0) {
setActiveChat(updatedChats[0]);
} else {
init("");
init({
message: "",
attachments: [],
});
}
}
@@ -396,8 +404,8 @@ const ChatAI = memo(
loadingStep={loadingStep}
timedoutShow={timedoutShow}
Question={Question}
handleSendMessage={(value) =>
handleSendMessage(value, activeChat)
handleSendMessage={(message) =>
handleSendMessage(activeChat, { message })
}
getFileUrl={getFileUrl}
formatUrl={formatUrl}
@@ -410,7 +418,11 @@ const ChatAI = memo(
)}
{!activeChat?._id && !visibleStartPage && (
<PrevSuggestion sendMessage={init} />
<PrevSuggestion
sendMessage={(message) => {
init({ message });
}}
/>
)}
</div>
</>

View File

@@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage";
import { Greetings } from "./Greetings";
// import FileList from "@/components/Assistant/FileList";
import AttachmentList from "@/components/Assistant/AttachmentList";
import { useChatScroll } from "@/hooks/useChatScroll";
import { useChatStore } from "@/stores/chatStore";
import type { Chat, IChunkData } from "@/types/chat";
import { useConnectStore } from "@/stores/connectStore";
// import SessionFile from "./SessionFile";
import ScrollToBottom from "@/components/Common/ScrollToBottom";
import { useChatStore } from "@/stores/chatStore";
interface ChatContentProps {
activeChat?: Chat;
@@ -44,14 +45,12 @@ export const ChatContent = ({
handleSendMessage,
formatUrl,
}: ChatContentProps) => {
// const sessionId = useConnectStore((state) => state.currentSessionId);
const setCurrentSessionId = useConnectStore((state) => {
return state.setCurrentSessionId;
});
const { currentSessionId, setCurrentSessionId } = useConnectStore();
const { t } = useTranslation();
// const uploadFiles = useChatStore((state) => state.uploadFiles);
const { uploadAttachments } = useChatStore();
const messagesEndRef = useRef<HTMLDivElement>(null);
const { scrollToBottom } = useChatScroll(messagesEndRef);
@@ -168,13 +167,13 @@ export const ChatContent = ({
<div ref={messagesEndRef} />
</div>
{/* {uploadFiles.length > 0 && (
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
<FileList />
{uploadAttachments.length > 0 && (
<div key={currentSessionId} className="max-h-[120px] overflow-auto p-2">
<AttachmentList />
</div>
)} */}
)}
{/* {sessionId && <SessionFile sessionId={sessionId} />} */}
{/* {currentSessionId && <SessionFile sessionId={currentSessionId} />} */}
<ScrollToBottom scrollRef={scrollRef} isAtBottom={isAtBottom} />
</div>

View File

@@ -1,142 +0,0 @@
import { useEffect, useMemo } from "react";
import { filesize } from "filesize";
import { X } from "lucide-react";
import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next";
import { useChatStore, UploadFile } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore";
import platformAdapter from "@/utils/platformAdapter";
import Tooltip2 from "../Common/Tooltip2";
import FileIcon from "../Common/Icons/FileIcon";
const FileList = () => {
const { t } = useTranslation();
const { uploadFiles, setUploadFiles } = useChatStore();
const { currentService } = useConnectStore();
const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
useEffect(() => {
return () => {
setUploadFiles([]);
};
}, []);
useAsyncEffect(async () => {
if (uploadFiles.length === 0) return;
for await (const item of uploadFiles) {
const { uploaded, path } = item;
if (uploaded) continue;
try {
const attachmentIds: any = await platformAdapter.commands(
"upload_attachment",
{
serverId,
filePaths: [path],
}
);
if (!attachmentIds) {
throw new Error("Failed to get attachment id");
} else {
Object.assign(item, {
uploaded: true,
attachmentId: attachmentIds[0],
});
}
setUploadFiles(uploadFiles);
} catch (error) {
Object.assign(item, {
uploadFailed: true,
failedMessage: String(error),
});
}
}
}, [uploadFiles]);
const deleteFile = async (file: UploadFile) => {
const { id, uploadFailed, attachmentId } = file;
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
if (uploadFailed) return;
platformAdapter.commands("delete_attachment", {
serverId,
id: attachmentId,
});
};
return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadFiles.map((file) => {
const {
id,
name,
path,
extname,
size,
uploaded,
attachmentId,
uploadFailed,
failedMessage,
} = file;
return (
<div key={id} className="w-1/3 px-1">
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
{(uploadFailed || attachmentId) && (
<div
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
onClick={() => {
deleteFile(file);
}}
>
<X className="size-[10px] text-white" />
</div>
)}
<FileIcon path={path} />
<div className="flex flex-col justify-between overflow-hidden">
<div className="truncate text-[#333333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs">
{uploadFailed && failedMessage ? (
<Tooltip2 content={failedMessage}>
<span className="text-red-500">Upload Failed</span>
</Tooltip2>
) : (
<div className="text-[#999]">
{uploaded ? (
<div className="flex gap-2">
{extname && <span>{extname}</span>}
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
) : (
<span>{t("assistant.fileList.uploading")}</span>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
);
};
export default FileList;

View File

@@ -1,5 +1,4 @@
import clsx from "clsx";
import { filesize } from "filesize";
import { Files, Trash2, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -10,6 +9,7 @@ import { AttachmentHit } from "@/types/commands";
import { useAppStore } from "@/stores/appStore";
import platformAdapter from "@/utils/platformAdapter";
import FileIcon from "../Common/Icons/FileIcon";
import { filesize } from "@/utils";
interface SessionFileProps {
sessionId: string;
@@ -39,10 +39,13 @@ const SessionFile = (props: SessionFileProps) => {
if (isTauri) {
console.log("sessionId", sessionId);
const response: any = await platformAdapter.commands("get_attachment", {
serverId,
sessionId,
});
const response: any = await platformAdapter.commands(
"get_attachment_by_ids",
{
serverId,
sessionId,
}
);
setUploadedFiles(response?.hits?.hits ?? []);
} else {
@@ -145,9 +148,7 @@ const SessionFile = (props: SessionFileProps) => {
</div>
<div className="text-xs text-[#999]">
{icon && <span className="pr-2">{icon}</span>}
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
<span>{filesize(size)}</span>
</div>
</div>
</div>

View File

@@ -1,17 +1,28 @@
import { useState } from "react";
import { FC, useState } from "react";
import clsx from "clsx";
import { CopyButton } from "@/components/Common/CopyButton";
import { useAsyncEffect } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { useConnectStore } from "@/stores/connectStore";
import { AttachmentItem } from "../Assistant/AttachmentList";
import { useAppStore } from "@/stores/appStore";
interface UserMessageProps {
messageContent: string;
message: string;
attachments: string[];
}
export const UserMessage = ({ messageContent }: UserMessageProps) => {
export const UserMessage: FC<UserMessageProps> = (props) => {
const { message, attachments } = props;
const [showCopyButton, setShowCopyButton] = useState(false);
const { currentService } = useConnectStore();
const [attachmentData, setAttachmentData] = useState<any[]>([]);
const { addError } = useAppStore();
const handleDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
if (typeof window !== "undefined" && typeof document !== "undefined") {
const selection = window.getSelection();
const range = document.createRange();
@@ -21,31 +32,81 @@ export const UserMessage = ({ messageContent }: UserMessageProps) => {
selection.removeAllRanges();
selection.addRange(range);
} catch (error) {
console.error('Selection failed:', error);
console.error("Selection failed:", error);
}
}
}
};
useAsyncEffect(async () => {
try {
if (attachments.length === 0) return;
const result: any = await platformAdapter.commands(
"get_attachment_by_ids",
{
serverId: currentService.id,
attachments,
}
);
setAttachmentData(result?.hits?.hits);
} catch (error) {
addError(String(error));
}
}, [attachments]);
return (
<div
className="max-w-full flex gap-1 items-center justify-end"
onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)}
>
<div
className={clsx("size-6 transition", {
"opacity-0": !showCopyButton,
})}
>
<CopyButton textToCopy={messageContent} />
</div>
<div
className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
onDoubleClick={handleDoubleClick}
>
{messageContent}
</div>
</div>
<>
{message && (
<div
className="flex gap-1 items-center justify-end"
onMouseEnter={() => setShowCopyButton(true)}
onMouseLeave={() => setShowCopyButton(false)}
>
<div
className={clsx("size-6 transition", {
"opacity-0": !showCopyButton,
})}
>
<CopyButton textToCopy={message} />
</div>
<div
className="max-w-[85%] overflow-auto text-left px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer user-select-text whitespace-pre-wrap"
onDoubleClick={handleDoubleClick}
>
{message}
</div>
</div>
)}
{attachmentData && (
<div
className={clsx("flex justify-end flex-wrap gap-y-2 w-full", {
"mt-3": message,
})}
>
{attachmentData.map((item) => {
const { id, name, size, icon } = item._source;
return (
<AttachmentItem
{...item._source}
key={id}
uploading={false}
uploaded
id={id}
extname={icon}
attachmentId={id}
name={name}
path={name}
size={size}
deletable={false}
/>
);
})}
</div>
)}
</>
);
};

View File

@@ -89,6 +89,7 @@ export const ChatMessage = memo(function ChatMessage({
]);
const messageContent = message?._source?.message || "";
const attachments = message?._source?.attachments ?? [];
const details = message?._source?.details || [];
const question = message?._source?.question || "";
@@ -103,7 +104,7 @@ export const ChatMessage = memo(function ChatMessage({
const renderContent = () => {
if (!isAssistant) {
return <UserMessage messageContent={messageContent} />;
return <UserMessage message={messageContent} attachments={attachments} />;
}
return (

View File

@@ -18,7 +18,9 @@ const FileIcon: FC<FileIconProps> = (props) => {
.then(setIconName);
});
return <FontIcon name={iconName} className={twMerge("size-8", className)} />;
return (
<FontIcon name={iconName} className={twMerge("min-w-8 h-8", className)} />
);
};
export default FileIcon;

View File

@@ -2,13 +2,16 @@ import React from "react";
import { Send } from "lucide-react";
import StopIcon from "@/icons/Stop";
import clsx from "clsx";
import { SendMessageParams } from "../Assistant/Chat";
import { getUploadedAttachmentsId, isAttachmentsUploaded } from "@/utils";
interface ChatIconsProps {
lineCount: number;
isChatMode: boolean;
curChatEnd: boolean;
inputValue: string;
onSend: (value: string) => void;
onSend: (params: SendMessageParams) => void;
disabledChange: () => void;
}
@@ -26,11 +29,19 @@ const ChatIcons: React.FC<ChatIconsProps> = ({
if (curChatEnd) {
return (
<button
className={`ml-1 p-1 ${
inputValue ? "bg-[#0072FF]" : "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
} rounded-full transition-colors h-6`}
className={clsx(
"ml-1 p-1 rounded-full transition-colors h-6 bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]",
{
"!bg-[#0072FF]": inputValue || isAttachmentsUploaded(),
}
)}
type="submit"
onClick={() => onSend(inputValue.trim())}
onClick={() => {
onSend({
message: inputValue.trim(),
attachments: getUploadedAttachmentsId(),
});
}}
>
<Send className="w-4 h-4 text-white" />
</button>

View File

@@ -18,11 +18,17 @@ import { useAssistantManager } from "./AssistantManager";
import InputControls from "./InputControls";
import { useExtensionsStore } from "@/stores/extensionsStore";
import AudioRecording from "../AudioRecording";
import { isDefaultServer } from "@/utils";
import {
getUploadedAttachmentsId,
isAttachmentsUploaded,
isDefaultServer,
} from "@/utils";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import { SendMessageParams } from "../Assistant/Chat";
import { isEmpty } from "lodash-es";
interface ChatInputProps {
onSend: (message: string) => void;
onSend: (params: SendMessageParams) => void;
disabled: boolean;
disabledChange: () => void;
changeMode?: (isChatMode: boolean) => void;
@@ -84,18 +90,13 @@ export default function ChatInput({
}: ChatInputProps) {
const { t } = useTranslation();
const currentAssistant = useConnectStore((state) => state.currentAssistant);
const setBlurred = useAppStore((state) => state.setBlurred);
const isTauri = useAppStore((state) => state.isTauri);
const { currentAssistant } = useConnectStore();
const { sourceData, goAskAi } = useSearchStore();
const { modifierKey, returnToInput, setModifierKeyPressed } =
useShortcutsStore();
const language = useAppStore((state) => {
return state.language;
});
const { isTauri, language, setBlurred } = useAppStore();
useEffect(() => {
return () => {
@@ -108,6 +109,7 @@ export default function ChatInput({
const { curChatEnd } = useChatStore();
const { setSearchValue, visibleExtensionStore, selectedExtension } =
useSearchStore();
const { uploadAttachments } = useChatStore();
useTauriFocus({
onFocus() {
@@ -122,12 +124,19 @@ export default function ChatInput({
const handleSubmit = useCallback(() => {
const trimmedValue = inputValue.trim();
if (!isAttachmentsUploaded()) return;
console.log("handleSubmit", trimmedValue, disabled);
if (trimmedValue && !disabled) {
if ((trimmedValue || !isEmpty(uploadAttachments)) && !disabled) {
changeInput("");
onSend(trimmedValue);
onSend({
message: trimmedValue,
attachments: getUploadedAttachmentsId(),
});
}
}, [inputValue, disabled, onSend]);
}, [inputValue, disabled, onSend, uploadAttachments]);
useKeyboardHandlers();
@@ -138,7 +147,7 @@ export default function ChatInput({
changeInput(value);
setSearchValue(value);
if (!isChatMode) {
onSend(value);
onSend({ message: value });
}
},
[changeInput, isChatMode, onSend]

View File

@@ -16,8 +16,7 @@ import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils";
// import InputUpload from "./InputUpload";
// import AiSummaryIcon from "@/components/Common/Icons/AiSummaryIcon";
import InputUpload from "./InputUpload";
interface InputControlsProps {
isChatMode: boolean;
@@ -56,16 +55,16 @@ const InputControls = ({
isChatPage,
hasModules,
changeMode,
}: // checkScreenPermission,
// requestScreenPermission,
// getScreenMonitors,
// getScreenWindows,
// captureWindowScreenshot,
// captureMonitorScreenshot,
// openFileDialog,
// getFileMetadata,
// getFileIcon,
InputControlsProps) => {
checkScreenPermission,
requestScreenPermission,
getScreenMonitors,
getScreenWindows,
captureWindowScreenshot,
captureMonitorScreenshot,
openFileDialog,
getFileMetadata,
getFileIcon,
}: InputControlsProps) => {
const { t } = useTranslation();
const isTauri = useAppStore((state) => state.isTauri);
@@ -171,22 +170,24 @@ InputControlsProps) => {
>
{isChatMode ? (
<div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]">
{/* <InputUpload
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/> */}
{source?.upload?.enabled && (
<InputUpload
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/>
)}
{source?.type === "deep_think" && source?.config?.visible && (
<button
className={clsx(
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
"flex items-center justify-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
{
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
}
@@ -231,7 +232,8 @@ InputControlsProps) => {
getMCPByServer={getMCPByServer}
/>
{!(source?.datasource?.enabled && source?.datasource?.visible) &&
{!source?.upload?.enabled &&
!(source?.datasource?.enabled && source?.datasource?.visible) &&
(source?.type !== "deep_think" || !source?.config?.visible) &&
!(source?.mcp_servers?.enabled && source?.mcp_servers?.visible) && (
<div className="px-[9px]">

View File

@@ -1,4 +1,4 @@
import { FC, Fragment, MouseEvent } from "react";
import { FC, Fragment, MouseEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Plus } from "lucide-react";
import {
@@ -19,6 +19,8 @@ import { useAppStore } from "@/stores/appStore";
import Tooltip from "@/components/Common/Tooltip";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import clsx from "clsx";
import { useConnectStore } from "@/stores/connectStore";
import { filesize } from "@/utils";
interface State {
screenRecordingPermission?: boolean;
@@ -61,9 +63,26 @@ const InputUpload: FC<InputUploadProps> = (props) => {
getFileMetadata,
} = props;
const { t, i18n } = useTranslation();
const { uploadFiles, setUploadFiles } = useChatStore();
const { uploadAttachments, setUploadAttachments } = useChatStore();
const { withVisibility, addError } = useAppStore();
const { modifierKey, addFile, modifierKeyPressed } = useShortcutsStore();
const { currentAssistant } = useConnectStore();
const uploadMaxSizeRef = useRef(1024 * 1024);
const uploadMaxCountRef = useRef(6);
const setVisibleStartPage = useConnectStore((state) => {
return state.setVisibleStartPage;
});
useEffect(() => {
if (!currentAssistant?._source?.upload) return;
const { max_file_size_in_bytes, max_file_count } =
currentAssistant._source.upload;
uploadMaxSizeRef.current = max_file_size_in_bytes;
uploadMaxCountRef.current = max_file_count;
}, [currentAssistant]);
const state = useReactive<State>({
screenshotableMonitors: [],
@@ -83,19 +102,25 @@ const InputUpload: FC<InputUploadProps> = (props) => {
if (isNil(selectedFiles)) return;
setVisibleStartPage(false);
handleUploadFiles(selectedFiles);
};
const handleUploadFiles = async (paths: string | string[]) => {
const files: typeof uploadFiles = [];
const files: typeof uploadAttachments = [];
for await (const path of castArray(paths)) {
if (find(uploadFiles, { path })) continue;
if (find(uploadAttachments, { path })) continue;
const stat = await getFileMetadata(path);
if (stat.size / 1024 / 1024 > 100) {
addError(t("search.input.uploadFileHints.maxSize"));
if (stat.size > uploadMaxSizeRef.current) {
addError(
t("search.input.uploadFileHints.maxSize", {
replace: [filesize(uploadMaxSizeRef.current)],
})
);
continue;
}
@@ -107,7 +132,7 @@ const InputUpload: FC<InputUploadProps> = (props) => {
});
}
setUploadFiles([...uploadFiles, ...files]);
setUploadAttachments([...uploadAttachments, ...files]);
};
const menuItems = useCreation<MenuItem[]>(() => {
@@ -176,8 +201,15 @@ const InputUpload: FC<InputUploadProps> = (props) => {
return (
<Menu>
<MenuButton className="flex p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Tooltip content={t("search.input.uploadFileHints.tooltip")}>
<MenuButton className="flex items-center justify-center h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
<Tooltip
content={t("search.input.uploadFileHints.tooltip", {
replace: [
uploadMaxCountRef.current,
filesize(uploadMaxSizeRef.current),
],
})}
>
<Plus
className={clsx("size-3 scale-[1.3]", {
hidden: modifierKeyPressed,

View File

@@ -166,7 +166,7 @@ export default function MCPPopover({
return (
<div
className={clsx(
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
{
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
}

View File

@@ -172,7 +172,7 @@ export default function SearchPopover({
return (
<div
className={clsx(
"flex items-center gap-1 p-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
"flex justify-center items-center gap-1 h-[20px] px-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
{
"!bg-[rgba(0,114,255,0.3)]": isSearchActive,
}

View File

@@ -13,7 +13,10 @@ import { useMount } from "ahooks";
import Search from "@/components/Search/Search";
import InputBox from "@/components/Search/InputBox";
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
import ChatAI, {
ChatAIRef,
SendMessageParams,
} from "@/components/Assistant/Chat";
import { isLinux, isWin } from "@/utils/platform";
import { appReducer, initialAppState } from "@/reducers/appReducer";
import { useWindowEvents } from "@/hooks/useWindowEvents";
@@ -25,6 +28,7 @@ import { useThemeStore } from "@/stores/themeStore";
import { useConnectStore } from "@/stores/connectStore";
import { useAppearanceStore } from "@/stores/appearanceStore";
import type { StartPage } from "@/types/chat";
import { isAttachmentsUploaded } from "@/utils";
interface SearchChatProps {
isTauri?: boolean;
@@ -148,10 +152,12 @@ function SearchChat({
}, []);
const handleSendMessage = useCallback(
async (value: string) => {
dispatch({ type: "SET_INPUT", payload: value });
async (params: SendMessageParams) => {
if (!isAttachmentsUploaded()) return;
dispatch({ type: "SET_INPUT", payload: params?.message ?? "" });
if (isChatMode) {
chatAIRef.current?.init(value);
chatAIRef.current?.init(params);
}
},
[isChatMode]

View File

@@ -9,7 +9,7 @@ import {
INITIAL_MODE_SWITCH,
INITIAL_RETURN_TO_INPUT,
// INITIAL_VOICE_INPUT,
// INITIAL_ADD_FILE,
INITIAL_ADD_FILE,
INITIAL_DEEP_THINKING,
INITIAL_INTERNET_SEARCH,
INITIAL_INTERNET_SEARCH_SCOPE,
@@ -46,8 +46,8 @@ const Shortcuts = () => {
setReturnToInput,
// voiceInput,
// setVoiceInput,
// addFile,
// setAddFile,
addFile,
setAddFile,
deepThinking,
setDeepThinking,
internetSearch,
@@ -106,15 +106,13 @@ const Shortcuts = () => {
// value: voiceInput,
// setValue: setVoiceInput,
// },
// {
// title: "settings.advanced.shortcuts.addFile.title",
// description: "settings.advanced.shortcuts.addFile.description",
// value: addFile,
// setValue: setAddFile,
// reset: () => {
// handleChange(INITIAL_ADD_FILE, setAddFile);
// },
// },
{
title: "settings.advanced.shortcuts.addFile.title",
description: "settings.advanced.shortcuts.addFile.description",
initialValue: INITIAL_ADD_FILE,
value: addFile,
setValue: setAddFile,
},
{
title: "settings.advanced.shortcuts.deepThinking.title",
description: "settings.advanced.shortcuts.deepThinking.description",

View File

@@ -1,11 +1,11 @@
import { useContext, useMemo, useState } from "react";
import { filesize } from "filesize";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "ahooks";
import platformAdapter from "@/utils/platformAdapter";
import { ExtensionsContext } from "../../../index";
import { filesize } from "@/utils";
interface Metadata {
name: string;
@@ -58,7 +58,7 @@ const App = () => {
},
{
label: t("settings.extensions.application.details.size"),
value: filesize(size, { standard: "jedec", spacer: "" }),
value: filesize(size),
},
{
label: t("settings.extensions.application.details.created"),

View File

@@ -9,6 +9,9 @@ import { useSearchStore } from "@/stores/searchStore";
import { useAuthStore } from "@/stores/authStore";
import { unrequitable } from "@/utils";
import { streamPost } from "@/api/streamFetch";
import { SendMessageParams } from "@/components/Assistant/Chat";
import { isEmpty } from "lodash-es";
import { useChatStore } from "@/stores/chatStore";
export function useChatActions(
setActiveChat: (chat: Chat | undefined) => void,
@@ -40,6 +43,9 @@ export function useChatActions(
} = useConnectStore();
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
const MCPIds = useSearchStore((state) => state.MCPIds);
const setUploadAttachments = useChatStore((state) => {
return state.setUploadAttachments;
});
const [keyword, setKeyword] = useState("");
@@ -289,10 +295,12 @@ export function useChatActions(
);
const prepareChatSession = useCallback(
async (value: string, timestamp: number) => {
async (timestamp: number, value: string) => {
// 1. Cleaning and preparation
await clearAllChunkData();
setUploadAttachments([]);
// 2. Update the status again
await new Promise<void>((resolve) => {
changeInput && changeInput("");
@@ -310,12 +318,17 @@ export function useChatActions(
);
const createNewChat = useCallback(
async (value: string = "") => {
if (!value) return;
async (params?: SendMessageParams) => {
const { message, attachments } = params || {};
console.log("message", message);
console.log("attachments", attachments);
if (!message && isEmpty(attachments)) return;
const timestamp = Date.now();
await prepareChatSession(value, timestamp);
await prepareChatSession(timestamp, message ?? "");
const queryParams = {
search: isSearchActive,
@@ -328,19 +341,22 @@ export function useChatActions(
if (isTauri) {
if (!currentService?.id) return;
console.log("chat_create", clientId, timestamp);
await platformAdapter.commands("chat_create", {
serverId: currentService?.id,
message: value,
message,
attachments,
queryParams,
clientId: `chat-stream-${clientId}-${timestamp}`,
});
console.log("_create end", value);
console.log("_create end", message);
resetChatState();
} else {
await streamPost({
url: "/chat/_create",
body: { message: value },
body: { message },
queryParams,
onMessage: (line) => {
console.log("⏳", line);
@@ -365,12 +381,16 @@ export function useChatActions(
);
const sendMessage = useCallback(
async (content: string, newChat: Chat) => {
if (!newChat?._id || !content) return;
async (newChat: Chat, params?: SendMessageParams) => {
if (!newChat?._id || !params) return;
const { message, attachments } = params;
if (!message && isEmpty(attachments)) return;
const timestamp = Date.now();
await prepareChatSession(content, timestamp);
await prepareChatSession(timestamp, message ?? "");
const queryParams = {
search: isSearchActive,
@@ -388,15 +408,16 @@ export function useChatActions(
serverId: currentService?.id,
sessionId: newChat?._id,
queryParams,
message: content,
message,
attachments,
clientId: `chat-stream-${clientId}-${timestamp}`,
});
console.log("chat_chat end", content, clientId);
console.log("chat_chat end", message, clientId);
resetChatState();
} else {
await streamPost({
url: `/chat/${newChat?._id}/_chat`,
body: { message: content },
body: { message },
queryParams,
onMessage: (line) => {
console.log("line", line);
@@ -421,10 +442,14 @@ export function useChatActions(
);
const handleSendMessage = useCallback(
async (content: string, activeChat?: Chat) => {
if (!activeChat?._id || !content) return;
async (activeChat?: Chat, params?: SendMessageParams) => {
if (!activeChat?._id) return;
await chatHistory(activeChat, (chat) => sendMessage(content, chat));
const { message, attachments } = params ?? {};
if (!message && isEmpty(attachments)) return;
await chatHistory(activeChat, (chat) => sendMessage(chat, params));
},
[chatHistory, sendMessage]
);

View File

@@ -351,8 +351,8 @@
"allScope": "All Scope"
},
"uploadFileHints": {
"tooltip": "Support screenshots, upload files, up to 50, single file up to 100 MB.",
"maxSize": "The file size cannot exceed 100 MB."
"tooltip": "Support screenshots, upload files, up to {{0}}, single file up to {{1}}.",
"maxSize": "The file size cannot exceed {{0}}."
}
},
"main": {

View File

@@ -351,8 +351,8 @@
"allScope": "所有范围"
},
"uploadFileHints": {
"tooltip": "支持截图、上传文件,最多 50个,单个文件最大 100 MB。",
"maxSize": "文件大小不能超过 100 MB。"
"tooltip": "支持截图、上传文件,最多 {{0}} 个,单个文件最大 {{1}}。",
"maxSize": "文件大小不能超过 {{0}}。"
}
},
"main": {

View File

@@ -14,7 +14,10 @@ import {
import { open } from "@tauri-apps/plugin-dialog";
import { metadata, icon } from "tauri-plugin-fs-pro-api";
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
import ChatAI, {
ChatAIRef,
SendMessageParams,
} from "@/components/Assistant/Chat";
import type { Chat as typeChat } from "@/types/chat";
import { useConnectStore } from "@/stores/connectStore";
import InputBox from "@/components/Search/InputBox";
@@ -99,14 +102,14 @@ export default function StandaloneChat({}: StandaloneChatProps) {
if (remainingChats.length > 0) {
setActiveChat(remainingChats[0]);
} else {
chatAIRef.current?.init("");
chatAIRef.current?.init({ message: "" });
}
}
};
const handleSendMessage = async (content: string) => {
setInput(content);
chatAIRef.current?.init(content);
const handleSendMessage = async (params: SendMessageParams) => {
setInput(params?.message ?? "");
chatAIRef.current?.init(params);
};
const chatHistory = async (chat: typeChat) => {

View File

@@ -5,9 +5,10 @@ import {
} from "zustand/middleware";
import { Metadata } from "tauri-plugin-fs-pro-api";
export interface UploadFile extends Metadata {
export interface UploadAttachments extends Metadata {
id: string;
path: string;
uploading: boolean;
uploaded?: boolean;
attachmentId?: string;
uploadFailed?: boolean;
@@ -28,8 +29,8 @@ export type IChatStore = {
setConnected: (value: boolean) => void;
messages: string;
setMessages: (value: string | ((prev: string) => string)) => void;
uploadFiles: UploadFile[];
setUploadFiles: (value: UploadFile[]) => void;
uploadAttachments: UploadAttachments[];
setUploadAttachments: (value: UploadAttachments[]) => void;
synthesizeItem?: SynthesizeItem;
setSynthesizeItem: (synthesizeItem?: SynthesizeItem) => void;
};
@@ -48,9 +49,9 @@ export const useChatStore = create<IChatStore>()(
set((state) => ({
messages: typeof value === "function" ? value(state.messages) : value,
})),
uploadFiles: [],
setUploadFiles: (uploadFiles: UploadFile[]) => {
return set(() => ({ uploadFiles }));
uploadAttachments: [],
setUploadAttachments: (uploadAttachments: UploadAttachments[]) => {
return set(() => ({ uploadAttachments }));
},
setSynthesizeItem(synthesizeItem?: SynthesizeItem) {
return set(() => ({ synthesizeItem }));

View File

@@ -12,6 +12,7 @@ export interface ISource {
session_id?: string;
type?: string;
message?: any;
attachments?: string[];
title?: string;
question?: string;
details?: any[] | null;
@@ -74,4 +75,4 @@ export interface Assistant {
ids?: string[];
};
};
}
}

View File

@@ -119,7 +119,10 @@ export interface UploadAttachmentResponse {
attachments: string[];
}
export type GetAttachmentPayload = Omit<UploadAttachmentPayload, "filePaths">;
export interface GetAttachmentByIdsPayload {
serverId: string;
attachments: string[];
}
export interface AttachmentHit {
_index: string;

View File

@@ -1,11 +1,13 @@
import { useEffect, useState } from "react";
import { isArray, isNil, isObject, isString } from "lodash-es";
import { filesize as filesizeLib } from "filesize";
import platformAdapter from "./platformAdapter";
import { useAppStore } from "@/stores/appStore";
import { DEFAULT_COCO_SERVER_ID, HISTORY_PANEL_ID } from "@/constants";
import { useConnectStore } from "@/stores/connectStore";
import { useAuthStore } from "@/stores/authStore";
import { useChatStore } from "@/stores/chatStore";
// 1
export async function copyToClipboard(text: string) {
@@ -192,3 +194,23 @@ export const isDefaultServer = (checkAvailability = true) => {
return isTauri && isDefaultServer;
};
export const filesize = (value: number, spacer?: string) => {
return filesizeLib(value, { standard: "jedec", spacer });
};
export const isAttachmentsUploaded = () => {
const { uploadAttachments } = useChatStore.getState();
if (uploadAttachments.length === 0) return false;
return uploadAttachments.every((item) => !item.uploading);
};
export const getUploadedAttachmentsId = () => {
const { uploadAttachments } = useChatStore.getState();
return uploadAttachments
.map((item) => item.attachmentId)
.filter((id) => !isNil(id));
};