chore: search-chat components add formatUrl & think data & icons url (#765)

* chore: web components add formatUrl & think data

* chore: add headers

* chore: add

* chhore: add server url

* docs: update notes

* chore: url

* docs: search chat docs
This commit is contained in:
BiggerRain
2025-07-17 09:22:23 +08:00
committed by GitHub
parent 0b5e31a476
commit e3a3849fa4
23 changed files with 193 additions and 348 deletions

View File

@@ -32,6 +32,7 @@
"localstorage",
"lucide",
"maximizable",
"mdast",
"meval",
"Minimizable",
"msvc",

View File

@@ -37,6 +37,7 @@ Information about release notes of Coco Server is provided here.
- chore: rename QuickLink/quick_link to Quicklink/quicklink #752
- chore: assistant params & styles #753
- chore: make optional fields optional #758
- chore: search-chat components add formatUrl & think data & icons url #765
## 0.6.0 (2025-06-29)

View File

@@ -96,7 +96,7 @@ export const Get = <T>(
export const Post = <T>(
url: string,
data: IAnyObj,
data: IAnyObj | undefined,
params: IAnyObj = {},
headers: IAnyObj = {}
): Promise<[any, FcResponse<T> | undefined]> => {

View File

@@ -15,11 +15,14 @@ export async function streamPost({
}) {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
let baseURL = appStore.state?.endpoint_http
let baseURL = appStore.state?.endpoint_http;
if (!baseURL || baseURL === "undefined") {
baseURL = "";
}
const headersStr = localStorage.getItem("headers") || "{}";
const headersStorage = JSON.parse(headersStr);
const query = new URLSearchParams(queryParams || {}).toString();
const fullUrl = `${baseURL}${url}?${query}`;
@@ -28,6 +31,7 @@ export async function streamPost({
method: "POST",
headers: {
"Content-Type": "application/json",
...(headersStorage),
...(headers || {}),
},
body: JSON.stringify(body),

View File

@@ -1,133 +0,0 @@
import { fetch } from "@tauri-apps/plugin-http";
import { clientEnv } from "@/utils/env";
import { useLogStore } from "@/stores/logStore";
import { get_server_token } from "@/commands";
interface FetchRequestConfig {
url: string;
method?: "GET" | "POST" | "PUT" | "DELETE";
headers?: Record<string, string>;
body?: any;
timeout?: number;
parseAs?: "json" | "text" | "binary";
baseURL?: string;
}
interface FetchResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Headers;
}
const timeoutPromise = (ms: number) => {
return new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Request timed out after ${ms} ms`)), ms)
);
};
export const tauriFetch = async <T = any>({
url,
method = "GET",
headers = {},
body,
timeout = 30,
parseAs = "json",
baseURL = clientEnv.COCO_SERVER_URL
}: FetchRequestConfig): Promise<FetchResponse<T>> => {
const addLog = useLogStore.getState().addLog;
try {
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
const connectStore = JSON.parse(localStorage.getItem("connect-store") || "{}");
console.log("baseURL", appStore.state?.endpoint_http)
baseURL = appStore.state?.endpoint_http || baseURL;
const authStore = JSON.parse(localStorage.getItem("auth-store") || "{}")
const auth = authStore?.state?.auth
console.log("auth", auth)
if (baseURL.endsWith("/")) {
baseURL = baseURL.slice(0, -1);
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
// If not, prepend the defaultPrefix
url = baseURL + url;
}
if (method !== "GET") {
headers["Content-Type"] = "application/json";
}
const server_id = connectStore.state?.currentService?.id || "default_coco_server"
const res: any = await get_server_token(server_id);
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || res?.access_token || undefined;
// debug API
const requestInfo = {
url,
method,
headers,
body,
timeout,
parseAs,
};
const fetchPromise = fetch(url, {
method,
headers,
body,
});
const response = await Promise.race([
fetchPromise,
timeoutPromise(timeout * 1000),
]);
const statusText = response.ok ? "OK" : "Error";
let data: any;
if (parseAs === "json") {
data = await response.json();
} else if (parseAs === "text") {
data = await response.text();
} else {
data = await response.arrayBuffer();
}
// debug API
const log = {
request: requestInfo,
response: {
data,
status: response.status,
statusText,
headers: response.headers,
},
};
addLog(log);
return log.response;
} catch (error) {
console.error("Request failed:", error);
// debug API
const log = {
request: {
url,
method,
headers,
body,
timeout,
parseAs,
},
error,
};
addLog(log);
throw error;
}
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -28,7 +28,11 @@ interface State {
activeMenuIndex: number;
}
const ContextMenu = () => {
interface ContextMenuProps {
formatUrl?: (item: any) => string;
}
const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { t, i18n } = useTranslation();
const state = useReactive<State>({
@@ -122,7 +126,10 @@ const ContextMenu = () => {
shortcut: "enter",
hide: category === "Calculator",
clickEvent: () => {
platformAdapter.openSearchItem(selectedSearchContent as any);
platformAdapter.openSearchItem(
selectedSearchContent as any,
formatUrl
);
},
},
{
@@ -135,7 +142,7 @@ const ContextMenu = () => {
type === "AI Assistant" ||
id === "Extension Store",
clickEvent() {
copyToClipboard(url);
copyToClipboard(formatUrl && formatUrl(selectedSearchContent) || url);
},
},
{

View File

@@ -20,6 +20,7 @@ interface DocumentListProps {
selectedId?: string;
viewMode: "detail" | "list";
setViewMode: (mode: "detail" | "list") => void;
formatUrl?: (item: any) => string;
}
const PAGE_SIZE = 20;
@@ -30,6 +31,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
isChatMode,
viewMode,
setViewMode,
formatUrl,
}) => {
const { t } = useTranslation();
const sourceData = useSearchStore((state) => state.sourceData);
@@ -174,7 +176,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
if (selectedItem === null) return;
const item = data.list[selectedItem]?.document;
platformAdapter.openSearchItem(item);
platformAdapter.openSearchItem(item, formatUrl);
};
switch (e.key) {
@@ -238,7 +240,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
currentIndex={index}
onMouseEnter={() => onMouseEnter(index, hit.document)}
onItemClick={() => {
platformAdapter.openSearchItem(hit.document);
platformAdapter.openSearchItem(hit.document, formatUrl);
}}
showListRight={viewMode === "list"}
/>

View File

@@ -26,6 +26,7 @@ interface DropdownListProps {
isSearchComplete: boolean;
isChatMode: boolean;
globalItemIndexMap: Record<number, SearchDocument>;
formatUrl?: (item: any) => string;
}
function DropdownList({
@@ -34,6 +35,7 @@ function DropdownList({
isError,
isChatMode,
globalItemIndexMap,
formatUrl,
}: DropdownListProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
@@ -77,7 +79,7 @@ function DropdownList({
setSelectedSearchContent(item);
},
onItemClick: (item: SearchDocument) => {
platformAdapter.openSearchItem(item);
platformAdapter.openSearchItem(item, formatUrl);
},
goToTwoPage: (item: SearchDocument) => {
setSourceData(item);
@@ -142,6 +144,7 @@ function DropdownList({
globalItemIndexMap,
handleItemAction,
isChatMode,
formatUrl,
});
return (

View File

@@ -16,7 +16,8 @@ const SearchResultsPanel = memo<{
isChatMode: boolean;
changeInput: (val: string) => void;
changeMode?: (isChatMode: boolean) => void;
}>(({ input, isChatMode, changeInput, changeMode }) => {
formatUrl?: (item: string) => string;
}>(({ input, isChatMode, changeInput, changeMode, formatUrl }) => {
const {
sourceData,
goAskAi,
@@ -90,6 +91,7 @@ const SearchResultsPanel = memo<{
isError={isError}
isSearchComplete={isSearchComplete}
isChatMode={isChatMode}
formatUrl={formatUrl}
/>
);
});
@@ -100,6 +102,7 @@ interface SearchProps {
input: string;
setIsPinned?: (value: boolean) => void;
changeMode?: (isChatMode: boolean) => void;
formatUrl?: (item: any) => string;
}
function Search({
@@ -108,6 +111,7 @@ function Search({
input,
setIsPinned,
changeMode,
formatUrl,
}: SearchProps) {
const mainWindowRef = useRef<HTMLDivElement>(null);
@@ -118,11 +122,12 @@ function Search({
isChatMode={isChatMode}
changeInput={changeInput}
changeMode={changeMode}
formatUrl={formatUrl}
/>
<Footer setIsPinnedWeb={setIsPinned} />
<ContextMenu />
<ContextMenu formatUrl={formatUrl}/>
</div>
);
}

View File

@@ -7,9 +7,10 @@ import { useAppStore } from "@/stores/appStore";
interface SearchResultsProps {
input: string;
isChatMode: boolean;
formatUrl?: (item: any) => string;
}
export function SearchResults({ input, isChatMode }: SearchResultsProps) {
export function SearchResults({ input, isChatMode, formatUrl }: SearchResultsProps) {
const isTauri = useAppStore((state) => state.isTauri);
const [selectedDocumentId, setSelectedDocumentId] = useState("1");
@@ -46,6 +47,7 @@ export function SearchResults({ input, isChatMode }: SearchResultsProps) {
isChatMode={isChatMode}
viewMode={viewMode}
setViewMode={setViewMode}
formatUrl={formatUrl}
/>
{/* Right Panel */}

View File

@@ -42,6 +42,7 @@ interface SearchChatProps {
isMobile?: boolean;
assistantIDs?: string[];
startPage?: StartPage;
formatUrl?: (item: any) => string;
}
function SearchChat({
@@ -57,6 +58,7 @@ function SearchChat({
isMobile = false,
assistantIDs,
startPage,
formatUrl,
}: SearchChatProps) {
const currentAssistant = useConnectStore((state) => state.currentAssistant);
@@ -331,6 +333,7 @@ function SearchChat({
changeInput={setInput}
setIsPinned={setIsPinned}
changeMode={changeMode}
formatUrl={formatUrl}
/>
</Suspense>
</div>

View File

@@ -254,7 +254,8 @@ export function useChatActions(
[chatHistory, sendMessage]
);
const handleChatCreateStreamMessage = useCallback((msg: string) => {
const handleChatCreateStreamMessage = useCallback(
(msg: string) => {
if (
msg.includes("_id") &&
msg.includes("_source") &&
@@ -292,8 +293,19 @@ export function useChatActions(
setVisibleStartPage(false);
return;
}
dealMsgRef.current?.(msg);
}, [curIdRef, updatedChatRef, changeInput, setActiveChat, setCurChatEnd, setVisibleStartPage, dealMsgRef]);
},
[
curIdRef,
updatedChatRef,
changeInput,
setActiveChat,
setCurChatEnd,
setVisibleStartPage,
dealMsgRef,
]
);
useEffect(() => {
if (!isTauri || !currentService?.id) return;

View File

@@ -15,6 +15,7 @@ interface UseKeyboardNavigationProps {
globalItemIndexMap: Record<number, SearchDocument>;
handleItemAction: (item: SearchDocument) => void;
isChatMode: boolean;
formatUrl?: (item: any) => string;
}
export function useKeyboardNavigation({
@@ -27,6 +28,7 @@ export function useKeyboardNavigation({
globalItemIndexMap,
handleItemAction,
isChatMode,
formatUrl,
}: UseKeyboardNavigationProps) {
const openPopover = useShortcutsStore((state) => state.openPopover);
const visibleContextMenu = useSearchStore((state) => {
@@ -109,7 +111,7 @@ export function useKeyboardNavigation({
if (e.key === "Enter" && !e.shiftKey && selectedIndex !== null) {
const item = globalItemIndexMap[selectedIndex];
return platformAdapter.openSearchItem(item);
return platformAdapter.openSearchItem(item, formatUrl);
}
if (e.key >= "0" && e.key <= "9" && showIndex && modifierKeyPressed) {
@@ -121,7 +123,7 @@ export function useKeyboardNavigation({
const item = globalItemIndexMap[index];
platformAdapter.openSearchItem(item);
platformAdapter.openSearchItem(item, formatUrl);
}
},
[suggests, selectedIndex, showIndex, globalItemIndexMap, openPopover]

View File

@@ -25,6 +25,7 @@ export function useMessageHandler(
) {
const messageTimeoutRef = useRef<NodeJS.Timeout>();
const connectionTimeout = useConnectStore((state) => state.connectionTimeout);
const inThinkRef = useRef<boolean>(false);
const dealMsg = useCallback(
(msg: string) => {
@@ -54,6 +55,8 @@ export function useMessageHandler(
[chunkData.chunk_type]: true,
}));
if (chunkData.chunk_type === "query_intent") {
handlers.deal_query_intent(chunkData);
} else if (chunkData.chunk_type === "tools") {
@@ -67,7 +70,28 @@ export function useMessageHandler(
} else if (chunkData.chunk_type === "think") {
handlers.deal_think(chunkData);
} else if (chunkData.chunk_type === "response") {
const message_chunk = chunkData.message_chunk;
if (typeof message_chunk === "string") {
if (
message_chunk.includes("\u003cthink\u003e") ||
message_chunk.includes("<think>")
) {
inThinkRef.current = true;
return;
} else if (
message_chunk.includes("\u003c/think\u003e") ||
message_chunk.includes("</think>")
) {
inThinkRef.current = false;
return;
}
if (inThinkRef.current) {
handlers.deal_think({...chunkData, chunk_type: "think"});
} else {
handlers.deal_response(chunkData);
}
}
} else if (chunkData.chunk_type === "reply_end") {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);

View File

@@ -26,9 +26,15 @@ const useScript = (src: string, onError?: () => void) => {
export default useScript;
export const useIconfontScript = () => {
export const useIconfontScript = (type: "web" | "app", serverUrl?: string) => {
if (type === "web") {
useScript(`${serverUrl}/assets/fonts/icons/iconfont.js`);
useScript(`${serverUrl}/assets/fonts/icons-app/iconfont.js`);
} else {
// Coco Server Icons
useScript("https://at.alicdn.com/t/c/font_4878526_cykw3et0ezd.js");
// Coco App Icons
useScript("https://at.alicdn.com/t/c/font_4934333_zclkkzo4fgo.js");
}
};

View File

@@ -1,26 +1,34 @@
# SearchChat Web Component API
A customizable search and chat interface component for web applications.
# @infinilabs/search-chat
## Installation
```bash
npm install @infini/coco-app
npm install @infinilabs/search-chat
# or
pnpm add @infinilabs/search-chat
# or
yarn add @infinilabs/search-chat
```
## Basic Usage
## Quick Start
```jsx
import SearchChat from '@infini/coco-app';
```tsx
import SearchChat from '@infinilabs/search-chat';
function App() {
return (
<SearchChat
serverUrl="https://your-server.com"
headers={{
"X-API-TOKEN": "your-token",
"APP-INTEGRATION-ID": "your-app-id"
}}
serverUrl="https://your-api-url"
width={800}
height={600}
hasModules={['search', 'chat']}
defaultModule="search"
assistantIDs={["a1", "a2"]}
theme="auto"
searchPlaceholder="Please enter search content"
chatPlaceholder="Please enter chat content"
showChatHistory={true}
// other props...
/>
);
}
@@ -28,116 +36,29 @@ function App() {
## Props
### `width`
- **Type**: `number`
- **Default**: `680`
- **Description**: Maximum width of the component in pixels
| Name | Type | Description |
|--------------------|--------------------------------|----------------------------------------------------------|
| headers | Record<string, unknown> | Optional, custom request headers |
| serverUrl | string | Optional, backend service URL |
| width | number | Optional, component width (pixels) |
| height | number | Optional, component height (pixels) |
| hasModules | string[] | Optional, enabled modules, e.g. ['search', 'chat'] |
| defaultModule | "search" \| "chat" | Optional, default module |
| assistantIDs | string[] | Optional, list of available assistant IDs |
| theme | "auto" \| "light" \| "dark" | Optional, theme mode |
| searchPlaceholder | string | Optional, search input placeholder |
| chatPlaceholder | string | Optional, chat input placeholder |
| showChatHistory | boolean | Optional, whether to show chat history |
| startPage | StartPage | Optional, initial page config (see project definition) |
| setIsPinned | (value: boolean) => void | Optional, callback for pinning the component |
| onCancel | () => void | Optional, cancel callback |
| formatUrl | (item: any) => string | Optional, function to format URLs |
| isOpen | boolean | Optional, whether the component is open |
### `height`
- **Type**: `number`
- **Default**: `590`
- **Description**: Height of the component in pixels
> For the `StartPage` type, please refer to the project definition.
### `headers`
- **Type**: `Record<string, unknown>`
- **Default**:
```typescript
{
"X-API-TOKEN": "default-token",
"APP-INTEGRATION-ID": "default-id"
}
```
- **Description**: HTTP headers for API requests
### `serverUrl`
- **Type**: `string`
- **Default**: `""`
- **Description**: Base URL for the server API
### `hasModules`
- **Type**: `string[]`
- **Default**: `["search", "chat"]`
- **Description**: Available modules to show
### `defaultModule`
- **Type**: `"search" | "chat"`
- **Default**: `"search"`
- **Description**: Initial active module
### `assistantIDs`
- **Type**: `string[]`
- **Default**: `[]`
- **Description**: List of assistant IDs to use
### `theme`
- **Type**: `"auto" | "light" | "dark"`
- **Default**: `"dark"`
- **Description**: UI theme setting
### `searchPlaceholder`
- **Type**: `string`
- **Default**: `""`
- **Description**: Placeholder text for search input
### `chatPlaceholder`
- **Type**: `string`
- **Default**: `""`
- **Description**: Placeholder text for chat input
### `showChatHistory`
- **Type**: `boolean`
- **Default**: `false`
- **Description**: Whether to display chat history panel
### `startPage`
- **Type**: `StartPage`
- **Optional**: Yes
- **Description**: Initial page configuration
### `setIsPinned`
- **Type**: `(value: boolean) => void`
- **Optional**: Yes
- **Description**: Callback when pin status changes
### `onCancel`
- **Type**: `() => void`
- **Optional**: Yes
- **Description**: Callback when close button is clicked (mobile only)
### `isOpen`
- **Type**: `boolean`
- **Optional**: Yes
- **Description**: Control component visibility
## Events
The component emits the following events:
- `onModeChange`: Triggered when switching between search and chat modes
- `onCancel`: Triggered when the close button is clicked (mobile only)
## Mobile Support
The component is responsive and includes mobile-specific features:
- Automatic height adjustment
- Close button in top-right corner
- Touch-friendly interface
## Example
```jsx
<SearchChat
width={800}
height={600}
serverUrl="https://api.example.com"
headers={{
"X-API-TOKEN": "your-token",
"APP-INTEGRATION-ID": "your-app-id"
}}
theme="dark"
showChatHistory={true}
hasModules={["search", "chat"]}
defaultModule="chat"
setIsPinned={(isPinned) => console.log('Pinned:', isPinned)}
/>
```
## Notes
- Requires React 18 or above.
- The component is bundled as ESM format; `react` and `react-dom` must be provided by the host project.
- Supports on-demand module loading and custom themes.
- For more advanced usage, please refer to the source code or contact the developer.

View File

@@ -28,6 +28,7 @@ interface WebAppProps {
startPage?: StartPage;
setIsPinned?: (value: boolean) => void;
onCancel?: () => void;
formatUrl?: (item: any) => string;
isOpen?: boolean;
}
@@ -49,6 +50,7 @@ function WebApp({
startPage,
setIsPinned,
onCancel,
formatUrl,
}: WebAppProps) {
const setIsTauri = useAppStore((state) => state.setIsTauri);
const setEndpoint = useAppStore((state) => state.setEndpoint);
@@ -73,7 +75,7 @@ function WebApp({
useEscape();
useModifierKeyPress();
useViewportHeight();
useIconfontScript();
useIconfontScript('web', serverUrl);
return (
<div
@@ -116,6 +118,7 @@ function WebApp({
isMobile={isMobile}
assistantIDs={assistantIDs}
startPage={startPage}
formatUrl={formatUrl}
/>
</div>
);

View File

@@ -108,7 +108,7 @@ export default function Layout() {
platformAdapter.error(message);
});
useIconfontScript();
useIconfontScript('app');
const setDisabledExtensions = useExtensionsStore((state) => {
return state.setDisabledExtensions;

View File

@@ -118,7 +118,7 @@ export interface SystemOperations {
commands: <T>(commandName: string, ...args: any[]) => Promise<T>;
isWindows10: () => Promise<boolean>;
revealItemInDir: (path: string) => Promise<unknown>;
openSearchItem: (data: SearchDocument) => Promise<unknown>;
openSearchItem: (data: SearchDocument, formatUrl?: (item: SearchDocument) => string) => Promise<unknown>;
searchMCPServers: (serverId: string, queryParams: string[]) => Promise<any[]>;
searchDataSources: (serverId: string, queryParams: string[]) => Promise<any[]>;
fetchAssistant: (serverId: string, queryParams: string[]) => Promise<any>;

View File

@@ -200,13 +200,14 @@ export const createWebAdapter = (): WebPlatformAdapter => {
console.log("revealItemInDir is not supported in web environment", path);
},
async openSearchItem(data) {
async openSearchItem(data, formatUrl) {
if (data.type === "AI Assistant") {
return;
}
if (data?.url) {
return OpenURLWithBrowser(data.url);
const url = (formatUrl && formatUrl(data)) || data.url;
if (url) {
return OpenURLWithBrowser(url);
}
if (data?.payload?.result?.value) {
@@ -217,16 +218,9 @@ export const createWebAdapter = (): WebPlatformAdapter => {
error: console.error,
async searchMCPServers(_serverId, queryParams) {
const urlParams = new URLSearchParams();
queryParams.forEach((param) => {
const [key, value] = param.split("=");
urlParams.append(key, decodeURIComponent(value));
});
const [error, res]: any = await Post(
"/mcp_server/_search",
{},
Object.fromEntries(urlParams)
`/mcp_server/_search?${queryParams?.join("&")}`,
undefined
);
if (error) {
@@ -244,16 +238,9 @@ export const createWebAdapter = (): WebPlatformAdapter => {
},
async searchDataSources(_serverId, queryParams) {
const urlParams = new URLSearchParams();
queryParams.forEach((param) => {
const [key, value] = param.split("=");
urlParams.append(key, decodeURIComponent(value));
});
const [error, res]: any = await Post(
"/datasource/_search",
{},
Object.fromEntries(urlParams)
`/datasource/_search?${queryParams?.join("&")}`,
undefined
);
if (error) {
@@ -271,16 +258,9 @@ export const createWebAdapter = (): WebPlatformAdapter => {
},
async fetchAssistant(_serverId, queryParams) {
const urlParams = new URLSearchParams();
queryParams.forEach((param) => {
const [key, value] = param.split("=");
urlParams.append(key, decodeURIComponent(value));
});
const [error, res]: any = await Post(
"/assistant/_search",
{},
Object.fromEntries(urlParams)
`/assistant/_search?${queryParams?.join("&")}`,
undefined
);
if (error) {

View File

@@ -72,7 +72,7 @@ export default defineConfig({
const packageJson = {
name: "@infinilabs/search-chat",
version: "1.2.28",
version: "1.2.35",
main: "index.js",
module: "index.js",
type: "module",