mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 05:42:39 +02:00
Merge commit '3d74cca88e' into dev_mod
This commit is contained in:
commit
461192174d
20 changed files with 1142 additions and 34 deletions
50
surfsense_backend/alembic/versions/111_add_prompts_table.py
Normal file
50
surfsense_backend/alembic/versions/111_add_prompts_table.py
Normal 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")
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
94
surfsense_backend/app/routes/prompts_routes.py
Normal file
94
surfsense_backend/app/routes/prompts_routes.py
Normal 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}
|
||||
31
surfsense_backend/app/schemas/prompts.py
Normal file
31
surfsense_backend/app/schemas/prompts.py
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
55
surfsense_desktop/src/modules/platform.ts
Normal file
55
surfsense_desktop/src/modules/platform.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1537,4 +1537,4 @@ export default function NewChatPage() {
|
|||
</div>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
225
surfsense_web/components/new-chat/prompt-picker.tsx
Normal file
225
surfsense_web/components/new-chat/prompt-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
40
surfsense_web/contracts/types/prompts.types.ts
Normal file
40
surfsense_web/contracts/types/prompts.types.ts
Normal 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(),
|
||||
});
|
||||
54
surfsense_web/lib/apis/prompts-api.service.ts
Normal file
54
surfsense_web/lib/apis/prompts-api.service.ts
Normal 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();
|
||||
3
surfsense_web/types/window.d.ts
vendored
3
surfsense_web/types/window.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue