rename quick-ask-actions to prompts across backend and frontend

This commit is contained in:
CREDO23 2026-03-29 00:07:08 +02:00
parent 11374248d8
commit a6ccb7a875
15 changed files with 277 additions and 523 deletions

View file

@ -1,68 +0,0 @@
import type { QuickAskAction } from "@/contracts/types/quick-ask-actions.types";
export const DEFAULT_ACTIONS: QuickAskAction[] = [
{
id: "fix-grammar",
name: "Fix grammar",
prompt: "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}",
mode: "transform",
icon: "check",
group: "transform",
},
{
id: "make-shorter",
name: "Make shorter",
prompt: "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}",
mode: "transform",
icon: "minimize",
group: "transform",
},
{
id: "translate",
name: "Translate",
prompt: "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}",
mode: "transform",
icon: "languages",
group: "transform",
},
{
id: "rewrite",
name: "Rewrite",
prompt: "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}",
mode: "transform",
icon: "pen-line",
group: "transform",
},
{
id: "summarize",
name: "Summarize",
prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}",
mode: "transform",
icon: "list",
group: "transform",
},
{
id: "explain",
name: "Explain",
prompt: "Explain the following text in simple terms:\n\n{selection}",
mode: "explore",
icon: "book-open",
group: "explore",
},
{
id: "ask-knowledge-base",
name: "Ask my knowledge base",
prompt: "Search my knowledge base for information related to:\n\n{selection}",
mode: "explore",
icon: "search",
group: "explore",
},
{
id: "look-up-web",
name: "Look up on the web",
prompt: "Search the web for information about:\n\n{selection}",
mode: "explore",
icon: "globe",
group: "explore",
},
];

View file

@ -1,152 +0,0 @@
"use client";
import {
BookOpen,
Check,
Globe,
Languages,
List,
MessageSquare,
Minimize2,
PenLine,
Search,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { DEFAULT_ACTIONS } from "./actions";
const ICONS: Record<string, React.ReactNode> = {
check: <Check className="size-4" />,
minimize: <Minimize2 className="size-4" />,
languages: <Languages className="size-4" />,
"pen-line": <PenLine className="size-4" />,
"book-open": <BookOpen className="size-4" />,
list: <List className="size-4" />,
search: <Search className="size-4" />,
globe: <Globe className="size-4" />,
};
export default function QuickAskPage() {
const [clipboardText, setClipboardText] = useState("");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => {
window.electronAPI?.getQuickAskText().then((text) => {
if (text) setClipboardText(text);
});
}, []);
const navigateToChat = async (prompt: string, mode: string) => {
await window.electronAPI?.setQuickAskMode(mode);
sessionStorage.setItem("quickAskAutoSubmit", "true");
const encoded = encodeURIComponent(prompt);
window.location.href = `/dashboard?quickAskPrompt=${encoded}`;
};
const navigateWithInitialText = async () => {
if (!clipboardText) return;
await window.electronAPI?.setQuickAskMode("explore");
sessionStorage.setItem("quickAskAutoSubmit", "false");
sessionStorage.setItem("quickAskInitialText", clipboardText);
window.location.href = `/dashboard?quickAskPrompt=${encodeURIComponent(clipboardText)}`;
};
const handleAction = (actionId: string) => {
const action = DEFAULT_ACTIONS.find((a) => a.id === actionId);
if (!action || !clipboardText) return;
const prompt = action.prompt.replace("{selection}", clipboardText);
navigateToChat(prompt, action.mode);
};
const transformActions = DEFAULT_ACTIONS.filter((a) => a.group === "transform");
const exploreActions = DEFAULT_ACTIONS.filter((a) => a.group === "explore");
const filteredTransform = useMemo(
() => transformActions.filter((a) => a.name.toLowerCase().includes(searchQuery.toLowerCase())),
[searchQuery]
);
const filteredExplore = useMemo(
() => exploreActions.filter((a) => a.name.toLowerCase().includes(searchQuery.toLowerCase())),
[searchQuery]
);
if (!clipboardText) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-sm text-muted-foreground">Loading...</div>
</div>
);
}
return (
<div className="flex h-screen flex-col bg-background">
<div className="border-b px-3 py-2">
<div className="flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-1.5">
<Search className="size-3.5 text-muted-foreground" />
<input
type="text"
placeholder="Search actions..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">
{filteredTransform.length > 0 && (
<>
<div className="mb-2 text-xs font-medium text-muted-foreground">Transform</div>
<div className="mb-3 grid grid-cols-2 gap-1.5">
{filteredTransform.map((action) => (
<button
key={action.id}
type="button"
onClick={() => handleAction(action.id)}
className="flex items-center gap-2 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-accent hover:border-accent-foreground/20 cursor-pointer"
>
<span className="text-muted-foreground">{ICONS[action.icon]}</span>
{action.name}
</button>
))}
</div>
</>
)}
{filteredExplore.length > 0 && (
<>
<div className="mb-2 text-xs font-medium text-muted-foreground">Explore</div>
<div className="mb-3 grid grid-cols-2 gap-1.5">
{filteredExplore.map((action) => (
<button
key={action.id}
type="button"
onClick={() => handleAction(action.id)}
className="flex items-center gap-2 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-accent hover:border-accent-foreground/20 cursor-pointer"
>
<span className="text-muted-foreground">{ICONS[action.icon]}</span>
{action.name}
</button>
))}
</div>
</>
)}
<div className="mb-2 text-xs font-medium text-muted-foreground">My Actions</div>
<div className="mb-3 rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
Custom actions coming soon
</div>
</div>
<div className="border-t px-3 py-2">
<button
type="button"
onClick={navigateWithInitialText}
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-3 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 cursor-pointer"
>
<MessageSquare className="size-4" />
Ask SurfSense...
</button>
</div>
</div>
);
}

View file

@ -57,7 +57,7 @@ import {
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel";
import { ActionPicker, type ActionPickerRef } from "@/components/new-chat/action-picker";
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import {
DocumentMentionPicker,
type DocumentMentionPickerRef,
@ -299,13 +299,13 @@ const Composer: FC = () => {
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [showActionPicker, setShowActionPicker] = useState(false);
const [showPromptPicker, setShowPromptPicker] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [actionQuery, setActionQuery] = useState("");
const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const actionPickerRef = useRef<ActionPickerRef>(null);
const promptPickerRef = useRef<PromptPickerRef>(null);
const { search_space_id, chat_id } = useParams();
const aui = useAui();
const hasAutoFocusedRef = useRef(false);
@ -427,24 +427,24 @@ const Composer: FC = () => {
// Open action picker when / is triggered
const handleActionTrigger = useCallback((query: string) => {
setShowActionPicker(true);
setShowPromptPicker(true);
setActionQuery(query);
}, []);
// Close action picker and reset query
const handleActionClose = useCallback(() => {
if (showActionPicker) {
setShowActionPicker(false);
if (showPromptPicker) {
setShowPromptPicker(false);
setActionQuery("");
}
}, [showActionPicker]);
}, [showPromptPicker]);
// Pending action prompt stored when user picks an action
const pendingActionRef = useRef<{ name: string; prompt: string; mode: "transform" | "explore" } | null>(null);
const handleActionSelect = useCallback(
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
setShowActionPicker(false);
setShowPromptPicker(false);
setActionQuery("");
pendingActionRef.current = action;
editorRef.current?.insertActionChip(action.name);
@ -459,25 +459,25 @@ const Composer: FC = () => {
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (showActionPicker) {
if (showPromptPicker) {
if (e.key === "ArrowDown") {
e.preventDefault();
actionPickerRef.current?.moveDown();
promptPickerRef.current?.moveDown();
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
actionPickerRef.current?.moveUp();
promptPickerRef.current?.moveUp();
return;
}
if (e.key === "Enter") {
e.preventDefault();
actionPickerRef.current?.selectHighlighted();
promptPickerRef.current?.selectHighlighted();
return;
}
if (e.key === "Escape") {
e.preventDefault();
setShowActionPicker(false);
setShowPromptPicker(false);
setActionQuery("");
return;
}
@ -506,7 +506,7 @@ const Composer: FC = () => {
}
}
},
[showDocumentPopover, showActionPicker]
[showDocumentPopover, showPromptPicker]
);
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
@ -514,7 +514,7 @@ const Composer: FC = () => {
if (isThreadRunning || isBlockedByOtherUser) {
return;
}
if (!showDocumentPopover && !showActionPicker) {
if (!showDocumentPopover && !showPromptPicker) {
if (pendingActionRef.current) {
const userText = editorRef.current?.getText() ?? "";
const finalPrompt = pendingActionRef.current.prompt.replace("{selection}", userText);
@ -528,7 +528,7 @@ const Composer: FC = () => {
}
}, [
showDocumentPopover,
showActionPicker,
showPromptPicker,
isThreadRunning,
isBlockedByOtherUser,
aui,
@ -621,14 +621,14 @@ const Composer: FC = () => {
/>,
document.body
)}
{showActionPicker &&
{showPromptPicker &&
typeof document !== "undefined" &&
createPortal(
<ActionPicker
ref={actionPickerRef}
<PromptPicker
ref={promptPickerRef}
onSelect={handleActionSelect}
onDone={() => {
setShowActionPicker(false);
setShowPromptPicker(false);
setActionQuery("");
}}
externalSearch={actionQuery}

View file

@ -21,17 +21,17 @@ import {
useState,
} from "react";
import type { QuickAskActionRead } from "@/contracts/types/quick-ask-actions.types";
import { quickAskActionsApiService } from "@/lib/apis/quick-ask-actions-api.service";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
import { cn } from "@/lib/utils";
export interface ActionPickerRef {
export interface PromptPickerRef {
selectHighlighted: () => void;
moveUp: () => void;
moveDown: () => void;
}
interface ActionPickerProps {
interface PromptPickerProps {
onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void;
onDone: () => void;
externalSearch?: string;
@ -61,27 +61,27 @@ const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "expl
{ name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" },
];
export const ActionPicker = forwardRef<ActionPickerRef, ActionPickerProps>(
function ActionPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) {
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
function PromptPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) {
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [customActions, setCustomActions] = useState<QuickAskActionRead[]>([]);
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
useEffect(() => {
quickAskActionsApiService.list().then(setCustomActions).catch(() => {});
promptsApiService.list().then(setCustomPrompts).catch(() => {});
}, []);
const allActions = useMemo(() => {
const customs = customActions.map((a) => ({
const customs = customPrompts.map((a) => ({
name: a.name,
prompt: a.prompt,
mode: a.mode as "transform" | "explore",
icon: a.icon || "zap",
}));
return [...DEFAULT_ACTIONS, ...customs];
}, [customActions]);
}, [customPrompts]);
const filtered = useMemo(() => {
if (!externalSearch) return allActions;

View file

@ -0,0 +1,40 @@
import { z } from "zod";
export type PromptMode = "transform" | "explore";
export const promptRead = z.object({
id: z.number(),
name: z.string(),
prompt: z.string(),
mode: z.enum(["transform", "explore"]),
icon: z.string().nullable(),
search_space_id: z.number().nullable(),
created_at: z.string(),
});
export type PromptRead = z.infer<typeof promptRead>;
export const promptsListResponse = z.array(promptRead);
export const promptCreateRequest = z.object({
name: z.string().min(1).max(200),
prompt: z.string().min(1),
mode: z.enum(["transform", "explore"]),
icon: z.string().max(50).nullable().optional(),
search_space_id: z.number().nullable().optional(),
});
export type PromptCreateRequest = z.infer<typeof promptCreateRequest>;
export const promptUpdateRequest = z.object({
name: z.string().min(1).max(200).optional(),
prompt: z.string().min(1).optional(),
mode: z.enum(["transform", "explore"]).optional(),
icon: z.string().max(50).nullable().optional(),
});
export type PromptUpdateRequest = z.infer<typeof promptUpdateRequest>;
export const promptDeleteResponse = z.object({
success: z.boolean(),
});

View file

@ -1,49 +0,0 @@
import { z } from "zod";
export type QuickAskActionMode = "transform" | "explore";
export const quickAskActionRead = z.object({
id: z.number(),
name: z.string(),
prompt: z.string(),
mode: z.enum(["transform", "explore"]),
icon: z.string().nullable(),
search_space_id: z.number().nullable(),
created_at: z.string(),
});
export type QuickAskActionRead = z.infer<typeof quickAskActionRead>;
export const quickAskActionsListResponse = z.array(quickAskActionRead);
export const quickAskActionCreateRequest = z.object({
name: z.string().min(1).max(200),
prompt: z.string().min(1),
mode: z.enum(["transform", "explore"]),
icon: z.string().max(50).nullable().optional(),
search_space_id: z.number().nullable().optional(),
});
export type QuickAskActionCreateRequest = z.infer<typeof quickAskActionCreateRequest>;
export const quickAskActionUpdateRequest = z.object({
name: z.string().min(1).max(200).optional(),
prompt: z.string().min(1).optional(),
mode: z.enum(["transform", "explore"]).optional(),
icon: z.string().max(50).nullable().optional(),
});
export type QuickAskActionUpdateRequest = z.infer<typeof quickAskActionUpdateRequest>;
export const quickAskActionDeleteResponse = z.object({
success: z.boolean(),
});
export interface QuickAskAction {
id: string;
name: string;
prompt: string;
mode: QuickAskActionMode;
icon: string;
group: "transform" | "explore" | "knowledge" | "custom";
}

View file

@ -0,0 +1,54 @@
import {
type PromptCreateRequest,
type PromptUpdateRequest,
promptCreateRequest,
promptDeleteResponse,
promptRead,
promptUpdateRequest,
promptsListResponse,
} from "@/contracts/types/prompts.types";
import { ValidationError } from "@/lib/error";
import { baseApiService } from "./base-api.service";
class PromptsApiService {
list = async (searchSpaceId?: number) => {
const params = new URLSearchParams();
if (searchSpaceId !== undefined) {
params.set("search_space_id", String(searchSpaceId));
}
const queryString = params.toString();
const url = queryString ? `/api/v1/prompts?${queryString}` : "/api/v1/prompts";
return baseApiService.get(url, promptsListResponse);
};
create = async (request: PromptCreateRequest) => {
const parsed = promptCreateRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post("/api/v1/prompts", promptRead, {
body: parsed.data,
});
};
update = async (promptId: number, request: PromptUpdateRequest) => {
const parsed = promptUpdateRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.put(`/api/v1/prompts/${promptId}`, promptRead, {
body: parsed.data,
});
};
delete = async (promptId: number) => {
return baseApiService.delete(`/api/v1/prompts/${promptId}`, promptDeleteResponse);
};
}
export const promptsApiService = new PromptsApiService();

View file

@ -1,59 +0,0 @@
import {
type QuickAskActionCreateRequest,
type QuickAskActionUpdateRequest,
quickAskActionCreateRequest,
quickAskActionDeleteResponse,
quickAskActionRead,
quickAskActionUpdateRequest,
quickAskActionsListResponse,
} from "@/contracts/types/quick-ask-actions.types";
import { ValidationError } from "@/lib/error";
import { baseApiService } from "./base-api.service";
class QuickAskActionsApiService {
list = async (searchSpaceId?: number) => {
const params = new URLSearchParams();
if (searchSpaceId !== undefined) {
params.set("search_space_id", String(searchSpaceId));
}
const queryString = params.toString();
const url = queryString
? `/api/v1/quick-ask-actions?${queryString}`
: "/api/v1/quick-ask-actions";
return baseApiService.get(url, quickAskActionsListResponse);
};
create = async (request: QuickAskActionCreateRequest) => {
const parsed = quickAskActionCreateRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.post("/api/v1/quick-ask-actions", quickAskActionRead, {
body: parsed.data,
});
};
update = async (actionId: number, request: QuickAskActionUpdateRequest) => {
const parsed = quickAskActionUpdateRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
throw new ValidationError(`Invalid request: ${errorMessage}`);
}
return baseApiService.put(`/api/v1/quick-ask-actions/${actionId}`, quickAskActionRead, {
body: parsed.data,
});
};
delete = async (actionId: number) => {
return baseApiService.delete(
`/api/v1/quick-ask-actions/${actionId}`,
quickAskActionDeleteResponse
);
};
}
export const quickAskActionsApiService = new QuickAskActionsApiService();