Merge commit '3d74cca88e' into dev_mod

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-03-29 02:47:46 -07:00
commit 461192174d
20 changed files with 1142 additions and 34 deletions

View file

@ -0,0 +1,50 @@
"""add prompts table
Revision ID: 111
Revises: 110
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "111"
down_revision: str | None = "110"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT 1 FROM pg_type WHERE typname = 'prompt_mode'")
)
if not result.fetchone():
op.execute("CREATE TYPE prompt_mode AS ENUM ('transform', 'explore')")
result = conn.execute(
sa.text("SELECT 1 FROM information_schema.tables WHERE table_name = 'prompts'")
)
if not result.fetchone():
op.execute("""
CREATE TABLE prompts (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
search_space_id INTEGER REFERENCES searchspaces(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
prompt TEXT NOT NULL,
mode prompt_mode NOT NULL,
icon VARCHAR(50),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
op.execute("CREATE INDEX ix_prompts_user_id ON prompts (user_id)")
op.execute("CREATE INDEX ix_prompts_search_space_id ON prompts (search_space_id)")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS prompts")
op.execute("DROP TYPE IF EXISTS prompt_mode")

View file

@ -1775,6 +1775,35 @@ class SearchSpaceInvite(BaseModel, TimestampMixin):
)
class PromptMode(StrEnum):
TRANSFORM = "transform"
EXPLORE = "explore"
class Prompt(BaseModel, TimestampMixin):
__tablename__ = "prompts"
user_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
search_space_id = Column(
Integer,
ForeignKey("searchspaces.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
name = Column(String(200), nullable=False)
prompt = Column(Text, nullable=False)
mode = Column(SQLAlchemyEnum(PromptMode), nullable=False)
icon = Column(String(50), nullable=True)
user = relationship("User")
search_space = relationship("SearchSpace")
if config.AUTH_TYPE == "GOOGLE":
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):

View file

@ -35,6 +35,7 @@ from .notifications_routes import router as notifications_router
from .notion_add_connector_route import router as notion_add_connector_router
from .podcasts_routes import router as podcasts_router
from .public_chat_routes import router as public_chat_router
from .prompts_routes import router as prompts_router
from .rbac_routes import router as rbac_router
from .reports_routes import router as reports_router
from .sandbox_routes import router as sandbox_router
@ -89,3 +90,4 @@ router.include_router(composio_router) # Composio OAuth and toolkit management
router.include_router(public_chat_router) # Public chat sharing and cloning
router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages
router.include_router(youtube_router) # YouTube playlist resolution
router.include_router(prompts_router)

View file

@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Prompt, User, get_async_session
from app.schemas.prompts import (
PromptCreate,
PromptRead,
PromptUpdate,
)
from app.users import current_active_user
router = APIRouter(tags=["Prompts"])
@router.get("/prompts", response_model=list[PromptRead])
async def list_prompts(
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
query = select(Prompt).where(Prompt.user_id == user.id)
if search_space_id is not None:
query = query.where(Prompt.search_space_id == search_space_id)
query = query.order_by(Prompt.created_at.desc())
result = await session.execute(query)
return result.scalars().all()
@router.post("/prompts", response_model=PromptRead)
async def create_prompt(
body: PromptCreate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
prompt = Prompt(
user_id=user.id,
search_space_id=body.search_space_id,
name=body.name,
prompt=body.prompt,
mode=body.mode,
icon=body.icon,
)
session.add(prompt)
await session.commit()
await session.refresh(prompt)
return prompt
@router.put("/prompts/{prompt_id}", response_model=PromptRead)
async def update_prompt(
prompt_id: int,
body: PromptUpdate,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
result = await session.execute(
select(Prompt).where(
Prompt.id == prompt_id,
Prompt.user_id == user.id,
)
)
prompt = result.scalar_one_or_none()
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(prompt, field, value)
session.add(prompt)
await session.commit()
await session.refresh(prompt)
return prompt
@router.delete("/prompts/{prompt_id}")
async def delete_prompt(
prompt_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
result = await session.execute(
select(Prompt).where(
Prompt.id == prompt_id,
Prompt.user_id == user.id,
)
)
prompt = result.scalar_one_or_none()
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
await session.delete(prompt)
await session.commit()
return {"success": True}

View file

@ -0,0 +1,31 @@
from datetime import datetime
from pydantic import BaseModel, Field
class PromptCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
prompt: str = Field(..., min_length=1)
mode: str = Field(..., pattern="^(transform|explore)$")
icon: str | None = Field(None, max_length=50)
search_space_id: int | None = None
class PromptUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
prompt: str | None = Field(None, min_length=1)
mode: str | None = Field(None, pattern="^(transform|explore)$")
icon: str | None = Field(None, max_length=50)
class PromptRead(BaseModel):
id: int
name: str
prompt: str
mode: str
icon: str | None
search_space_id: int | None
created_at: datetime
class Config:
from_attributes = True

View file

@ -3,4 +3,7 @@ export const IPC_CHANNELS = {
GET_APP_VERSION: 'get-app-version',
DEEP_LINK: 'deep-link',
QUICK_ASK_TEXT: 'quick-ask-text',
SET_QUICK_ASK_MODE: 'set-quick-ask-mode',
GET_QUICK_ASK_MODE: 'get-quick-ask-mode',
REPLACE_TEXT: 'replace-text',
} as const;

View file

@ -0,0 +1,55 @@
import { execSync } from 'child_process';
import { systemPreferences } from 'electron';
export function getFrontmostApp(): string {
try {
if (process.platform === 'darwin') {
return execSync(
'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\''
).toString().trim();
}
if (process.platform === 'win32') {
return execSync(
'powershell -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"'
).toString().trim();
}
} catch {
return '';
}
return '';
}
export function getSelectedText(): string {
try {
if (process.platform === 'darwin') {
return execSync(
'osascript -e \'tell application "System Events" to get value of attribute "AXSelectedText" of focused UI element of first application process whose frontmost is true\''
).toString().trim();
}
// Windows: no reliable accessibility API for selected text across apps
} catch {
return '';
}
return '';
}
export function simulateCopy(): void {
if (process.platform === 'darwin') {
execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\'');
} else if (process.platform === 'win32') {
execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^c\')"');
}
}
export function simulatePaste(): void {
if (process.platform === 'darwin') {
execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\'');
} else if (process.platform === 'win32') {
execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"');
}
}
export function checkAccessibilityPermission(): boolean {
if (process.platform !== 'darwin') return true;
return systemPreferences.isTrustedAccessibilityClient(true);
}

View file

@ -1,16 +1,22 @@
import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron';
import path from 'path';
import { IPC_CHANNELS } from '../ipc/channels';
import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './platform';
import { getServerPort } from './server';
const SHORTCUT = 'CommandOrControl+Option+S';
let quickAskWindow: BrowserWindow | null = null;
let pendingText = '';
let pendingMode = '';
let sourceApp = '';
let savedClipboard = '';
function hideQuickAsk(): void {
function destroyQuickAsk(): void {
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
quickAskWindow.hide();
quickAskWindow.close();
}
quickAskWindow = null;
pendingMode = '';
}
function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } {
@ -23,16 +29,11 @@ function clampToScreen(x: number, y: number, w: number, h: number): { x: number;
}
function createQuickAskWindow(x: number, y: number): BrowserWindow {
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
quickAskWindow.setPosition(x, y);
quickAskWindow.show();
quickAskWindow.focus();
return quickAskWindow;
}
destroyQuickAsk();
quickAskWindow = new BrowserWindow({
width: 450,
height: 550,
height: 750,
x,
y,
...(process.platform === 'darwin'
@ -58,7 +59,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
});
quickAskWindow.webContents.on('before-input-event', (_event, input) => {
if (input.key === 'Escape') hideQuickAsk();
if (input.key === 'Escape') destroyQuickAsk();
});
quickAskWindow.webContents.setWindowOpenHandler(({ url }) => {
@ -78,17 +79,20 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
export function registerQuickAsk(): void {
const ok = globalShortcut.register(SHORTCUT, () => {
if (quickAskWindow && !quickAskWindow.isDestroyed() && quickAskWindow.isVisible()) {
hideQuickAsk();
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
destroyQuickAsk();
return;
}
const text = clipboard.readText().trim();
sourceApp = getFrontmostApp();
savedClipboard = clipboard.readText();
const text = savedClipboard.trim();
if (!text) return;
pendingText = text;
const cursor = screen.getCursorScreenPoint();
const pos = clampToScreen(cursor.x, cursor.y, 450, 550);
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
createQuickAskWindow(pos.x, pos.y);
});
@ -101,6 +105,35 @@ export function registerQuickAsk(): void {
pendingText = '';
return text;
});
ipcMain.handle(IPC_CHANNELS.SET_QUICK_ASK_MODE, (_event, mode: string) => {
pendingMode = mode;
});
ipcMain.handle(IPC_CHANNELS.GET_QUICK_ASK_MODE, (event) => {
if (quickAskWindow && !quickAskWindow.isDestroyed() && event.sender.id === quickAskWindow.webContents.id) {
return pendingMode;
}
return '';
});
ipcMain.handle(IPC_CHANNELS.REPLACE_TEXT, async (_event, text: string) => {
if (!sourceApp) return;
if (!checkAccessibilityPermission()) return;
clipboard.writeText(text);
destroyQuickAsk();
try {
await new Promise((r) => setTimeout(r, 50));
simulatePaste();
await new Promise((r) => setTimeout(r, 100));
clipboard.writeText(savedClipboard);
} catch {
clipboard.writeText(savedClipboard);
}
});
}
export function unregisterQuickAsk(): void {

View file

@ -18,4 +18,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
};
},
getQuickAskText: () => ipcRenderer.invoke(IPC_CHANNELS.QUICK_ASK_TEXT),
setQuickAskMode: (mode: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_QUICK_ASK_MODE, mode),
getQuickAskMode: () => ipcRenderer.invoke(IPC_CHANNELS.GET_QUICK_ASK_MODE),
replaceText: (text: string) => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_TEXT, text),
});

View file

@ -1537,4 +1537,4 @@ export default function NewChatPage() {
</div>
</AssistantRuntimeProvider>
);
}
}

View file

@ -0,0 +1,228 @@
"use client";
import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
interface PromptFormData {
name: string;
prompt: string;
mode: "transform" | "explore";
}
const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform" };
export function PromptsContent() {
const [prompts, setPrompts] = useState<PromptRead[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<PromptFormData>(EMPTY_FORM);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
promptsApiService
.list()
.then(setPrompts)
.catch(() => toast.error("Failed to load prompts"))
.finally(() => setIsLoading(false));
}, []);
const handleSave = useCallback(async () => {
if (!formData.name.trim() || !formData.prompt.trim()) {
toast.error("Name and prompt are required");
return;
}
setIsSaving(true);
try {
if (editingId) {
const updated = await promptsApiService.update(editingId, formData);
setPrompts((prev) => prev.map((p) => (p.id === editingId ? updated : p)));
toast.success("Prompt updated");
} else {
const created = await promptsApiService.create(formData);
setPrompts((prev) => [created, ...prev]);
toast.success("Prompt created");
}
setShowForm(false);
setFormData(EMPTY_FORM);
setEditingId(null);
} catch {
toast.error("Failed to save prompt");
} finally {
setIsSaving(false);
}
}, [formData, editingId]);
const handleEdit = useCallback((prompt: PromptRead) => {
setFormData({
name: prompt.name,
prompt: prompt.prompt,
mode: prompt.mode as "transform" | "explore",
});
setEditingId(prompt.id);
setShowForm(true);
}, []);
const handleDelete = useCallback(async (id: number) => {
try {
await promptsApiService.delete(id);
setPrompts((prev) => prev.filter((p) => p.id !== id));
toast.success("Prompt deleted");
} catch {
toast.error("Failed to delete prompt");
}
}, []);
const handleCancel = useCallback(() => {
setShowForm(false);
setFormData(EMPTY_FORM);
setEditingId(null);
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner className="size-6" />
</div>
);
}
return (
<div className="space-y-6 min-w-0 overflow-hidden">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Create prompt templates triggered with <kbd className="rounded border bg-muted px-1.5 py-0.5 text-xs font-mono">/</kbd> in the chat composer.
</p>
{!showForm && (
<Button
size="sm"
onClick={() => {
setShowForm(true);
setEditingId(null);
setFormData(EMPTY_FORM);
}}
className="shrink-0 gap-1.5"
>
<Plus className="size-3.5" />
New
</Button>
)}
</div>
{showForm && (
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4">
<h3 className="text-sm font-semibold tracking-tight">
{editingId ? "Edit prompt" : "New prompt"}
</h3>
<div className="space-y-2">
<Label htmlFor="prompt-name">Name</Label>
<Input
id="prompt-name"
value={formData.name}
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
placeholder="e.g. Fix grammar"
/>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-template">Prompt template</Label>
<textarea
id="prompt-template"
value={formData.prompt}
onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))}
placeholder="e.g. Fix the grammar in the following text:\n\n{selection}"
rows={4}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Use <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">{"{selection}"}</code> to insert the input text. If omitted, the text is appended automatically.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="prompt-mode">Mode</Label>
<select
id="prompt-mode"
value={formData.mode}
onChange={(e) => setFormData((p) => ({ ...p, mode: e.target.value as "transform" | "explore" }))}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
>
<option value="transform">Transform rewrites or modifies your text</option>
<option value="explore">Explore answers a question about your text</option>
</select>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? <Spinner className="size-3.5" /> : editingId ? "Update" : "Create"}
</Button>
</div>
</div>
)}
{prompts.length === 0 && !showForm && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<Sparkles className="mx-auto size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No custom prompts yet</p>
<p className="text-xs text-muted-foreground/60">
Create prompts to quickly transform or explore text with /
</p>
</div>
)}
{prompts.length > 0 && (
<div className="space-y-2">
{prompts.map((prompt) => (
<div
key={prompt.id}
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4"
>
<div className="mt-0.5 shrink-0 text-muted-foreground">
<Sparkles className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{prompt.name}</span>
<span className="rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
{prompt.mode}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{prompt.prompt}</p>
</div>
<div className="hidden group-hover:flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => handleEdit(prompt)}
>
<PenLine className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(prompt.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -3,7 +3,7 @@
import { useAtomValue } from "jotai";
import { AlertCircle, Plus, Search } from "lucide-react";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
@ -89,6 +89,7 @@ function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
export default function DashboardPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const t = useTranslations("dashboard");
@ -98,9 +99,11 @@ export default function DashboardPage() {
if (isLoading) return;
if (searchSpaces.length > 0) {
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`);
const params = searchParams.toString();
const query = params ? `?${params}` : "";
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat${query}`);
}
}, [isLoading, searchSpaces, router]);
}, [isLoading, searchSpaces, router, searchParams]);
// Show loading while fetching or while we have spaces and are about to redirect
const shouldShowLoading = isLoading || searchSpaces.length > 0;

View file

@ -3,12 +3,13 @@ import {
AuiIf,
ErrorPrimitive,
MessagePrimitive,
useAui,
useAuiState,
} from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
@ -278,6 +279,17 @@ export const AssistantMessage: FC = () => {
const AssistantActionBar: FC = () => {
const isLast = useAuiState((s) => s.message.isLast);
const aui = useAui();
const [quickAskMode, setQuickAskMode] = useState("");
useEffect(() => {
if (!isLast || !window.electronAPI?.getQuickAskMode) return;
window.electronAPI.getQuickAskMode().then((mode) => {
if (mode) setQuickAskMode(mode);
});
}, [isLast]);
const isTransform = isLast && !!window.electronAPI?.replaceText && quickAskMode === "transform";
return (
<ActionBarPrimitive.Root
@ -301,7 +313,6 @@ const AssistantActionBar: FC = () => {
<DownloadIcon />
</TooltipIconButton>
</ActionBarPrimitive.ExportMarkdown>
{/* Only allow regenerating the last assistant message */}
{isLast && (
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
@ -309,6 +320,19 @@ const AssistantActionBar: FC = () => {
</TooltipIconButton>
</ActionBarPrimitive.Reload>
)}
{isTransform && (
<button
type="button"
onClick={() => {
const text = aui.message().getCopyText();
window.electronAPI?.replaceText(text);
}}
className="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<ClipboardPaste className="size-3.5" />
Paste back
</button>
)}
</ActionBarPrimitive.Root>
);
};

View file

@ -40,6 +40,8 @@ interface InlineMentionEditorProps {
placeholder?: string;
onMentionTrigger?: (query: string) => void;
onMentionClose?: () => void;
onActionTrigger?: (query: string) => void;
onActionClose?: () => void;
onSubmit?: () => void;
onChange?: (text: string, docs: MentionedDocument[]) => void;
onDocumentRemove?: (docId: number, docType?: string) => void;
@ -90,6 +92,8 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
placeholder = "Type @ to mention documents...",
onMentionTrigger,
onMentionClose,
onActionTrigger,
onActionClose,
onSubmit,
onChange,
onDocumentRemove,
@ -119,13 +123,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
useEffect(() => {
if (!initialText || !editorRef.current) return;
// Insert the text and add trailing line breaks for typing space
editorRef.current.innerText = initialText;
editorRef.current.appendChild(document.createElement("br"));
editorRef.current.appendChild(document.createElement("br"));
setIsEmpty(false);
onChange?.(initialText, Array.from(mentionedDocs.values()));
// Place cursor at the end of the content
editorRef.current.focus();
const sel = window.getSelection();
const range = document.createRange();
@ -133,7 +135,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
// Scroll to cursor via a temporary anchor element
const anchor = document.createElement("span");
range.insertNode(anchor);
anchor.scrollIntoView({ block: "end" });
@ -520,6 +521,39 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
}
}
// Check for / actions (same pattern as @)
let shouldTriggerAction = false;
let actionQuery = "";
if (!shouldTriggerMention && selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType === Node.TEXT_NODE) {
const textContent = textNode.textContent || "";
const cursorPos = range.startOffset;
let slashIndex = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (textContent[i] === "/") {
slashIndex = i;
break;
}
if (textContent[i] === " " || textContent[i] === "\n") {
break;
}
}
if (slashIndex !== -1 && (slashIndex === 0 || textContent[slashIndex - 1] === " " || textContent[slashIndex - 1] === "\n")) {
const query = textContent.slice(slashIndex + 1, cursorPos);
if (!query.startsWith(" ")) {
shouldTriggerAction = true;
actionQuery = query;
}
}
}
}
// If no @ found before cursor, check if text contains @ at all
// If text is empty or doesn't contain @, close the mention
if (!shouldTriggerMention) {
@ -533,9 +567,15 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
onMentionTrigger?.(mentionQuery);
}
if (!shouldTriggerAction) {
onActionClose?.();
} else {
onActionTrigger?.(actionQuery);
}
// Notify parent of change
onChange?.(text, Array.from(mentionedDocs.values()));
}, [getText, mentionedDocs, onChange, onMentionTrigger, onMentionClose]);
}, [getText, mentionedDocs, onChange, onMentionTrigger, onMentionClose, onActionTrigger, onActionClose]);
// Handle keydown
const handleKeyDown = useCallback(

View file

@ -12,6 +12,9 @@ import {
AlertCircle,
ArrowDownIcon,
ArrowUpIcon,
ChevronDown,
ChevronUp,
Clipboard,
Globe,
Plus,
Settings2,
@ -58,6 +61,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 { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
import {
DocumentMentionPicker,
type DocumentMentionPickerRef,
@ -295,15 +299,56 @@ const ConnectToolsBanner: FC<{ isThreadEmpty: boolean }> = ({ isThreadEmpty }) =
);
};
const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDismiss }) => {
const [expanded, setExpanded] = useState(false);
const isLong = text.length > 120;
const preview = isLong ? `${text.slice(0, 120)}` : text;
return (
<div className="mx-3 mt-2 rounded-lg border border-border/40 bg-background/60">
<div className="flex items-center gap-2 px-3 py-2">
<Clipboard className="size-4 shrink-0 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">From clipboard</span>
<div className="flex-1" />
{isLong && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? <ChevronUp className="size-3.5" /> : <ChevronDown className="size-3.5" />}
</button>
)}
<button
type="button"
onClick={onDismiss}
className="flex items-center text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-3.5" />
</button>
</div>
<div className="px-3 pb-2">
<p className="text-xs text-foreground/80 whitespace-pre-wrap wrap-break-word leading-relaxed">
{expanded ? text : preview}
</p>
</div>
</div>
);
};
const Composer: FC = () => {
// Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = 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 composerBoxRef = useRef<HTMLDivElement>(null);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const promptPickerRef = useRef<PromptPickerRef>(null);
const { search_space_id, chat_id } = useParams();
const aui = useAui();
const threadViewportStore = useThreadViewportStore();
@ -314,10 +359,16 @@ const Composer: FC = () => {
return () => { submitCleanupRef.current?.(); };
}, []);
const [quickAskText, setQuickAskText] = useState<string | undefined>();
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false);
useEffect(() => {
window.electronAPI?.getQuickAskText().then((text) => {
if (text) setQuickAskText(text);
if (!window.electronAPI || clipboardLoadedRef.current) return;
clipboardLoadedRef.current = true;
window.electronAPI.getQuickAskText().then((text) => {
if (text) {
setClipboardInitialText(text);
setShowPromptPicker(true);
}
});
}, []);
@ -424,9 +475,86 @@ const Composer: FC = () => {
}
}, [showDocumentPopover]);
// Keyboard navigation for document picker (arrow keys, Enter, Escape)
// Open action picker when / is triggered
const handleActionTrigger = useCallback((query: string) => {
setShowPromptPicker(true);
setActionQuery(query);
}, []);
// Close action picker and reset query
const handleActionClose = useCallback(() => {
if (showPromptPicker) {
setShowPromptPicker(false);
setActionQuery("");
}
}, [showPromptPicker]);
const handleActionSelect = useCallback(
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
let userText = editorRef.current?.getText() ?? "";
const trigger = `/${actionQuery}`;
if (userText.endsWith(trigger)) {
userText = userText.slice(0, -trigger.length).trimEnd();
}
const finalPrompt = action.prompt.includes("{selection}")
? action.prompt.replace("{selection}", () => userText)
: userText ? `${action.prompt}\n\n${userText}` : action.prompt;
aui.composer().setText(finalPrompt);
aui.composer().send();
editorRef.current?.clear();
setShowPromptPicker(false);
setActionQuery("");
setMentionedDocuments([]);
setSidebarDocs([]);
},
[actionQuery, aui, setMentionedDocuments, setSidebarDocs]
);
const handleQuickAskSelect = useCallback(
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
if (!clipboardInitialText) return;
window.electronAPI?.setQuickAskMode(action.mode);
const finalPrompt = action.prompt.includes("{selection}")
? action.prompt.replace("{selection}", () => clipboardInitialText)
: `${action.prompt}\n\n${clipboardInitialText}`;
aui.composer().setText(finalPrompt);
aui.composer().send();
editorRef.current?.clear();
setShowPromptPicker(false);
setActionQuery("");
setClipboardInitialText(undefined);
setMentionedDocuments([]);
setSidebarDocs([]);
},
[clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs]
);
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (showPromptPicker) {
if (e.key === "ArrowDown") {
e.preventDefault();
promptPickerRef.current?.moveDown();
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
promptPickerRef.current?.moveUp();
return;
}
if (e.key === "Enter") {
e.preventDefault();
promptPickerRef.current?.selectHighlighted();
return;
}
if (e.key === "Escape") {
e.preventDefault();
setShowPromptPicker(false);
setActionQuery("");
return;
}
}
if (showDocumentPopover) {
if (e.key === "ArrowDown") {
e.preventDefault();
@ -451,11 +579,28 @@ const Composer: FC = () => {
}
}
},
[showDocumentPopover]
[showDocumentPopover, showPromptPicker]
);
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
const handleSubmit = useCallback(() => {
if (isThreadRunning || isBlockedByOtherUser) {
return;
}
if (!showDocumentPopover && !showPromptPicker) {
if (clipboardInitialText) {
const userText = editorRef.current?.getText() ?? "";
const combined = userText
? `${userText}\n\n${clipboardInitialText}`
: clipboardInitialText;
aui.composer().setText(combined);
setClipboardInitialText(undefined);
}
aui.composer().send();
editorRef.current?.clear();
setMentionedDocuments([]);
setSidebarDocs([]);
}
if (isThreadRunning || isBlockedByOtherUser) return;
if (showDocumentPopover) return;
@ -515,8 +660,10 @@ const Composer: FC = () => {
};
}, [
showDocumentPopover,
showPromptPicker,
isThreadRunning,
isBlockedByOtherUser,
clipboardInitialText,
aui,
setMentionedDocuments,
setSidebarDocs,
@ -557,14 +704,23 @@ const Composer: FC = () => {
);
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
<ComposerPrimitive.Root
className="aui-composer-root relative flex w-full flex-col gap-2"
style={(showPromptPicker && clipboardInitialText) ? { marginBottom: 220 } : undefined}
>
<ChatSessionStatus
isAiResponding={isAiResponding}
respondingToUserId={respondingToUserId}
currentUserId={currentUser?.id ?? null}
members={members ?? []}
/>
<div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
<div ref={composerBoxRef} className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
{clipboardInitialText && (
<ClipboardChip
text={clipboardInitialText}
onDismiss={() => setClipboardInitialText(undefined)}
/>
)}
{/* Inline editor with @mention support */}
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
<InlineMentionEditor
@ -572,11 +728,12 @@ const Composer: FC = () => {
placeholder={currentPlaceholder}
onMentionTrigger={handleMentionTrigger}
onMentionClose={handleMentionClose}
onActionTrigger={handleActionTrigger}
onActionClose={handleActionClose}
onChange={handleEditorChange}
onDocumentRemove={handleDocumentRemove}
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
initialText={quickAskText}
className="min-h-[24px]"
/>
</div>
@ -605,6 +762,33 @@ const Composer: FC = () => {
/>,
document.body
)}
{showPromptPicker &&
typeof document !== "undefined" &&
createPortal(
<PromptPicker
ref={promptPickerRef}
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
onDone={() => {
setShowPromptPicker(false);
setActionQuery("");
}}
externalSearch={actionQuery}
containerStyle={{
position: "fixed",
...(clipboardInitialText && composerBoxRef.current
? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` }
: { bottom: editorContainerRef.current
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
: "200px" }
),
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
zIndex: 50,
}}
/>,
document.body
)}
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
<ConnectorIndicator showTrigger={false} />
<ConnectToolsBanner isThreadEmpty={isThreadEmpty} />

View file

@ -0,0 +1,225 @@
"use client";
import {
BookOpen,
Check,
Globe,
Languages,
List,
Minimize2,
PenLine,
Search,
Zap,
Plus,
} from "lucide-react";
import { useSetAtom } from "jotai";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
import { cn } from "@/lib/utils";
export interface PromptPickerRef {
selectHighlighted: () => void;
moveUp: () => void;
moveDown: () => void;
}
interface PromptPickerProps {
onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void;
onDone: () => void;
externalSearch?: string;
containerStyle?: React.CSSProperties;
}
const ICONS: Record<string, React.ReactNode> = {
check: <Check className="size-3.5" />,
minimize: <Minimize2 className="size-3.5" />,
languages: <Languages className="size-3.5" />,
"pen-line": <PenLine className="size-3.5" />,
"book-open": <BookOpen className="size-3.5" />,
list: <List className="size-3.5" />,
search: <Search className="size-3.5" />,
globe: <Globe className="size-3.5" />,
zap: <Zap className="size-3.5" />,
};
const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "explore"; icon: string }[] = [
{ 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" },
{ 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" },
{ 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" },
{ 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" },
{ name: "Summarize", prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", mode: "transform", icon: "list" },
{ name: "Explain", prompt: "Explain the following text in simple terms:\n\n{selection}", mode: "explore", icon: "book-open" },
{ name: "Ask my knowledge base", prompt: "Search my knowledge base for information related to:\n\n{selection}", mode: "explore", icon: "search" },
{ name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" },
];
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
function PromptPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) {
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
useEffect(() => {
promptsApiService.list().then(setCustomPrompts).catch(() => {});
}, []);
const allActions = useMemo(() => {
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];
}, [customPrompts]);
const filtered = useMemo(() => {
if (!externalSearch) return allActions;
return allActions.filter((a) =>
a.name.toLowerCase().includes(externalSearch.toLowerCase())
);
}, [allActions, externalSearch]);
// Reset highlight when results change
const prevSearchRef = useRef(externalSearch);
if (prevSearchRef.current !== externalSearch) {
prevSearchRef.current = externalSearch;
if (highlightedIndex !== 0) {
setHighlightedIndex(0);
}
}
const handleSelect = useCallback(
(index: number) => {
const action = filtered[index];
if (!action) return;
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
},
[filtered, onSelect]
);
// Auto-scroll highlighted item into view
useEffect(() => {
if (!shouldScrollRef.current) return;
shouldScrollRef.current = false;
const rafId = requestAnimationFrame(() => {
const item = itemRefs.current.get(highlightedIndex);
const container = scrollContainerRef.current;
if (item && container) {
const itemRect = item.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
item.scrollIntoView({ block: "nearest" });
}
}
});
return () => cancelAnimationFrame(rafId);
}, [highlightedIndex]);
useImperativeHandle(
ref,
() => ({
selectHighlighted: () => handleSelect(highlightedIndex),
moveUp: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
},
moveDown: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
},
}),
[filtered.length, highlightedIndex, handleSelect]
);
if (filtered.length === 0) return null;
const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length);
const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length);
return (
<div
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle}
>
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{defaultFiltered.map((action, index) => (
<button
key={action.name}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
<span className="text-muted-foreground">{ICONS[action.icon] ?? <Zap className="size-3.5" />}</span>
<span className="truncate">{action.name}</span>
</button>
))}
{customFiltered.length > 0 && (
<div className="my-1 h-px bg-border mx-2" />
)}
{customFiltered.map((action, i) => {
const index = defaultFiltered.length + i;
return (
<button
key={action.name}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
<span className="text-muted-foreground"><Zap className="size-3.5" /></span>
<span className="truncate">{action.name}</span>
</button>
);
})}
<div className="my-1 h-px bg-border mx-2" />
<button
type="button"
onClick={() => {
onDone();
setUserSettingsDialog({ open: true, initialTab: "prompts" });
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer"
>
<Plus className="size-3.5" />
<span>Create prompt</span>
</button>
</div>
</div>
);
}
);

View file

@ -1,10 +1,11 @@
"use client";
import { useAtom } from "jotai";
import { KeyRound, User } from "lucide-react";
import { KeyRound, Sparkles, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { SettingsDialog } from "@/components/settings/settings-dialog";
@ -19,6 +20,11 @@ export function UserSettingsDialog() {
label: t("api_key_nav_label"),
icon: <KeyRound className="h-4 w-4" />,
},
{
value: "prompts",
label: "My Prompts",
icon: <Sparkles className="h-4 w-4" />,
},
];
return (
@ -33,6 +39,7 @@ export function UserSettingsDialog() {
<div className="pt-4">
{state.initialTab === "profile" && <ProfileContent />}
{state.initialTab === "api-key" && <ApiKeyContent />}
{state.initialTab === "prompts" && <PromptsContent />}
</div>
</SettingsDialog>
);

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

@ -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

@ -11,6 +11,9 @@ interface ElectronAPI {
getAppVersion: () => Promise<string>;
onDeepLink: (callback: (url: string) => void) => () => void;
getQuickAskText: () => Promise<string>;
setQuickAskMode: (mode: string) => Promise<void>;
getQuickAskMode: () => Promise<string>;
replaceText: (text: string) => Promise<void>;
}
declare global {