6 Commits

Author SHA1 Message Date
ayang
5df1f9668d refactor: update 2025-12-18 18:18:11 +08:00
ayang
23607e6b4c refactor: update 2025-12-18 16:55:47 +08:00
ayang
494be3db62 refactor: update 2025-12-18 16:51:49 +08:00
Steve Lau
530502ecff debugging: print query strings 2025-12-18 16:00:31 +08:00
ayang
8d6204a9d8 refactor: update 2025-12-18 15:59:37 +08:00
ayang
ca350dfeed feat: add search filter 2025-12-18 15:54:59 +08:00
8 changed files with 1207 additions and 102 deletions

View File

@@ -48,6 +48,7 @@
"@tauri-store/zustand": "^1.1.0",
"@wavesurfer/react": "^1.0.11",
"ahooks": "^3.8.4",
"antd": "^6.1.1",
"axios": "^1.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -57,7 +58,7 @@
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.1.0",
"lodash-es": "^4.17.21",
"lucide-react": "^0.461.0",
"lucide-react": "^0.561.0",
"mdast-util-gfm-autolink-literal": "2.0.0",
"mermaid": "^11.6.0",
"nanoid": "^5.1.5",

851
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,8 @@ pub async fn query_coco_fusion(
query_strings: HashMap<String, String>,
query_timeout: u64,
) -> Result<MultiSourceQueryResponse, SearchError> {
println!("DBG: querystrings {:?}", query_strings);
if query_strings.contains_key("datasource") && !query_strings.contains_key("querysource") {
panic!("[querysource] has to be provided if [datasource] is set")
}

View File

@@ -332,7 +332,7 @@ const ExtensionStore = ({
}}
>
<div className="flex items-center gap-2 overflow-hidden">
<img src={icon} className="size-[20px]" />
<img src={icon} className="size-5" />
<span className="whitespace-nowrap">{name}</span>
<span className="truncate text-[#999]">{description}</span>
</div>

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { Brain, Sparkles } from "lucide-react";
import { Brain, RotateCcw, ScanSearch, Sparkles } from "lucide-react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
@@ -12,11 +12,13 @@ import { useConnectStore } from "@/stores/connectStore";
import VisibleKey from "@/components/Common/VisibleKey";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { DEFAULT_FUZZINESS, useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils";
import InputUpload from "./InputUpload";
import Copyright from "../Common/Copyright";
import TimeFilter from "./TimeFilter";
import { Slider } from "../ui/slider";
interface InputControlsProps {
isChatMode: boolean;
@@ -161,7 +163,13 @@ const InputControls = ({
return state.aiOverviewAssistant;
});
const aiOverviewShortcut = useShortcutsStore((state) => state.aiOverview);
const { visibleExtensionStore } = useSearchStore();
const {
visibleExtensionStore,
enabledFuzzyMatch,
setEnabledFuzzyMatch,
fuzziness,
setFuzziness,
} = useSearchStore();
return (
<div
@@ -242,7 +250,10 @@ const InputControls = ({
)}
</div>
) : (
<div data-tauri-drag-region className="w-28 flex gap-2 relative">
<div
data-tauri-drag-region
className="w-28 flex gap-2 items-center relative"
>
{!disabledExtensions.includes("AIOverview") &&
isTauri &&
aiOverviewServer &&
@@ -274,12 +285,64 @@ const InputControls = ({
</VisibleKey>
<span
className={clsx("text-xs", { hidden: !enabledAiOverview })}
className={clsx("text-xs truncate", {
hidden: !enabledAiOverview,
})}
>
AI Overview
</span>
</div>
)}
<div
className={clsx(
"inline-flex items-center gap-1 h-5 px-1 rounded-full hover:text-[#881c94]! cursor-pointer transition",
[
enabledFuzzyMatch
? "text-[#881c94]"
: "text-[#333] dark:text-[#d8d8d8]",
],
{
"bg-[#881C94]/20 dark:bg-[#202126]": enabledFuzzyMatch,
}
)}
onClick={() => {
setEnabledFuzzyMatch(!enabledFuzzyMatch);
}}
>
<ScanSearch className="size-3" />
{enabledFuzzyMatch && (
<>
<span className={clsx("text-xs truncate")}>Fuzzy Match</span>
<Slider
value={[fuzziness]}
max={5}
className="w-20"
onValueChange={(value) => {
setFuzziness(value[0]);
}}
onClick={(event) => {
event.stopPropagation();
}}
/>
<RotateCcw
className="size-3"
onClick={(event) => {
event.stopPropagation();
setFuzziness(DEFAULT_FUZZINESS);
}}
/>
</>
)}
</div>
<div className="inline-flex items-center gap-1 h-5 px-1 rounded-full hover:text-[#881c94]! cursor-pointer transition">
<TimeFilter />
</div>
</div>
)}

View File

@@ -0,0 +1,180 @@
import { useRef, useState } from "react";
import { ListFilter, ChevronRight, BrushCleaning } from "lucide-react";
import { DatePicker } from "antd";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from "../ui/popover";
import { useSearchStore } from "@/stores/searchStore";
import MultiSelect from "../ui/multi-select";
const TimeFilter = () => {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const { aggregateFilter, setAggregateFilter } = useSearchStore();
const dropdownMenuItems = [
{
key: "all-time",
label: "All Time",
value: null,
},
{
key: "7-day",
label: "7 Day",
value: 7,
},
{
key: "90-day",
label: "90 Day",
value: 90,
},
{
key: "1-year",
label: "1 Year",
value: 365,
},
{
key: "more",
label: "More",
onClick: () => {
setPopoverOpen(true);
},
},
];
const typeOptions = [
{
label: "Web Page",
value: "web_page",
},
{
label: "PDF",
value: "pdf",
},
{
label: "Images",
value: "images",
},
];
const sourceOptions = [
{
label: "INFINI Gateway",
value: "INFINI Gateway",
},
{
label: "INFINI Labs Blog",
value: "INFINI Labs Blog",
},
{
label: "Coco 官网",
value: "Coco 官网",
},
{
label: "INFINI Labs 中文官网",
value: "INFINI Labs 中文官网",
},
{
label: "INFINI Labs blog",
value: "INFINI Labs blog",
},
];
console.log("triggerRef", triggerRef.current);
return (
<div>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<div ref={triggerRef} className="relative">
<ListFilter className="size-3" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{dropdownMenuItems.map((item) => {
const { key, label, onClick } = item;
return (
<DropdownMenuItem
key={key}
className="flex justify-between"
onClick={() => {
if (onClick) {
onClick();
} else {
console.log("key", key);
}
}}
>
<span>{label}</span>
{key === "more" && (
<ChevronRight className="size-4 text-muted-foreground" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<div />
</PopoverTrigger>
<PopoverPortal container={triggerRef.current}>
<PopoverContent className="w-100 p-4 text-sm">
<div className="flex items-center gap-2 text-sm">
<span className="font-bold">Filters</span>
<BrushCleaning className="size-4 text-[#6000FF]" />
</div>
<div className="pt-4 pb-2 text-[#999]">Date range</div>
<DatePicker.RangePicker />
<div className="pt-4 pb-2 text-[#999]">Type</div>
<MultiSelect
value={aggregateFilter.type ?? []}
placeholder="Please select type"
options={typeOptions}
onChange={(value) => {
setAggregateFilter({
...aggregateFilter,
type: value,
});
}}
/>
<div className="pt-4 pb-2 text-[#999]">Source</div>
<MultiSelect
value={aggregateFilter.source ?? []}
placeholder="Please select source"
options={sourceOptions}
onChange={(value) => {
setAggregateFilter({
...aggregateFilter,
source: value,
});
}}
/>
</PopoverContent>
</PopoverPortal>
</Popover>
</div>
);
};
export default TimeFilter;

View File

@@ -1,97 +1,92 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { FC, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./dropdown-menu";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox";
type Option = { value: string; label: string };
export interface MultiSelectProps {
options: Option[];
value: string[];
onChange?: (next: string[]) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
export interface Option {
label: string;
value: string;
}
export const MultiSelect: React.FC<MultiSelectProps> = ({
options,
value,
onChange,
placeholder = "",
className,
disabled,
}) => {
const [open, setOpen] = React.useState(false);
const values = React.useMemo(() => new Set(value), [value]);
export interface MultiSelectProps {
value: string[];
options: Option[];
placeholder?: string;
onChange?: (value: string[]) => void;
}
const toggle = (v: string) => {
const next = new Set(values);
if (next.has(v)) next.delete(v);
else next.add(v);
onChange?.(Array.from(next));
const MultiSelect: FC<MultiSelectProps> = (props) => {
const { value, options, placeholder, onChange } = props;
const [open, setOpen] = useState(false);
const renderTrigger = () => {
if (value.length === 0) {
return <div className="text-muted-foreground px-1">{placeholder}</div>;
}
return (
<div className="flex flex-wrap gap-1">
{value.map((item) => (
<div className="inline-flex items-center gap-1 h-5.5 px-2 bg-muted rounded-md text-muted-foreground">
<span>{item}</span>
</div>
))}
</div>
);
};
const display = React.useMemo(() => {
if (values.size === 0) return placeholder;
const labels = options
.filter((o) => values.has(o.value))
.map((o) => o.label);
return labels.join(", ");
}, [options, values, placeholder]);
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<span className={cn(values.size === 0 && "text-muted-foreground")}>{display}</span>
<svg
className="h-4 w-4 opacity-70"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Content
sideOffset={4}
className={cn(
"z-50 w-(--radix-popover-trigger-width) min-w-[220px] rounded-md border border-input bg-popover p-2 text-popover-foreground shadow-md outline-none",
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
)}
>
<div className="max-h-48 overflow-y-auto space-y-1">
{options.map((opt) => {
const checked = values.has(opt.value) ? "checked" : "unchecked";
return (
<label
key={opt.value}
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.preventDefault();
toggle(opt.value);
}}
>
<Checkbox checked={checked === "checked"} className="h-4 w-4" />
<span className="text-sm">{opt.label}</span>
</label>
);
})}
<DropdownMenu onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<div className="flex items-center justify-between border border-border min-h-8 rounded-lg p-1">
{renderTrigger()}
<ChevronDown
className={cn("size-4 min-w-4 text-muted-foreground transition", {
"rotate-180": open,
})}
/>
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Root>
</DropdownMenuTrigger>
<DropdownMenuContent>
{options.map((item) => {
const { label, value: itemValue } = item;
const included = value.includes(itemValue);
return (
<DropdownMenuItem
className={"flex items-center justify-between gap-2"}
key={itemValue}
onSelect={(event) => {
event.preventDefault();
if (included) {
onChange?.(value.filter((item) => item !== itemValue));
} else {
onChange?.([...value, itemValue]);
}
}}
>
<span>{label}</span>
<Check
className={cn("size-4 text-muted-foreground", {
"opacity-0": !value.includes(itemValue),
})}
/>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
};
export default MultiSelect;

View File

@@ -14,9 +14,16 @@ export type ViewExtensionOpened = [
// HTML file URL
string,
ExtensionPermission | null,
ViewExtensionUISettings | null,
ViewExtensionUISettings | null
];
export interface AggregateFilter {
update_time_start?: string;
update_time_end?: string;
type?: string[];
source?: string[];
}
export type ISearchStore = {
sourceData: any;
setSourceData: (sourceData: any) => void;
@@ -64,8 +71,19 @@ export type ISearchStore = {
// When we open a View extension, we set this to a non-null value.
viewExtensionOpened?: ViewExtensionOpened;
setViewExtensionOpened: (showViewExtension?: ViewExtensionOpened) => void;
enabledFuzzyMatch: boolean;
setEnabledFuzzyMatch: (enabledFuzzyMatch: boolean) => void;
fuzziness: number;
setFuzziness: (fuzziness: number) => void;
aggregateFilter: AggregateFilter;
setAggregateFilter: (aggregateFilter: AggregateFilter) => void;
};
export const DEFAULT_FUZZINESS = 5;
export const useSearchStore = create<ISearchStore>()(
persist(
(set) => ({
@@ -138,11 +156,24 @@ export const useSearchStore = create<ISearchStore>()(
setViewExtensionOpened: (viewExtensionOpened) => {
return set({ viewExtensionOpened });
},
enabledFuzzyMatch: false,
setEnabledFuzzyMatch: (enabledFuzzyMatch) => {
return set({ enabledFuzzyMatch });
},
fuzziness: DEFAULT_FUZZINESS,
setFuzziness: (fuzziness) => {
return set({ fuzziness });
},
aggregateFilter: {},
setAggregateFilter: (aggregateFilter) => {
return set({ aggregateFilter });
},
}),
{
name: "search-store",
partialize: (state) => ({
sourceData: state.sourceData,
fuzziness: state.fuzziness,
}),
}
)