mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat: add suggestion tooltip UI and autocomplete API types
This commit is contained in:
parent
fbd033d0a4
commit
bcc227a4dd
4 changed files with 275 additions and 0 deletions
13
surfsense_web/app/suggestion/layout.tsx
Normal file
13
surfsense_web/app/suggestion/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import "./suggestion.css";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "SurfSense Suggestion",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SuggestionLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <div className="suggestion-body">{children}</div>;
|
||||||
|
}
|
||||||
160
surfsense_web/app/suggestion/page.tsx
Normal file
160
surfsense_web/app/suggestion/page.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
type SSEEvent =
|
||||||
|
| { type: "text-delta"; id: string; delta: string }
|
||||||
|
| { type: "text-start"; id: string }
|
||||||
|
| { type: "text-end"; id: string }
|
||||||
|
| { type: "start"; messageId: string }
|
||||||
|
| { type: "finish" }
|
||||||
|
| { type: "error"; errorText: string };
|
||||||
|
|
||||||
|
export default function SuggestionPage() {
|
||||||
|
const [suggestion, setSuggestion] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const fetchSuggestion = useCallback(
|
||||||
|
async (text: string, cursorPosition: number, searchSpaceId: string) => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setSuggestion("");
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const token = getBearerToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Not authenticated");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backendUrl =
|
||||||
|
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
text,
|
||||||
|
cursor_position: String(cursorPosition),
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${backendUrl}/api/v1/autocomplete/stream?${params}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(`Error: ${response.status}`);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
setError("No response body");
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const events = buffer.split(/\r?\n\r?\n/);
|
||||||
|
buffer = events.pop() || "";
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const lines = event.split(/\r?\n/);
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
const data = line.slice(6).trim();
|
||||||
|
if (!data || data === "[DONE]") continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: SSEEvent = JSON.parse(data);
|
||||||
|
if (parsed.type === "text-delta") {
|
||||||
|
setSuggestion((prev) => {
|
||||||
|
const updated = prev + parsed.delta;
|
||||||
|
window.electronAPI?.updateSuggestionText?.(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
} else if (parsed.type === "error") {
|
||||||
|
setError(parsed.errorText);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||||
|
setError("Failed to get suggestion");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.electronAPI?.onAutocompleteContext) return;
|
||||||
|
|
||||||
|
const cleanup = window.electronAPI.onAutocompleteContext((data) => {
|
||||||
|
const searchSpaceId = data.searchSpaceId || "1";
|
||||||
|
fetchSuggestion(data.text, data.cursorPosition, searchSpaceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}, [fetchSuggestion]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="suggestion-tooltip suggestion-error">
|
||||||
|
<span className="suggestion-error-text">{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && !suggestion) {
|
||||||
|
return (
|
||||||
|
<div className="suggestion-tooltip">
|
||||||
|
<div className="suggestion-loading">
|
||||||
|
<span className="suggestion-dot" />
|
||||||
|
<span className="suggestion-dot" />
|
||||||
|
<span className="suggestion-dot" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!suggestion) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="suggestion-tooltip">
|
||||||
|
<p className="suggestion-text">{suggestion}</p>
|
||||||
|
<div className="suggestion-hint">
|
||||||
|
<span className="suggestion-key">Tab</span> accept
|
||||||
|
<span className="suggestion-separator">·</span>
|
||||||
|
<span className="suggestion-key">Esc</span> dismiss
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
surfsense_web/app/suggestion/suggestion.css
Normal file
96
surfsense_web/app/suggestion/suggestion.css
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
.suggestion-body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-tooltip {
|
||||||
|
background: rgba(30, 30, 30, 0.95);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 4px;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-hint {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 11px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-key {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-separator {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-error {
|
||||||
|
border-color: rgba(255, 80, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-error-text {
|
||||||
|
color: rgba(255, 120, 120, 0.9);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-loading {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
animation: suggestion-pulse 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-dot:nth-child(2) {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-dot:nth-child(3) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes suggestion-pulse {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
surfsense_web/types/window.d.ts
vendored
6
surfsense_web/types/window.d.ts
vendored
|
|
@ -14,6 +14,12 @@ interface ElectronAPI {
|
||||||
setQuickAskMode: (mode: string) => Promise<void>;
|
setQuickAskMode: (mode: string) => Promise<void>;
|
||||||
getQuickAskMode: () => Promise<string>;
|
getQuickAskMode: () => Promise<string>;
|
||||||
replaceText: (text: string) => Promise<void>;
|
replaceText: (text: string) => Promise<void>;
|
||||||
|
onAutocompleteContext: (callback: (data: { text: string; cursorPosition: number; searchSpaceId?: string }) => void) => () => void;
|
||||||
|
acceptSuggestion: (text: string) => Promise<void>;
|
||||||
|
dismissSuggestion: () => Promise<void>;
|
||||||
|
updateSuggestionText: (text: string) => Promise<void>;
|
||||||
|
setAutocompleteEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
getAutocompleteEnabled: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue