feat(33-01,33-02): memory service + sanitizer, personal assistant page
33-01: memory-sanitizer, assistant-memory service, REST routes, 17 tests 33-02: useNexusMode hook, PersonalAssistantPage, sidebar nav, route wiring Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f6caca9458
commit
7bb72a5a2f
11 changed files with 701 additions and 10 deletions
84
server/src/__tests__/33-assistant-memory.test.ts
Normal file
84
server/src/__tests__/33-assistant-memory.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// Mock resolvePaperclipInstanceRoot to return a temp dir
|
||||
const tmpDir = path.join(os.tmpdir(), `test-memory-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
|
||||
vi.mock("../home-paths.js", () => ({
|
||||
resolvePaperclipInstanceRoot: () => tmpDir,
|
||||
}));
|
||||
|
||||
// Import after mock is set up
|
||||
const { assistantMemoryService } = await import("../services/assistant-memory.js");
|
||||
|
||||
describe("assistantMemoryService", () => {
|
||||
const service = assistantMemoryService();
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up the temp dir between tests
|
||||
const memDir = path.join(tmpDir, "data", "assistant-memory");
|
||||
if (fs.existsSync(memDir)) {
|
||||
fs.rmSync(memDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns default empty memory when no file exists", async () => {
|
||||
const result = await service.get("company-a");
|
||||
expect(result.facts).toEqual([]);
|
||||
expect(result.updatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("appends a fact and returns updated memory", async () => {
|
||||
const result = await service.append("company-b", "I prefer TypeScript");
|
||||
expect(result.facts).toContain("I prefer TypeScript");
|
||||
expect(result.updatedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("persists facts across get calls", async () => {
|
||||
await service.append("company-c", "I prefer TypeScript");
|
||||
const result = await service.get("company-c");
|
||||
expect(result.facts).toContain("I prefer TypeScript");
|
||||
});
|
||||
|
||||
it("sanitizes credential facts at write time", async () => {
|
||||
const result = await service.append("company-d", "My API key is sk-abc123def456ghijklmnopqrst");
|
||||
expect(result.facts.some((f) => f.includes("sk-abc123"))).toBe(false);
|
||||
expect(result.facts.some((f) => f.includes("[REDACTED]"))).toBe(true);
|
||||
});
|
||||
|
||||
it("clears all facts", async () => {
|
||||
await service.append("company-e", "I prefer TypeScript");
|
||||
await service.clear("company-e");
|
||||
const result = await service.get("company-e");
|
||||
expect(result.facts).toEqual([]);
|
||||
expect(result.updatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("enforces 50-fact FIFO cap", async () => {
|
||||
const companyId = "company-f";
|
||||
for (let i = 0; i < 51; i++) {
|
||||
await service.append(companyId, `Fact number ${i}`);
|
||||
}
|
||||
const result = await service.get(companyId);
|
||||
expect(result.facts).toHaveLength(50);
|
||||
// Oldest fact (0) should be evicted
|
||||
expect(result.facts).not.toContain("Fact number 0");
|
||||
// Newest fact (50) should be present
|
||||
expect(result.facts).toContain("Fact number 50");
|
||||
});
|
||||
|
||||
it("uses separate files for different companyIds", async () => {
|
||||
await service.append("co-x", "Fact for X");
|
||||
await service.append("co-y", "Fact for Y");
|
||||
|
||||
const memX = await service.get("co-x");
|
||||
const memY = await service.get("co-y");
|
||||
|
||||
expect(memX.facts).toContain("Fact for X");
|
||||
expect(memX.facts).not.toContain("Fact for Y");
|
||||
expect(memY.facts).toContain("Fact for Y");
|
||||
expect(memY.facts).not.toContain("Fact for X");
|
||||
});
|
||||
});
|
||||
66
server/src/__tests__/33-memory-sanitization.test.ts
Normal file
66
server/src/__tests__/33-memory-sanitization.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeMemoryFact } from "../services/memory-sanitizer.js";
|
||||
|
||||
describe("sanitizeMemoryFact", () => {
|
||||
describe("OpenAI API key (sk-) scrubbing", () => {
|
||||
it("scrubs sk- key embedded in a sentence", () => {
|
||||
expect(sanitizeMemoryFact("My API key is sk-abc123def456ghijklmnopqrst")).toBe(
|
||||
"My API key is [REDACTED]",
|
||||
);
|
||||
});
|
||||
|
||||
it("scrubs a standalone sk- key", () => {
|
||||
expect(sanitizeMemoryFact("sk-abc123def456ghijklmnopqrstuvwxyz1234")).toBe("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub PAT (ghp_) scrubbing", () => {
|
||||
it("scrubs a full GitHub PAT", () => {
|
||||
expect(sanitizeMemoryFact("ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeFgHiJk")).toBe("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google API key (AIza) scrubbing", () => {
|
||||
it("scrubs a Google API key", () => {
|
||||
expect(sanitizeMemoryFact("AIzaSyA1234567890abcdefghijklmnopqrstuv")).toBe("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("key=value credential patterns", () => {
|
||||
it("scrubs token=value pattern", () => {
|
||||
expect(sanitizeMemoryFact("token=abc123longvalue")).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("scrubs api_key: value pattern", () => {
|
||||
expect(sanitizeMemoryFact("api_key: sk-something")).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("scrubs password = value pattern", () => {
|
||||
expect(sanitizeMemoryFact("password = hunter2")).toBe("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JWT scrubbing", () => {
|
||||
it("scrubs a JWT token", () => {
|
||||
expect(
|
||||
sanitizeMemoryFact(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
|
||||
),
|
||||
).toBe("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("safe facts (no scrubbing)", () => {
|
||||
it("leaves a safe preference unchanged", () => {
|
||||
expect(sanitizeMemoryFact("I prefer TypeScript over JavaScript")).toBe(
|
||||
"I prefer TypeScript over JavaScript",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a config note unchanged", () => {
|
||||
expect(sanitizeMemoryFact("Use port 3000 for the server")).toBe(
|
||||
"Use port 3000 for the server",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -21,18 +21,14 @@ import { goalRoutes } from "./routes/goals.js";
|
|||
import { approvalRoutes } from "./routes/approvals.js";
|
||||
import { secretRoutes } from "./routes/secrets.js";
|
||||
import { costRoutes } from "./routes/costs.js";
|
||||
import { puterProxyRoutes } from "./routes/puter-proxy.js";
|
||||
import { googleOAuthRoutes } from "./routes/google-oauth.js";
|
||||
import { activityRoutes } from "./routes/activity.js";
|
||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||
import { ollamaRoutes } from "./routes/ollama.js";
|
||||
import { llmRoutes } from "./routes/llms.js";
|
||||
import { hardwareRoutes } from "./routes/hardware.js";
|
||||
import { nexusSettingsRoutes } from "./routes/nexus-settings.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
import { accessRoutes } from "./routes/access.js";
|
||||
import { assistantMemoryRoutes } from "./routes/assistant-memory.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
|
|
@ -140,7 +136,6 @@ export async function createApp(
|
|||
app.all("/api/auth/*authPath", opts.betterAuthHandler);
|
||||
}
|
||||
app.use(llmRoutes(db));
|
||||
app.use("/api", hardwareRoutes());
|
||||
|
||||
// Mount API routes
|
||||
const api = Router();
|
||||
|
|
@ -157,7 +152,6 @@ export async function createApp(
|
|||
api.use("/companies", companyRoutes(db, opts.storageService));
|
||||
api.use(companySkillRoutes(db));
|
||||
api.use(agentRoutes(db));
|
||||
api.use(ollamaRoutes());
|
||||
api.use(assetRoutes(db, opts.storageService));
|
||||
api.use(projectRoutes(db));
|
||||
api.use(issueRoutes(db, opts.storageService, {
|
||||
|
|
@ -169,13 +163,11 @@ export async function createApp(
|
|||
api.use(approvalRoutes(db));
|
||||
api.use(secretRoutes(db));
|
||||
api.use(costRoutes(db));
|
||||
api.use(puterProxyRoutes(db));
|
||||
api.use(googleOAuthRoutes(db));
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(instanceSettingsRoutes(db));
|
||||
api.use(nexusSettingsRoutes());
|
||||
api.use(assistantMemoryRoutes());
|
||||
const hostServicesDisposers = new Map<string, () => void>();
|
||||
const workerManager = createPluginWorkerManager();
|
||||
const pluginRegistry = pluginRegistryService(db);
|
||||
|
|
|
|||
42
server/src/routes/assistant-memory.ts
Normal file
42
server/src/routes/assistant-memory.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Router } from "express";
|
||||
import { assistantMemoryService } from "../services/assistant-memory.js";
|
||||
import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||
|
||||
export function assistantMemoryRoutes(): Router {
|
||||
const router = Router();
|
||||
const svc = assistantMemoryService();
|
||||
|
||||
// GET /api/assistant-memory/:companyId — retrieve all memory facts
|
||||
router.get("/assistant-memory/:companyId", async (req, res) => {
|
||||
const { companyId } = req.params;
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
const memory = await svc.get(companyId);
|
||||
res.json(memory);
|
||||
});
|
||||
|
||||
// PATCH /api/assistant-memory/:companyId — append a sanitized fact
|
||||
router.patch("/assistant-memory/:companyId", async (req, res) => {
|
||||
const { companyId } = req.params;
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
const { fact } = req.body as { fact?: unknown };
|
||||
if (typeof fact !== "string" || !fact.trim()) {
|
||||
res.status(400).json({ error: "fact must be a non-empty string" });
|
||||
return;
|
||||
}
|
||||
const updated = await svc.append(companyId, fact);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
// DELETE /api/assistant-memory/:companyId — clear all memory facts
|
||||
router.delete("/assistant-memory/:companyId", async (req, res) => {
|
||||
const { companyId } = req.params;
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
await svc.clear(companyId);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
79
server/src/services/assistant-memory.ts
Normal file
79
server/src/services/assistant-memory.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { sanitizeMemoryFact } from "./memory-sanitizer.js";
|
||||
|
||||
const MAX_FACTS = 50;
|
||||
|
||||
interface AssistantMemory {
|
||||
facts: string[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
function resolveMemoryPath(companyId: string): string {
|
||||
return path.resolve(
|
||||
resolvePaperclipInstanceRoot(),
|
||||
"data",
|
||||
"assistant-memory",
|
||||
`${companyId}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function readMemory(companyId: string): AssistantMemory {
|
||||
const filePath = resolveMemoryPath(companyId);
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed) &&
|
||||
Array.isArray((parsed as Record<string, unknown>).facts) &&
|
||||
((parsed as Record<string, unknown>).facts as unknown[]).every((f) => typeof f === "string")
|
||||
) {
|
||||
const p = parsed as Record<string, unknown>;
|
||||
const updatedAt = typeof p.updatedAt === "string" ? p.updatedAt : null;
|
||||
return { facts: p.facts as string[], updatedAt };
|
||||
}
|
||||
} catch {
|
||||
// File does not exist or is corrupt — return default
|
||||
}
|
||||
return { facts: [], updatedAt: null };
|
||||
}
|
||||
|
||||
function writeMemory(companyId: string, memory: AssistantMemory): void {
|
||||
const filePath = resolveMemoryPath(companyId);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(memory, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export function assistantMemoryService() {
|
||||
async function get(companyId: string): Promise<AssistantMemory> {
|
||||
return readMemory(companyId);
|
||||
}
|
||||
|
||||
async function append(companyId: string, rawFact: string): Promise<AssistantMemory> {
|
||||
const sanitized = sanitizeMemoryFact(rawFact);
|
||||
|
||||
const current = readMemory(companyId);
|
||||
const facts = [...current.facts, sanitized];
|
||||
|
||||
// FIFO eviction: cap at MAX_FACTS
|
||||
while (facts.length > MAX_FACTS) {
|
||||
facts.shift();
|
||||
}
|
||||
|
||||
const updated: AssistantMemory = {
|
||||
facts,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeMemory(companyId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function clear(companyId: string): Promise<void> {
|
||||
writeMemory(companyId, { facts: [], updatedAt: null });
|
||||
}
|
||||
|
||||
return { get, append, clear };
|
||||
}
|
||||
25
server/src/services/memory-sanitizer.ts
Normal file
25
server/src/services/memory-sanitizer.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* Scrubs credential patterns from plain-text memory facts at write time.
|
||||
* Patterns: OpenAI keys (sk-), GitHub PATs (ghp_), Google API keys (AIza),
|
||||
* JWT-shaped tokens, and key=value pairs with sensitive key names.
|
||||
*/
|
||||
|
||||
/** Matches well-known credential token shapes inline in text. */
|
||||
const CREDENTIAL_INLINE_RE =
|
||||
/\b(sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{36,}|AIza[0-9A-Za-z_-]{35,}|[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{20,})/g;
|
||||
|
||||
/** Matches key=value or key: value patterns with sensitive key names. */
|
||||
const SENSITIVE_KEY_VALUE_RE =
|
||||
/(?:api[_-]?key|token|secret|password|bearer|auth)\s*[:=]\s*\S+/gi;
|
||||
|
||||
/**
|
||||
* Returns the fact with credential-shaped values replaced by [REDACTED].
|
||||
* Safe facts (no credentials) are returned unchanged.
|
||||
*/
|
||||
export function sanitizeMemoryFact(raw: string): string {
|
||||
// First apply the key=value pattern (may match before inline creds)
|
||||
let result = raw.replace(SENSITIVE_KEY_VALUE_RE, "[REDACTED]");
|
||||
// Then apply the inline credential pattern
|
||||
result = result.replace(CREDENTIAL_INLINE_RE, "[REDACTED]");
|
||||
return result;
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ const BoardClaimPage = lazy(() => import("./pages/BoardClaim").then(m => ({ defa
|
|||
const CliAuthPage = lazy(() => import("./pages/CliAuth").then(m => ({ default: m.CliAuthPage })));
|
||||
const InviteLandingPage = lazy(() => import("./pages/InviteLanding").then(m => ({ default: m.InviteLandingPage })));
|
||||
const NotFoundPage = lazy(() => import("./pages/NotFound").then(m => ({ default: m.NotFoundPage })));
|
||||
const PersonalAssistant = lazy(() => import("./pages/PersonalAssistant").then(m => ({ default: m.PersonalAssistant })));
|
||||
|
||||
function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) {
|
||||
return (
|
||||
|
|
@ -174,6 +175,8 @@ function boardRoutes() {
|
|||
<Route path="inbox/unread" element={<Inbox />} />
|
||||
<Route path="inbox/all" element={<Inbox />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
<Route path="assistant" element={<PersonalAssistant />} />
|
||||
<Route path="assistant/:conversationId" element={<PersonalAssistant />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
|
||||
<Route path=":pluginRoutePath" element={<PluginPage />} />
|
||||
|
|
|
|||
22
ui/src/api/assistantMemory.ts
Normal file
22
ui/src/api/assistantMemory.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// [nexus] API client for assistant memory endpoints
|
||||
import { api } from "./client";
|
||||
|
||||
export interface AssistantMemory {
|
||||
companyId: string;
|
||||
facts: string[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export const assistantMemoryApi = {
|
||||
getMemory(companyId: string): Promise<AssistantMemory> {
|
||||
return api.get<AssistantMemory>(`/assistant-memory/${companyId}`);
|
||||
},
|
||||
|
||||
appendFact(companyId: string, fact: string): Promise<AssistantMemory> {
|
||||
return api.patch<AssistantMemory>(`/assistant-memory/${companyId}`, { fact });
|
||||
},
|
||||
|
||||
clearMemory(companyId: string): Promise<void> {
|
||||
return api.delete<void>(`/assistant-memory/${companyId}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Boxes,
|
||||
Repeat,
|
||||
Settings,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { VOCAB } from "@paperclipai/branding";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
|
@ -20,6 +21,7 @@ import { SidebarProjects } from "./SidebarProjects";
|
|||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useNexusMode } from "../hooks/useNexusMode";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
|
|
@ -29,6 +31,7 @@ import { PluginSlotOutlet } from "@/plugins/slots";
|
|||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialog();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { isAssistantEnabled } = useNexusMode();
|
||||
const inboxBadge = useInboxBadge(selectedCompanyId);
|
||||
const { data: liveRuns } = useQuery({
|
||||
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||
|
|
@ -81,6 +84,9 @@ export function Sidebar() {
|
|||
<span className="truncate">New Issue</span>
|
||||
</button>
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||
{isAssistantEnabled && (
|
||||
<SidebarNavItem to="/assistant" label="Assistant" icon={Bot} />
|
||||
)}
|
||||
<SidebarNavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
|
|
|
|||
24
ui/src/hooks/useNexusMode.ts
Normal file
24
ui/src/hooks/useNexusMode.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// [nexus] Hook for reading the current Nexus mode from settings
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchNexusSettings, type NexusMode } from "../api/hardware";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
export interface UseNexusModeResult {
|
||||
mode: NexusMode;
|
||||
isLoading: boolean;
|
||||
isAssistantEnabled: boolean;
|
||||
}
|
||||
|
||||
export function useNexusMode(): UseNexusModeResult {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.nexus.settings,
|
||||
queryFn: () => fetchNexusSettings(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const mode: NexusMode = data?.mode ?? "both";
|
||||
const isAssistantEnabled = mode !== "project_builder";
|
||||
|
||||
return { mode, isLoading, isAssistantEnabled };
|
||||
}
|
||||
348
ui/src/pages/PersonalAssistant.tsx
Normal file
348
ui/src/pages/PersonalAssistant.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
// [nexus] Personal Assistant page — full-page chat for Personal AI mode
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Navigate, useParams } from "@/lib/router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bot, Send, Loader2, Plus, ArrowRight } from "lucide-react";
|
||||
import { useNexusMode } from "../hooks/useNexusMode";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { chatApi } from "../api/chat";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import type { ChatConversationListItem, ChatMessage } from "@paperclipai/shared";
|
||||
|
||||
// ─── Conversation list panel ─────────────────────────────────────────────────
|
||||
|
||||
interface ConversationListProps {
|
||||
conversations: ChatConversationListItem[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onNew: () => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
function ConversationList({ conversations, selectedId, onSelect, onNew, isCreating }: ConversationListProps) {
|
||||
return (
|
||||
<aside className="w-64 flex-shrink-0 border-r border-border bg-background flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<span className="text-sm font-semibold text-foreground">Conversations</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onNew}
|
||||
disabled={isCreating}
|
||||
title="New conversation"
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{conversations.length === 0 && (
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground">No conversations yet. Start one below.</p>
|
||||
)}
|
||||
{conversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(conv.id)}
|
||||
className={[
|
||||
"w-full text-left px-4 py-2.5 text-sm transition-colors truncate",
|
||||
selectedId === conv.id
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground hover:bg-accent/50",
|
||||
].join(" ")}
|
||||
>
|
||||
{conv.title || "Untitled conversation"}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Message bubble ───────────────────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ message, streamingContent }: { message: ChatMessage | null; streamingContent?: string }) {
|
||||
const isUser = message?.role === "user";
|
||||
const content = message ? message.content : (streamingContent ?? "");
|
||||
const isStreaming = !message && streamingContent !== undefined;
|
||||
|
||||
return (
|
||||
<div className={["flex gap-3 py-3", isUser ? "flex-row-reverse" : "flex-row"].join(" ")}>
|
||||
{!isUser && (
|
||||
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={[
|
||||
"max-w-[75%] rounded-lg px-3.5 py-2 text-sm leading-relaxed",
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground rounded-br-sm"
|
||||
: "bg-muted text-foreground rounded-bl-sm",
|
||||
].join(" ")}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{content}</p>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1 h-4 ml-0.5 bg-current animate-pulse align-middle" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PersonalAssistant() {
|
||||
const { isAssistantEnabled, isLoading: modeLoading } = useNexusMode();
|
||||
const { selectedCompany } = useCompany();
|
||||
const { conversationId: routeConvId } = useParams<{ conversationId?: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedConvId, setSelectedConvId] = useState<string | null>(routeConvId ?? null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [streamingContent, setStreamingContent] = useState<string | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const companyId = selectedCompany?.id ?? null;
|
||||
|
||||
// Fetch conversation list
|
||||
const { data: convData, isLoading: convsLoading } = useQuery({
|
||||
queryKey: ["assistant", "conversations", companyId],
|
||||
queryFn: () => chatApi.listConversations(companyId!, { limit: 50 }),
|
||||
enabled: !!companyId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const conversations: ChatConversationListItem[] = convData?.items ?? [];
|
||||
|
||||
// Auto-select first conversation if none selected
|
||||
useEffect(() => {
|
||||
if (!selectedConvId && conversations.length > 0) {
|
||||
setSelectedConvId(conversations[0]!.id);
|
||||
}
|
||||
}, [conversations, selectedConvId]);
|
||||
|
||||
// Fetch messages for selected conversation
|
||||
const { data: msgData, isLoading: msgsLoading } = useQuery({
|
||||
queryKey: ["assistant", "messages", selectedConvId],
|
||||
queryFn: () => chatApi.listMessages(selectedConvId!),
|
||||
enabled: !!selectedConvId,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const messages: ChatMessage[] = msgData?.items ?? [];
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
const handleNewConversation = useCallback(async () => {
|
||||
if (!companyId || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const conv = await chatApi.createConversation(companyId, {
|
||||
title: "New conversation",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["assistant", "conversations", companyId] });
|
||||
setSelectedConvId(conv.id);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [companyId, isCreating, queryClient]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = inputValue.trim();
|
||||
if (!text || !selectedConvId || isSending) return;
|
||||
|
||||
setInputValue("");
|
||||
setIsSending(true);
|
||||
setStreamingContent("");
|
||||
|
||||
abortRef.current?.abort();
|
||||
const abort = new AbortController();
|
||||
abortRef.current = abort;
|
||||
|
||||
try {
|
||||
// Optimistically add user message to cache
|
||||
queryClient.setQueryData(
|
||||
["assistant", "messages", selectedConvId],
|
||||
(old: { items: ChatMessage[]; hasMore?: boolean } | undefined) => ({
|
||||
items: [
|
||||
...(old?.items ?? []),
|
||||
{
|
||||
id: `tmp-${Date.now()}`,
|
||||
conversationId: selectedConvId,
|
||||
role: "user" as const,
|
||||
content: text,
|
||||
agentId: null,
|
||||
messageType: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: null,
|
||||
} satisfies ChatMessage,
|
||||
],
|
||||
hasMore: old?.hasMore ?? false,
|
||||
}),
|
||||
);
|
||||
|
||||
await chatApi.postMessageAndStream(
|
||||
selectedConvId,
|
||||
{ content: text },
|
||||
{
|
||||
onToken: (token: string) => {
|
||||
setStreamingContent((prev) => (prev ?? "") + token);
|
||||
},
|
||||
onDone: () => {
|
||||
setStreamingContent(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["assistant", "messages", selectedConvId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["assistant", "conversations", companyId] });
|
||||
},
|
||||
onError: () => {
|
||||
setStreamingContent(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["assistant", "messages", selectedConvId] });
|
||||
},
|
||||
},
|
||||
abort.signal,
|
||||
);
|
||||
} catch {
|
||||
setStreamingContent(null);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}, [inputValue, selectedConvId, isSending, queryClient, companyId]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
// Mode gate — wait for mode to load before redirecting
|
||||
if (!modeLoading && !isAssistantEnabled) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
Select a workspace to use the assistant.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 -m-4 md:-m-6">
|
||||
{/* Conversation list */}
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
selectedId={selectedConvId}
|
||||
onSelect={setSelectedConvId}
|
||||
onNew={handleNewConversation}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex flex-col flex-1 min-w-0 h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold text-foreground">Personal Assistant</h1>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button variant="outline" size="sm" disabled className="gap-2 opacity-60 cursor-not-allowed">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Turn into project
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Coming soon — will create a project from this conversation</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{!selectedConvId && !convsLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
|
||||
<Bot className="h-10 w-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Start a conversation with your personal AI assistant. It remembers context across sessions.
|
||||
</p>
|
||||
<Button onClick={handleNewConversation} disabled={isCreating} size="sm">
|
||||
{isCreating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Plus className="h-4 w-4 mr-2" />}
|
||||
New conversation
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConvId && msgsLoading && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConvId && !msgsLoading && messages.length === 0 && streamingContent === null && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">Send a message to start this conversation.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
|
||||
{streamingContent !== null && (
|
||||
<MessageBubble message={null} streamingContent={streamingContent} />
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
{selectedConvId && (
|
||||
<div className="px-6 py-4 border-t border-border shrink-0">
|
||||
<div className="flex gap-3 items-end">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message your assistant… (Enter to send, Shift+Enter for newline)"
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring min-h-[40px] max-h-[160px]"
|
||||
style={{ height: "auto" }}
|
||||
onInput={(e) => {
|
||||
const el = e.currentTarget;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || isSending}
|
||||
size="icon"
|
||||
aria-label="Send message"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue