feat: add new UI about no-data page (#80)

* feat: new UI redesign

* feat: add new ui no-data page
This commit is contained in:
BiggerRain
2025-01-02 14:41:54 +08:00
committed by GitHub
parent ce921944d9
commit 9215ae060e
19 changed files with 972 additions and 54 deletions

View File

@@ -12,6 +12,7 @@
"INFINI",
"inputbox",
"katex",
"khtml",
"localstorage",
"lucide",
"maximizable",
@@ -38,5 +39,8 @@
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer",
"editor.formatOnSave": true
}
},
"i18n-ally.localesPaths": [
"src/locales"
]
}

106
src-tauri/Cargo.lock generated
View File

@@ -358,9 +358,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "6.0.0"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b"
checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@@ -546,6 +546,7 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-nspanel",
"tauri-plugin-autostart",
"tauri-plugin-global-shortcut",
"tauri-plugin-http",
@@ -1143,15 +1144,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "fluent-uri"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -2002,9 +1994,9 @@ dependencies = [
[[package]]
name = "json-patch"
version = "2.0.0"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b1fb8864823fad91877e6caea0baca82e49e8db50f8e5c9f9a453e27d3330fc"
checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08"
dependencies = [
"jsonptr",
"serde",
@@ -2014,11 +2006,10 @@ dependencies = [
[[package]]
name = "jsonptr"
version = "0.4.7"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c6e529149475ca0b2820835d3dce8fcc41c6b943ca608d32f35b449255e4627"
checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70"
dependencies = [
"fluent-uri",
"serde",
"serde_json",
]
@@ -2337,6 +2328,17 @@ dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -2555,6 +2557,15 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc_id"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
dependencies = [
"objc",
]
[[package]]
name = "object"
version = "0.36.5"
@@ -3844,9 +3855,9 @@ dependencies = [
[[package]]
name = "tao"
version = "0.30.3"
version = "0.30.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0dbbebe82d02044dfa481adca1550d6dd7bd16e086bc34fa0fbecceb5a63751"
checksum = "6682a07cf5bab0b8a2bd20d0a542917ab928b5edb75ebd4eda6b05cbaab872da"
dependencies = [
"bitflags 2.6.0",
"cocoa",
@@ -3900,9 +3911,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tauri"
version = "2.0.5"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ce2818e803ce3097987296623ed8c0d9f65ed93b4137ff9a83e168bdbf62932"
checksum = "e545de0a2dfe296fa67db208266cd397c5a55ae782da77973ef4c4fac90e9f2c"
dependencies = [
"anyhow",
"bytes",
@@ -3938,7 +3949,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
"thiserror 1.0.64",
"thiserror 2.0.6",
"tokio",
"tray-icon",
"url",
@@ -3951,9 +3962,9 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "2.0.1"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "935f9b3c49b22b3e2e485a57f46d61cd1ae07b1cbb2ba87387a387caf2d8c4e7"
checksum = "7bd2a4bcfaf5fb9f4be72520eefcb61ae565038f8ccba2a497d8c28f463b8c01"
dependencies = [
"anyhow",
"cargo_toml",
@@ -3973,9 +3984,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "2.0.1"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95d7443dd4f0b597704b6a14b964ee2ed16e99928d8e6292ae9825f09fbcd30e"
checksum = "bf79faeecf301d3e969b1fae977039edb77a4c1f25cc0a961be298b54bff97cf"
dependencies = [
"base64 0.22.1",
"brotli",
@@ -3991,7 +4002,7 @@ dependencies = [
"sha2",
"syn 2.0.90",
"tauri-utils",
"thiserror 1.0.64",
"thiserror 2.0.6",
"time",
"url",
"uuid",
@@ -4000,9 +4011,9 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "2.0.1"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d2c0963ccfc3f5194415f2cce7acc975942a8797fbabfb0aa1ed6f59326ae7f"
checksum = "c52027c8c5afb83166dacddc092ee8fff50772f9646d461d8c33ee887e447a03"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -4012,6 +4023,22 @@ dependencies = [
"tauri-utils",
]
[[package]]
name = "tauri-nspanel"
version = "2.0.0"
source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#23b30f0f1974c35673db3234f1f1bd214fa9c4e9"
dependencies = [
"bitflags 2.6.0",
"block",
"cocoa",
"core-foundation 0.10.0",
"core-graphics",
"objc",
"objc-foundation",
"objc_id",
"tauri",
]
[[package]]
name = "tauri-plugin"
version = "2.0.1"
@@ -4164,9 +4191,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8f437293d6f5e5dce829250f4dbdce4e0b52905e297a6689cc2963eb53ac728"
checksum = "cce18d43f80d4aba3aa8a0c953bbe835f3d0f2370aca75e8dbb14bd4bab27958"
dependencies = [
"dpi",
"gtk",
@@ -4176,16 +4203,16 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
"thiserror 1.0.64",
"thiserror 2.0.6",
"url",
"windows",
]
[[package]]
name = "tauri-runtime-wry"
version = "2.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1431602bcc71f2f840ad623915c9842ecc32999b867c4a787d975a17a9625cc6"
checksum = "9f442a38863e10129ffe2cec7bd09c2dcf8a098a3a27801a476a304d5bb991d2"
dependencies = [
"gtk",
"http",
@@ -4209,9 +4236,9 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "2.0.1"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c38b0230d6880cf6dd07b6d7dd7789a0869f98ac12146e0d18d1c1049215a045"
checksum = "9271a88f99b4adea0dc71d0baca4505475a0bbd139fb135f62958721aaa8fe54"
dependencies = [
"brotli",
"cargo_metadata",
@@ -4219,6 +4246,7 @@ dependencies = [
"dunce",
"glob",
"html5ever",
"http",
"infer",
"json-patch",
"kuchikiki",
@@ -4235,7 +4263,7 @@ dependencies = [
"serde_json",
"serde_with",
"swift-rs",
"thiserror 1.0.64",
"thiserror 2.0.6",
"toml 0.8.2",
"url",
"urlpattern",
@@ -5356,12 +5384,13 @@ dependencies = [
[[package]]
name = "wry"
version = "0.46.3"
version = "0.47.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd5cdf57c66813d97601181349c63b96994b3074fc3d7a31a8cce96e968e3bbd"
checksum = "61ce51277d65170f6379d8cda935c80e3c2d1f0ff712a123c8bddb11b31a4b73"
dependencies = [
"base64 0.22.1",
"block2",
"cookie",
"crossbeam-channel",
"dpi",
"dunce",
@@ -5386,6 +5415,7 @@ dependencies = [
"soup3",
"tao-macros",
"thiserror 1.0.64",
"url",
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",

View File

@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.0.0", features = [] }
[dependencies]
tauri = { version = "2.0.0", features = ["macos-private-api", "tray-icon", "image-png", "unstable"] }
tauri = { version = "2.0.6", features = ["macos-private-api", "tray-icon", "image-png", "unstable"] }
tauri-plugin-shell = "2.0.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -27,6 +27,8 @@ tauri-plugin-http = "2"
tauri-plugin-websocket = "2"
tauri-plugin-theme = "2.1.2"
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
[profile.dev]
incremental = true # Compile your binary in smaller steps.

View File

@@ -1,6 +1,7 @@
use std::{fs::create_dir, io::Read};
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime, WebviewWindow};
use tauri_nspanel::{panel_delegate, ManagerExt, WebviewWindowExt};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
@@ -31,6 +32,29 @@ fn change_window_height(handle: AppHandle, height: u32) {
window.set_size(size).unwrap();
}
#[tauri::command]
fn show_panel(handle: AppHandle) {
let panel = handle.get_webview_panel("main").unwrap();
panel.show();
}
#[tauri::command]
fn hide_panel(handle: AppHandle) {
let panel = handle.get_webview_panel("main").unwrap();
panel.order_out(None);
}
#[tauri::command]
fn close_panel(handle: AppHandle) {
let panel = handle.get_webview_panel("main").unwrap();
panel.released_when_closed(true);
panel.close();
}
#[derive(serde::Deserialize)]
struct ThemeChangedPayload {
is_dark_mode: bool,
@@ -41,6 +65,7 @@ pub fn run() {
let mut ctx = tauri::generate_context!();
tauri::Builder::default()
.plugin(tauri_nspanel::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_autostart::init(
@@ -56,6 +81,9 @@ pub fn run() {
change_autostart,
hide_coco,
switch_tray_icon,
show_panel,
hide_panel,
close_panel
])
.setup(|app| {
init(app.app_handle());
@@ -80,7 +108,34 @@ pub fn run() {
.expect("error while running tauri application");
}
fn init(_app_handle: &AppHandle) {}
fn init(app_handle: &AppHandle) {
let window: WebviewWindow = app_handle.get_webview_window("main").unwrap();
let panel = window.to_panel().unwrap();
let delegate = panel_delegate!(MyPanelDelegate {
window_did_become_key,
window_did_resign_key
});
let handle = app_handle.to_owned();
delegate.set_listener(Box::new(move |delegate_name: String| {
match delegate_name.as_str() {
"window_did_become_key" => {
let app_name = handle.package_info().name.to_owned();
println!("[info]: {:?} panel becomes key window!", app_name);
}
"window_did_resign_key" => {
println!("[info]: panel resigned from key window!");
}
_ => (),
}
}));
panel.set_delegate(delegate);
}
fn enable_shortcut(app: &mut tauri::App) {
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};

View File

@@ -13,17 +13,20 @@
"macOSPrivateApi": true,
"windows": [
{
"decorations": false,
"height": 90,
"width": 680,
"minimizable": true,
"shadow": true,
"title": "Coco AI",
"transparent": true,
"url": "/ui",
"height": 590,
"width": 680,
"decorations": false,
"minimizable": false,
"maximizable": false,
"shadow": false,
"transparent": true,
"fullscreen": false,
"center": false,
"windowEffects": {
"effects": [],
"radius": 20
"radius": 12
}
}
],

BIN
src/assets/coconut-tree.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -0,0 +1,65 @@
import { useEffect, useRef, useImperativeHandle, forwardRef } from "react";
interface AutoResizeTextareaProps {
input: string;
setInput: (value: string) => void;
handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
}
// Forward ref to allow parent to interact with this component
const AutoResizeTextarea = forwardRef<
{ reset: () => void; focus: () => void },
AutoResizeTextareaProps
>(({ input, setInput, handleKeyDown }, ref) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
const prevHeight = textarea.style.height;
textarea.style.height = "auto"; // Reset height to recalculate
if (textarea.style.height !== prevHeight) {
textarea.style.height = `${textarea.scrollHeight}px`; // Adjust based on content
}
}
}, [input]);
// Expose methods to the parent via ref
useImperativeHandle(ref, () => ({
reset: () => {
setInput("");
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
}
},
focus: () => {
textareaRef.current?.focus();
},
}));
return (
<textarea
ref={textareaRef}
autoFocus
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="text-base flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
placeholder="Ask whatever you want ..."
aria-label="Ask whatever you want ..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => handleKeyDown?.(e)}
rows={1}
style={{
resize: "none", // Prevent manual resize
overflow: "auto", // Enable scrollbars when needed
maxHeight: "4.5rem", // Limit height to 3 rows (3 * 1.5 line-height)
lineHeight: "1.5rem", // Line height to match row height
}}
/>
);
});
export default AutoResizeTextarea;

View File

@@ -0,0 +1,161 @@
import { useEffect, useRef, useState } from "react";
import { isTauri } from "@tauri-apps/api/core";
interface DropdownListProps {
selected: (item: any) => void;
suggests: any[];
isSearchComplete: boolean;
}
function DropdownList({ selected, suggests }: DropdownListProps) {
const [selectedItem, setSelectedItem] = useState<number | null>(null);
const [showIndex, setShowIndex] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const handleOpenURL = async (url: string) => {
if (!url) return;
try {
if (isTauri()) {
const { open } = await import("@tauri-apps/plugin-shell");
await open(url);
console.log("URL opened in default browser");
}
} catch (error) {
console.error("Failed to open URL:", error);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
console.log(
"handleKeyDown",
e.key,
showIndex,
e.key >= "0" && e.key <= "9" && showIndex
);
if (!suggests.length) return;
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedItem((prev) =>
prev === null || prev === 0 ? suggests.length - 1 : prev - 1
);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedItem((prev) =>
prev === null || prev === suggests.length - 1 ? 0 : prev + 1
);
} else if (e.key === "Meta") {
e.preventDefault();
setShowIndex(true);
}
if (e.key === "Enter" && selectedItem !== null) {
console.log("Enter key pressed", selectedItem);
const item = suggests[selectedItem];
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
} else {
selected(item);
}
}
if (e.key >= "0" && e.key <= "9" && showIndex) {
console.log(`number ${e.key}`);
const item = suggests[parseInt(e.key, 10)];
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
} else {
selected(item);
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
console.log("handleKeyUp", e.key);
if (!suggests.length) return;
if (!e.metaKey) {
setShowIndex(false);
}
};
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [showIndex, selectedItem, suggests]);
useEffect(() => {
if (suggests.length > 0) {
setSelectedItem(0);
}
}, [JSON.stringify(suggests)]);
useEffect(() => {
if (selectedItem !== null && itemRefs.current[selectedItem]) {
itemRefs.current[selectedItem]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedItem]);
return (
<div
ref={containerRef}
data-tauri-drag-region
className="h-[458px] w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
tabIndex={0}
>
<div className="p-2 text-xs text-[#999] dark:text-[#666]">Results</div>
{suggests?.map((item, index) => {
const isSelected = selectedItem === index;
return (
<div
key={item._id}
ref={(el) => (itemRefs.current[index] = el)}
onMouseEnter={() => setSelectedItem(index)}
onClick={() => {
if (item?._source?.url) {
handleOpenURL(item?._source?.url);
} else {
selected(item);
}
}}
className={`w-full px-2 py-2.5 text-sm flex items-center justify-between rounded-lg transition-colors ${
isSelected
? "bg-[rgba(0,0,0,0.1)] dark:bg-[rgba(255,255,255,0.1)] hover:bg-[rgba(0,0,0,0.1)] dark:hover:bg-[rgba(255,255,255,0.1)]"
: ""
}`}
>
<div className="flex gap-2 items-center">
<img className="w-5 h-5" src={item?._source?.icon} alt="icon" />
<span className="text-[#333] dark:text-[#d8d8d8] truncate w-80 text-left">
{item?._source?.title}
</span>
</div>
<div className="flex gap-2 items-center relative">
<span className="text-sm text-[#666] dark:text-[#666] truncate w-52 text-right">
{item?._source?.source}
</span>
{showIndex && index < 10 ? (
<div
className={`absolute right-0 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] shadow-[-6px_0px_6px_2px_#e6e6e6] dark:shadow-[-6px_0px_6px_2px_#000] rounded-md`}
>
{index}
</div>
) : null}
</div>
</div>
);
})}
</div>
);
}
export default DropdownList;

View File

@@ -0,0 +1,40 @@
import {
Command,
ArrowDown01,
AppWindowMac,
CornerDownLeft,
} from "lucide-react";
interface FooterProps {
isChat: boolean;
name?: string;
}
export default function Footer({ name }: FooterProps) {
return (
<div
data-tauri-drag-region
className="px-4 z-999 mx-[1px] h-10 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-xl rounded-t-none overflow-hidden"
>
<div className="flex items-center">
{name ? (
<div className="flex gap-2 items-center text-[#666] text-xs">
<AppWindowMac className="w-5 h-5" /> {name}
</div>
) : null}
</div>
<div className="flex items-center gap-3">
<div className="gap-1 flex items-center text-[#666] dark:text-[#666] text-sm">
<span className="mr-1.5 ">Quick open</span>
<Command className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
<ArrowDown01 className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
</div>
<div className="flex items-center text-[#666] dark:text-[#666] text-sm">
<span className="mr-1.5 ">Open</span>
<CornerDownLeft className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,311 @@
import { Library, Mic, Send, Plus, AudioLines, Image } from "lucide-react";
import { useRef, useState, useEffect, useCallback } from "react";
import { listen } from "@tauri-apps/api/event";
import { isTauri } from "@tauri-apps/api/core";
import ChatSwitch from "../SearchChat/ChatSwitch";
import AutoResizeTextarea from "./AutoResizeTextarea";
import { useChatStore } from "@/stores/chatStore";
import StopIcon from "@/icons/Stop";
import { useAppStore } from "@/stores/appStore";
interface ChatInputProps {
onSend: (message: string) => void;
disabled: boolean;
disabledChange: () => void;
changeMode: (isChatMode: boolean) => void;
isChatMode: boolean;
inputValue: string;
changeInput: (val: string) => void;
}
export default function ChatInput({
onSend,
disabled,
changeMode,
isChatMode,
inputValue,
changeInput,
disabledChange,
}: ChatInputProps) {
const showTooltip = useAppStore((state) => state.showTooltip);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
const { curChatEnd } = useChatStore();
const [isCommandPressed, setIsCommandPressed] = useState(false);
const handleToggleFocus = useCallback(() => {
if (isChatMode) {
textareaRef.current?.focus();
} else {
inputRef.current?.focus();
}
}, [isChatMode, textareaRef, inputRef]);
const handleSubmit = useCallback(() => {
const trimmedValue = inputValue.trim();
if (trimmedValue && !disabled) {
onSend(trimmedValue);
}
}, [inputValue, disabled, onSend]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.code === "MetaLeft" || e.code === "MetaRight") {
setIsCommandPressed(true);
}
if (e.metaKey) {
switch (e.code) {
case "KeyI":
handleToggleFocus();
break;
case "KeyM":
console.log("KeyM");
break;
case "Enter":
isChatMode && handleSubmit();
break;
case "KeyO":
console.log("KeyO");
break;
case "KeyU":
console.log("KeyU");
break;
case "KeyN":
console.log("KeyN");
break;
case "KeyG":
console.log("KeyG");
break;
default:
break;
}
}
},
[handleToggleFocus, isChatMode, handleSubmit]
);
const handleKeyUp = useCallback((e: KeyboardEvent) => {
if (e.code === "MetaLeft" || e.code === "MetaRight") {
setIsCommandPressed(false);
}
}, []);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [handleKeyDown, handleKeyUp]);
useEffect(() => {
if (!isTauri()) return;
const setupListener = async () => {
const unlisten = await listen("tauri://focus", () => {
console.log("Window focused!");
if (isChatMode) {
textareaRef.current?.focus();
} else {
inputRef.current?.focus();
}
});
return unlisten;
};
let unlisten: (() => void) | undefined;
setupListener().then((unlistener) => {
unlisten = unlistener;
});
return () => {
unlisten?.();
};
}, [isChatMode]);
const openChatAI = async () => {
console.log("Chat AI opened.");
};
return (
<div className="w-full relative">
<div className="p-[12px] flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded transition-all relative">
<div className="flex flex-wrap gap-2 flex-1 items-center relative">
{isChatMode ? (
<AutoResizeTextarea
ref={textareaRef}
input={inputValue}
setInput={changeInput}
handleKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSubmit();
}
}}
/>
) : (
<input
ref={inputRef}
type="text"
autoFocus
autoComplete="off"
autoCapitalize="none"
spellCheck="false"
className="text-base font-normal flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
placeholder="Search whatever you want ..."
value={inputValue}
onChange={(e) => {
onSend(e.target.value);
}}
/>
)}
{showTooltip && isCommandPressed ? (
<div
className={`absolute bg-black bg-opacity-70 text-white font-bold px-2 py-0.5 rounded-md text-xs transition-opacity duration-200`}
>
+ I
</div>
) : null}
</div>
{isChatMode ? (
<button
className="p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition-colors"
type="button"
>
<Mic className="w-4 h-4 text-[#999] dark:text-[#999]" />
</button>
) : null}
{isChatMode && curChatEnd ? (
<button
className={`ml-1 p-1 ${
inputValue
? "bg-[#0072FF]"
: "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
} rounded-full transition-colors`}
type="submit"
onClick={() => onSend(inputValue.trim())}
>
<Send className="w-4 h-4 text-white" />
</button>
) : null}
{isChatMode && !curChatEnd ? (
<button
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
type="submit"
onClick={() => disabledChange()}
>
<StopIcon
size={16}
className="w-4 h-4 text-white"
aria-label="Stop message"
/>
</button>
) : null}
{showTooltip && isChatMode && isCommandPressed ? (
<div
className={`absolute right-16 bg-black bg-opacity-70 text-white font-bold px-2 py-0.5 rounded-md text-xs transition-opacity duration-200`}
>
+ M
</div>
) : null}
{showTooltip && isChatMode && isCommandPressed ? (
<div
className={`absolute right-1 bg-black bg-opacity-70 text-white font-bold px-2 py-0.5 rounded-md text-xs transition-opacity duration-200`}
>
+
</div>
) : null}
</div>
<div
data-tauri-drag-region
className="flex justify-between items-center p-2"
>
{isChatMode ? (
<div className="flex gap-2 text-xs text-[#333] dark:text-[#d8d8d8]">
<button
className="inline-flex items-center rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors relative"
onClick={openChatAI}
>
<Library className="w-4 h-4 mr-1 text-[#000] dark:text-[#d8d8d8]" />
Coco
</button>
<button className="inline-flex items-center rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-color relative">
<Plus className="w-4 h-4 mr-1 text-[#000] dark:text-[#d8d8d8]" />
Upload
</button>
{showTooltip && isCommandPressed ? (
<div
className={`absolute left-2 bg-black bg-opacity-70 text-white font-bold px-2 py-0.5 rounded-md text-xs transition-opacity duration-200`}
>
+ O
</div>
) : null}
{showTooltip && isCommandPressed ? (
<div
className={`absolute left-16 bg-black bg-opacity-70 text-white font-bold px-2 py-0.5 rounded-md text-xs transition-opacity duration-200`}
>
+ U
</div>
) : null}
</div>
) : (
<div className="w-28 flex gap-2 relative">
<button
className="inline-flex items-center rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors relative"
onClick={openChatAI}
>
<AudioLines className="w-4 h-4 text-[#000] dark:text-[#d8d8d8]" />
</button>
<button className="inline-flex items-center rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-color relative">
<Image className="w-4 h-4 text-[#000] dark:text-[#d8d8d8]" />
</button>
{showTooltip && isCommandPressed ? (
<div
className={`absolute left-0 bg-black bg-opacity-70 text-white font-bold px-2 py-0.5 rounded-md text-xs transition-opacity duration-200`}
>
+ N
</div>
) : null}
{showTooltip && isCommandPressed ? (
<div
className={`absolute left-14 bg-black bg-opacity-70 text-white font-bold px-2 py-0.5 rounded-md text-xs transition-opacity duration-200`}
>
+ G
</div>
) : null}
</div>
)}
<div className="relative w-24 flex justify-end items-center">
{showTooltip && isCommandPressed ? (
<div
className={`absolute left-0 z-10 bg-black bg-opacity-70 text-white font-bold px-2 py-0.5 rounded-md text-xs transition-opacity duration-200`}
>
+ T
</div>
) : null}
<ChatSwitch
isChatMode={isChatMode}
onChange={(value) => {
value && disabledChange();
changeMode(value);
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { Command } from "lucide-react";
// import { isTauri } from "@tauri-apps/api/core";
import DropdownList from "./DropdownList";
import Footer from "./Footer";
import { tauriFetch } from "@/api/tauriFetchClient";
import noDataImg from "@/assets/coconut-tree.png";
interface SearchProps {
changeInput: (val: string) => void;
isChatMode: boolean;
input: string;
}
function Search({ isChatMode, input }: SearchProps) {
const [suggests, setSuggests] = useState<any[]>([]);
const [isSearchComplete, setIsSearchComplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<any>();
const mainWindowRef = useRef<HTMLDivElement>(null);
// useEffect(() => {
// if (!isTauri()) return;
// const element = mainWindowRef.current;
// if (!element) return;
// const resizeObserver = new ResizeObserver(async (entries) => {
// const { getCurrentWebviewWindow } = await import(
// "@tauri-apps/api/webviewWindow"
// );
// const { LogicalSize } = await import("@tauri-apps/api/dpi");
// for (let entry of entries) {
// let newHeight = entry.contentRect.height;
// console.log("Height updated:", newHeight);
// newHeight = newHeight + 90 + (newHeight === 0 ? 0 : 46);
// await getCurrentWebviewWindow()?.setSize(
// new LogicalSize(680, newHeight)
// );
// }
// });
// resizeObserver.observe(element);
// return () => {
// resizeObserver.disconnect();
// };
// }, [suggests]);
const getSuggest = async () => {
if (!input) return;
//
// const list = [];
// for (let i = 0; i < input.length; i++) {
// list.push({
// _source: { url: `https://www.google.com/search?q=${i}`, _id: i },
// });
// }
// setSuggests(list);
// return;
//
try {
const response = await tauriFetch({
url: `/query/_search?query=${input}`,
method: "GET",
});
console.log("_suggest", input, response);
const data = response.data?.hits?.hits || [];
setSuggests(data);
setIsSearchComplete(true);
} catch (error) {
console.error("Failed to fetch user data:", error);
}
};
function debounce(fn: Function, delay: number) {
let timer: NodeJS.Timeout;
return (...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const debouncedSearch = useCallback(debounce(getSuggest, 300), [input]);
useEffect(() => {
!isChatMode && debouncedSearch();
if (!input) setSuggests([]);
}, [input]);
return (
<div ref={mainWindowRef} className={`h-[500px] pb-10 w-full relative`}>
{/* Search Results Panel */}
{suggests.length > 0 ? (
<DropdownList
suggests={suggests}
isSearchComplete={isSearchComplete}
selected={(item) => setSelectedItem(item)}
/>
) : (
<div
data-tauri-drag-region
className="h-full w-full flex flex-col items-center"
>
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
No Results
</div>
<div className="mt-10 text-sm text-[#333] dark:text-[#D8D8D8] flex">
Ask Coco AI
<span className="ml-3 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
<Command className="w-3 h-3" />
</span>
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
T
</span>
</div>
</div>
)}
<Footer isChat={false} name={selectedItem?.source} />
</div>
);
}
export default Search;

View File

@@ -205,7 +205,7 @@ const ChatAI = forwardRef<ChatAIRef, ChatAIProps>(
async function openChatAI() {
if (isTauri()) {
createWin({
createWin && createWin({
label: "chat",
title: "Coco AI",
dragDropEnabled: true,

View File

@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { isTauri, invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
const useEscape = () => {
@@ -13,6 +13,7 @@ const useEscape = () => {
};
useEffect(() => {
if(!isTauri()) return;
const unlisten = listen("tauri://focus", () => {
// Add event listener for keydown
window.addEventListener("keydown", handleEscape);

View File

@@ -2,6 +2,7 @@ import { useEffect, useCallback } from "react";
import { getAllWindows, getCurrentWindow } from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { listen } from "@tauri-apps/api/event";
import { isTauri } from "@tauri-apps/api/core";
const defaultWindowConfig = {
label: "",
@@ -20,6 +21,7 @@ const defaultWindowConfig = {
};
export const useWindows = () => {
if (!isTauri()) return {}
const appWindow = getCurrentWindow();
const createWin = useCallback(async (options: any) => {

View File

@@ -23,7 +23,7 @@
body,
#root {
@apply text-gray-900 rounded-xl antialiased;
@apply text-gray-900 rounded-xl overflow-hidden antialiased;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;

105
src/pages/app/index.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { useState, useRef } from "react";
import { isTauri } from "@tauri-apps/api/core";
import InputBox from "@/components/AppAI/InputBox";
import Search from "@/components/AppAI/Search";
import ChatAI, { ChatAIRef } from "@/components/ChatAI/Chat";
export default function DesktopApp() {
const chatAIRef = useRef<ChatAIRef>(null);
const [isChatMode, setIsChatMode] = useState(false);
const [input, setInput] = useState("");
const [isTransitioned, setIsTransitioned] = useState(false);
async function changeMode(value: boolean) {
setIsChatMode(value);
setIsTransitioned(value);
}
async function changeInput(value: string) {
setInput(value);
}
const handleSendMessage = async (value: string) => {
setInput(value);
if (isChatMode) {
if (isTauri()) {
const { getCurrentWebviewWindow } = await import(
"@tauri-apps/api/webviewWindow"
);
const { LogicalSize } = await import("@tauri-apps/api/dpi");
await getCurrentWebviewWindow()?.setSize(new LogicalSize(680, 596));
}
chatAIRef.current?.init();
}
};
const cancelChat = () => {
chatAIRef.current?.cancelChat();
};
const isTyping = false;
return (
<div
data-tauri-drag-region
className={`shadow-window-custom w-[680px] h-[590px] m-auto rounded-xl overflow-hidden relative border border-[#E6E6E6] dark:border-[#272626] ${
isTransitioned
? "bg-chat_bg_light dark:bg-chat_bg_dark"
: "bg-search_bg_light dark:bg-search_bg_dark"
} bg-no-repeat bg-cover bg-center`}
>
<div
data-tauri-drag-region
className={`p-[7px] pb-0 absolute w-full flex items-center justify-center transition-all duration-500 ${
isTransitioned
? "top-[500px] h-[90px] border-t"
: "top-0 h-[90px] border-b"
} border-[#E6E6E6] dark:border-[#272626] `}
>
<InputBox
isChatMode={isChatMode}
inputValue={input}
onSend={handleSendMessage}
disabled={isTyping}
disabledChange={() => {
cancelChat();
}}
changeMode={changeMode}
changeInput={changeInput}
/>
</div>
<div
data-tauri-drag-region
className={`absolute w-full transition-opacity duration-500 ${
isTransitioned ? "opacity-0 pointer-events-none" : "opacity-100"
} bottom-0 h-[500px]`}
>
<Search
key="Search"
input={input}
isChatMode={isChatMode}
changeInput={changeInput}
/>
</div>
<div
data-tauri-drag-region
className={`absolute w-full transition-all duration-500 ${
isTransitioned
? "top-0 opacity-100 pointer-events-auto"
: "-top-[506px] opacity-0 pointer-events-none"
} h-[500px]`}
>
<ChatAI
ref={chatAIRef}
key="ChatAI"
inputValue={input}
isTransitioned={isTransitioned}
changeInput={changeInput}
/>
</div>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import ChatAI from "../components/ChatAI";
import MySearch from "../components/MySearch";
import Layout from "./Layout";
import WebApp from "../pages/web";
import DesktopApp from "../pages/app";
export const router = createBrowserRouter([
{
@@ -17,7 +18,8 @@ export const router = createBrowserRouter([
element: <Layout />,
errorElement: <ErrorPage />,
children: [
{ path: "/ui", element: <SearchChat /> },
{ path: "/ui", element: <DesktopApp /> },
{ path: "/ui/old", element: <SearchChat /> },
{ path: "/ui/settings", element: <Settings2 /> },
{ path: "/ui/chat", element: <ChatAI /> },
{ path: "/ui/search", element: <MySearch /> },

View File

@@ -14,7 +14,11 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@@ -1,12 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from 'path';
const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors