feat(33-03): real AI streaming with memory injection + assistant handoff
Replace streamEcho with Puter proxy AI call, inject memory facts as system message, append memory after each turn. Assistant-to-PM handoff creates new conversation with context summary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2dcb24b9ce
commit
222d00c57f
6 changed files with 356 additions and 18 deletions
193
server/src/__tests__/33-assistant-handoff.test.ts
Normal file
193
server/src/__tests__/33-assistant-handoff.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
// [nexus] Tests for assistant handoff route (Plan 33-03)
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock chatService so we can track calls without a real DB
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGetConversation = vi.fn();
|
||||||
|
const mockListMessages = vi.fn();
|
||||||
|
const mockCreateConversation = vi.fn();
|
||||||
|
const mockAddSystemMessage = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../services/chat.js", () => ({
|
||||||
|
chatService: () => ({
|
||||||
|
getConversation: mockGetConversation,
|
||||||
|
listMessages: mockListMessages,
|
||||||
|
createConversation: mockCreateConversation,
|
||||||
|
addSystemMessage: mockAddSystemMessage,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock authz — assertBoard is a no-op in tests
|
||||||
|
vi.mock("../routes/authz.js", () => ({
|
||||||
|
assertBoard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import the route factory under test
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const { buildHandoffSummary, assistantHandoffRoutes } = await import("../routes/assistant-handoff.js");
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Unit tests for buildHandoffSummary (pure function)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("buildHandoffSummary", () => {
|
||||||
|
it("concatenates user messages into a summary", () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: "user", content: "Hello world" },
|
||||||
|
{ role: "assistant", content: "Hi there" },
|
||||||
|
{ role: "user", content: "Can you build a login page?" },
|
||||||
|
];
|
||||||
|
const summary = buildHandoffSummary(messages as Array<{ role: string; content: string }>);
|
||||||
|
expect(summary).toContain("Hello world");
|
||||||
|
expect(summary).toContain("Can you build a login page?");
|
||||||
|
expect(summary).not.toContain("Hi there"); // assistant message excluded
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes non-user messages (assistant, system)", () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: "system", content: "You are a helpful assistant" },
|
||||||
|
{ role: "user", content: "Build me a dashboard" },
|
||||||
|
{ role: "assistant", content: "Sure!" },
|
||||||
|
];
|
||||||
|
const summary = buildHandoffSummary(messages as Array<{ role: string; content: string }>);
|
||||||
|
expect(summary).toContain("Build me a dashboard");
|
||||||
|
expect(summary).not.toContain("You are a helpful assistant");
|
||||||
|
expect(summary).not.toContain("Sure!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps summary at 1500 chars", () => {
|
||||||
|
const longContent = "A".repeat(600);
|
||||||
|
const messages = [
|
||||||
|
{ role: "user", content: longContent },
|
||||||
|
{ role: "user", content: longContent },
|
||||||
|
{ role: "user", content: longContent },
|
||||||
|
];
|
||||||
|
const summary = buildHandoffSummary(messages as Array<{ role: string; content: string }>);
|
||||||
|
expect(summary.length).toBeLessThanOrEqual(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string if no user messages", () => {
|
||||||
|
const messages = [
|
||||||
|
{ role: "assistant", content: "Hello!" },
|
||||||
|
{ role: "system", content: "Context here" },
|
||||||
|
];
|
||||||
|
const summary = buildHandoffSummary(messages as Array<{ role: string; content: string }>);
|
||||||
|
expect(summary).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Integration-style tests for the route handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("POST /conversations/:id/assistant-handoff", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockGetConversation.mockResolvedValue({
|
||||||
|
id: "conv-123",
|
||||||
|
companyId: "company-abc",
|
||||||
|
agentId: null,
|
||||||
|
title: "Test conversation",
|
||||||
|
});
|
||||||
|
|
||||||
|
mockListMessages.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{ role: "user", content: "I want to build a todo app" },
|
||||||
|
{ role: "assistant", content: "Great idea! Let me help." },
|
||||||
|
{ role: "user", content: "It should have drag-and-drop" },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCreateConversation.mockResolvedValue({
|
||||||
|
id: "new-conv-999",
|
||||||
|
companyId: "company-abc",
|
||||||
|
title: "Project from assistant chat",
|
||||||
|
agentId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAddSystemMessage.mockResolvedValue({
|
||||||
|
id: "msg-handoff-1",
|
||||||
|
role: "system",
|
||||||
|
content: "summary",
|
||||||
|
messageType: "handoff_context",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new conversation with handoff_context system message", async () => {
|
||||||
|
const router = assistantHandoffRoutes({} as any);
|
||||||
|
|
||||||
|
// Find the POST handler for /conversations/:id/assistant-handoff
|
||||||
|
const layer = (router as any).stack.find(
|
||||||
|
(l: any) => l.route?.path === "/conversations/:id/assistant-handoff" && l.route?.methods?.post,
|
||||||
|
);
|
||||||
|
expect(layer).toBeDefined();
|
||||||
|
|
||||||
|
const handler = layer.route.stack[0].handle;
|
||||||
|
const req = { params: { id: "conv-123" }, actor: { type: "board" } } as any;
|
||||||
|
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() } as any;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(mockGetConversation).toHaveBeenCalledWith("conv-123");
|
||||||
|
expect(mockListMessages).toHaveBeenCalledWith("conv-123", { limit: 20 });
|
||||||
|
expect(mockCreateConversation).toHaveBeenCalledWith(
|
||||||
|
"company-abc",
|
||||||
|
expect.objectContaining({ title: "Project from assistant chat" }),
|
||||||
|
);
|
||||||
|
expect(mockAddSystemMessage).toHaveBeenCalledWith(
|
||||||
|
"new-conv-999",
|
||||||
|
expect.objectContaining({ messageType: "handoff_context" }),
|
||||||
|
);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ targetConversationId: "new-conv-999" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds summary from user messages only", async () => {
|
||||||
|
const router = assistantHandoffRoutes({} as any);
|
||||||
|
const layer = (router as any).stack.find(
|
||||||
|
(l: any) => l.route?.path === "/conversations/:id/assistant-handoff" && l.route?.methods?.post,
|
||||||
|
);
|
||||||
|
const handler = layer.route.stack[0].handle;
|
||||||
|
const req = { params: { id: "conv-123" }, actor: { type: "board" } } as any;
|
||||||
|
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() } as any;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
const callArgs = mockAddSystemMessage.mock.calls[0];
|
||||||
|
const systemMessageContent = callArgs[1].content;
|
||||||
|
expect(systemMessageContent).toContain("I want to build a todo app");
|
||||||
|
expect(systemMessageContent).toContain("It should have drag-and-drop");
|
||||||
|
expect(systemMessageContent).not.toContain("Great idea! Let me help.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps summary content at 1500 chars in the system message", async () => {
|
||||||
|
const longContent = "X".repeat(600);
|
||||||
|
mockListMessages.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{ role: "user", content: longContent },
|
||||||
|
{ role: "user", content: longContent },
|
||||||
|
{ role: "user", content: longContent },
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = assistantHandoffRoutes({} as any);
|
||||||
|
const layer = (router as any).stack.find(
|
||||||
|
(l: any) => l.route?.path === "/conversations/:id/assistant-handoff" && l.route?.methods?.post,
|
||||||
|
);
|
||||||
|
const handler = layer.route.stack[0].handle;
|
||||||
|
const req = { params: { id: "conv-123" }, actor: { type: "board" } } as any;
|
||||||
|
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() } as any;
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
const callArgs = mockAddSystemMessage.mock.calls[0];
|
||||||
|
expect(callArgs[1].content.length).toBeLessThanOrEqual(1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -29,6 +29,7 @@ import { llmRoutes } from "./routes/llms.js";
|
||||||
import { assetRoutes } from "./routes/assets.js";
|
import { assetRoutes } from "./routes/assets.js";
|
||||||
import { accessRoutes } from "./routes/access.js";
|
import { accessRoutes } from "./routes/access.js";
|
||||||
import { assistantMemoryRoutes } from "./routes/assistant-memory.js";
|
import { assistantMemoryRoutes } from "./routes/assistant-memory.js";
|
||||||
|
import { assistantHandoffRoutes } from "./routes/assistant-handoff.js";
|
||||||
import { pluginRoutes } from "./routes/plugins.js";
|
import { pluginRoutes } from "./routes/plugins.js";
|
||||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||||
import { applyUiBranding } from "./ui-branding.js";
|
import { applyUiBranding } from "./ui-branding.js";
|
||||||
|
|
@ -168,6 +169,7 @@ export async function createApp(
|
||||||
api.use(sidebarBadgeRoutes(db));
|
api.use(sidebarBadgeRoutes(db));
|
||||||
api.use(instanceSettingsRoutes(db));
|
api.use(instanceSettingsRoutes(db));
|
||||||
api.use(assistantMemoryRoutes());
|
api.use(assistantMemoryRoutes());
|
||||||
|
api.use(assistantHandoffRoutes(db));
|
||||||
const hostServicesDisposers = new Map<string, () => void>();
|
const hostServicesDisposers = new Map<string, () => void>();
|
||||||
const workerManager = createPluginWorkerManager();
|
const workerManager = createPluginWorkerManager();
|
||||||
const pluginRegistry = pluginRegistryService(db);
|
const pluginRegistry = pluginRegistryService(db);
|
||||||
|
|
|
||||||
57
server/src/routes/assistant-handoff.ts
Normal file
57
server/src/routes/assistant-handoff.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// [nexus] Assistant-to-PM handoff route (Plan 33-03)
|
||||||
|
import { Router } from "express";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { assertBoard } from "./authz.js";
|
||||||
|
import { chatService } from "../services/chat.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a summary string from user messages only, capped at 1500 chars.
|
||||||
|
* Exported for unit testing.
|
||||||
|
*/
|
||||||
|
export function buildHandoffSummary(
|
||||||
|
messages: Array<{ role: string; content: string }>,
|
||||||
|
): string {
|
||||||
|
const userMessages = messages
|
||||||
|
.filter((m) => m.role === "user")
|
||||||
|
.map((m) => m.content)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return userMessages.slice(0, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assistantHandoffRoutes(db: Db): Router {
|
||||||
|
const router = Router();
|
||||||
|
const svc = chatService(db);
|
||||||
|
|
||||||
|
// POST /api/conversations/:id/assistant-handoff
|
||||||
|
// Creates a new PM conversation with a handoff_context system message
|
||||||
|
// containing a summary of user messages from the source conversation.
|
||||||
|
router.post("/conversations/:id/assistant-handoff", async (req, res) => {
|
||||||
|
assertBoard(req);
|
||||||
|
|
||||||
|
// 1. Get source conversation
|
||||||
|
const conversation = await svc.getConversation(req.params.id!);
|
||||||
|
|
||||||
|
// 2. Fetch last 20 messages
|
||||||
|
const msgs = await svc.listMessages(req.params.id!, { limit: 20 });
|
||||||
|
|
||||||
|
// 3. Build summary from user messages only, capped at 1500 chars
|
||||||
|
const summary = buildHandoffSummary(msgs.items);
|
||||||
|
|
||||||
|
// 4. Create new PM conversation (generic — PM agent wiring is out of scope here)
|
||||||
|
const newConv = await svc.createConversation(conversation.companyId, {
|
||||||
|
title: "Project from assistant chat",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Insert handoff_context system message
|
||||||
|
await svc.addSystemMessage(newConv.id, {
|
||||||
|
content: summary,
|
||||||
|
messageType: "handoff_context",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Return target conversation ID
|
||||||
|
res.json({ targetConversationId: newConv.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,9 @@ import { assertBoard, assertCompanyAccess } from "./authz.js";
|
||||||
import { chatService } from "../services/chat.js";
|
import { chatService } from "../services/chat.js";
|
||||||
import { sendPushToAll } from "../services/pushService.js";
|
import { sendPushToAll } from "../services/pushService.js";
|
||||||
import { issueService } from "../services/issues.js";
|
import { issueService } from "../services/issues.js";
|
||||||
|
import { assistantMemoryService } from "../services/assistant-memory.js";
|
||||||
|
import { nexusSettingsService } from "../services/nexus-settings.js";
|
||||||
|
import { puterProxyService } from "../services/puter-proxy.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
createConversationSchema,
|
createConversationSchema,
|
||||||
|
|
@ -93,6 +96,49 @@ export function chatRoutes(db: Db): Router {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve conversation and settings BEFORE flushing headers (Pitfall 3)
|
||||||
|
const conversation = await svc.getConversation(req.params.id!);
|
||||||
|
const settings = await nexusSettingsService().get();
|
||||||
|
const isAssistant = settings.mode !== "project_builder";
|
||||||
|
|
||||||
|
// Load memory facts if in assistant mode
|
||||||
|
const memory = isAssistant
|
||||||
|
? await assistantMemoryService().get(conversation.companyId)
|
||||||
|
: { facts: [] as string[], updatedAt: null };
|
||||||
|
|
||||||
|
// Try resolving puter token — fall back to echo if not configured
|
||||||
|
let puterTokenAvailable = false;
|
||||||
|
try {
|
||||||
|
await puterProxyService(db).resolveToken(conversation.companyId);
|
||||||
|
puterTokenAvailable = true;
|
||||||
|
} catch {
|
||||||
|
// No puter token — will fall back to echo stub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build messages array for AI call
|
||||||
|
const recentMsgs = await svc.listMessages(req.params.id!, { limit: 20 });
|
||||||
|
// listMessages returns newest-first; reverse for chronological order
|
||||||
|
const chronological = [...recentMsgs.items].reverse();
|
||||||
|
|
||||||
|
const messagesWithMemory: Array<{ role: string; content: string }> = [];
|
||||||
|
|
||||||
|
// Inject memory as system message if applicable
|
||||||
|
if (isAssistant && memory.facts.length > 0) {
|
||||||
|
const memoryText = `[Memory from previous sessions]\n${memory.facts.map((f) => "- " + f).join("\n")}\n\nUse these facts to personalize your responses. Do not mention that you have a memory system unless asked.`;
|
||||||
|
const capped = memoryText.slice(0, 2000);
|
||||||
|
messagesWithMemory.push({ role: "system", content: capped });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add recent conversation history
|
||||||
|
for (const msg of chronological) {
|
||||||
|
if (msg.role === "user" || msg.role === "assistant") {
|
||||||
|
messagesWithMemory.push({ role: msg.role, content: msg.content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new user message
|
||||||
|
messagesWithMemory.push({ role: "user", content });
|
||||||
|
|
||||||
// Set SSE headers and flush BEFORE any generation (PERF-02)
|
// Set SSE headers and flush BEFORE any generation (PERF-02)
|
||||||
res.setHeader("Content-Type", "text/event-stream");
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
|
@ -106,10 +152,16 @@ export function chatRoutes(db: Db): Router {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let fullContent = "";
|
let fullContent = "";
|
||||||
for await (const token of svc.streamEcho(content, abort.signal)) {
|
|
||||||
|
// Choose stream source: real AI or echo fallback
|
||||||
|
const tokenStream = puterTokenAvailable
|
||||||
|
? puterProxyService(db).chatStream(conversation.companyId, agentId || undefined, messagesWithMemory, undefined, abort.signal)
|
||||||
|
: svc.streamEcho(content, abort.signal);
|
||||||
|
|
||||||
|
for await (const token of tokenStream) {
|
||||||
if (!res.writable) break;
|
if (!res.writable) break;
|
||||||
fullContent += token;
|
fullContent += token;
|
||||||
res.write(`data: ${JSON.stringify({ token })}\n\n`);
|
res.write(`data: ${JSON.stringify({ type: "token", token })}\n\n`);
|
||||||
}
|
}
|
||||||
if (res.writable && !abort.signal.aborted) {
|
if (res.writable && !abort.signal.aborted) {
|
||||||
const message = await svc.addMessage(req.params.id!, {
|
const message = await svc.addMessage(req.params.id!, {
|
||||||
|
|
@ -117,19 +169,24 @@ export function chatRoutes(db: Db): Router {
|
||||||
content: fullContent.trim(),
|
content: fullContent.trim(),
|
||||||
agentId: agentId || undefined,
|
agentId: agentId || undefined,
|
||||||
});
|
});
|
||||||
res.write(`data: ${JSON.stringify({ done: true, messageId: message.id, content: fullContent.trim() })}\n\n`);
|
res.write(`data: ${JSON.stringify({ type: "done", messageId: message.id, content: fullContent.trim() })}\n\n`);
|
||||||
|
|
||||||
// Fire push notification for offline subscribers (PWA-06)
|
// Fire push notification for offline subscribers (PWA-06)
|
||||||
const conversation = await svc.getConversation(req.params.id!);
|
|
||||||
sendPushToAll(db, conversation.companyId, {
|
sendPushToAll(db, conversation.companyId, {
|
||||||
title: "New agent response",
|
title: "New agent response",
|
||||||
body: fullContent.trim().slice(0, 100),
|
body: fullContent.trim().slice(0, 100),
|
||||||
data: { url: `/chat/${conversation.id}` },
|
data: { url: `/chat/${conversation.id}` },
|
||||||
}).catch(() => {}); // non-blocking
|
}).catch(() => {}); // non-blocking
|
||||||
|
|
||||||
|
// Append a brief memory fact after each assistant turn (non-blocking)
|
||||||
|
if (isAssistant) {
|
||||||
|
const fact = `User asked about: ${content.slice(0, 100)}. Assistant topic: ${fullContent.slice(0, 100)}`;
|
||||||
|
assistantMemoryService().append(conversation.companyId, fact).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (res.writable && !abort.signal.aborted) {
|
if (res.writable && !abort.signal.aborted) {
|
||||||
res.write(`data: ${JSON.stringify({ error: "Stream error" })}\n\n`);
|
res.write(`data: ${JSON.stringify({ type: "error", error: "Stream error" })}\n\n`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
res.end();
|
res.end();
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,13 @@ export const chatApi = {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
assistantHandoff(conversationId: string): Promise<{ targetConversationId: string }> {
|
||||||
|
return api.post<{ targetConversationId: string }>(
|
||||||
|
`/conversations/${conversationId}/assistant-handoff`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
exportConversation(conversationId: string, format: "markdown" | "json") {
|
exportConversation(conversationId: string, format: "markdown" | "json") {
|
||||||
// Returns a download URL — use window.location.href to trigger
|
// Returns a download URL — use window.location.href to trigger
|
||||||
return `/api/conversations/${conversationId}/export?format=${format}`;
|
return `/api/conversations/${conversationId}/export?format=${format}`;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
// [nexus] Personal Assistant page — full-page chat for Personal AI mode
|
// [nexus] Personal Assistant page — full-page chat for Personal AI mode
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Navigate, useParams } from "@/lib/router";
|
import { Navigate, useParams, useNavigate } from "@/lib/router";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bot, Send, Loader2, Plus, ArrowRight } from "lucide-react";
|
import { Bot, Send, Loader2, Plus, ArrowRight } from "lucide-react";
|
||||||
import { useNexusMode } from "../hooks/useNexusMode";
|
import { useNexusMode } from "../hooks/useNexusMode";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
import { chatApi } from "../api/chat";
|
import { chatApi } from "../api/chat";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
|
||||||
import type { ChatConversationListItem, ChatMessage } from "@paperclipai/shared";
|
import type { ChatConversationListItem, ChatMessage } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
|
||||||
// ─── Conversation list panel ─────────────────────────────────────────────────
|
// ─── Conversation list panel ─────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ConversationListProps {
|
interface ConversationListProps {
|
||||||
|
|
@ -97,12 +98,15 @@ export function PersonalAssistant() {
|
||||||
const { selectedCompany } = useCompany();
|
const { selectedCompany } = useCompany();
|
||||||
const { conversationId: routeConvId } = useParams<{ conversationId?: string }>();
|
const { conversationId: routeConvId } = useParams<{ conversationId?: string }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pushToast } = useToast();
|
||||||
|
|
||||||
const [selectedConvId, setSelectedConvId] = useState<string | null>(routeConvId ?? null);
|
const [selectedConvId, setSelectedConvId] = useState<string | null>(routeConvId ?? null);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [streamingContent, setStreamingContent] = useState<string | null>(null);
|
const [streamingContent, setStreamingContent] = useState<string | null>(null);
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const [isHandingOff, setIsHandingOff] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
@ -215,6 +219,20 @@ export function PersonalAssistant() {
|
||||||
}
|
}
|
||||||
}, [inputValue, selectedConvId, isSending, queryClient, companyId]);
|
}, [inputValue, selectedConvId, isSending, queryClient, companyId]);
|
||||||
|
|
||||||
|
const handleHandoff = useCallback(async () => {
|
||||||
|
if (!selectedConvId || isHandingOff) return;
|
||||||
|
setIsHandingOff(true);
|
||||||
|
try {
|
||||||
|
await chatApi.assistantHandoff(selectedConvId);
|
||||||
|
pushToast({ title: "Conversation handed off to PM", tone: "positive" });
|
||||||
|
navigate("/dashboard");
|
||||||
|
} catch {
|
||||||
|
pushToast({ title: "Handoff failed", body: "Could not create project conversation.", tone: "critical" });
|
||||||
|
} finally {
|
||||||
|
setIsHandingOff(false);
|
||||||
|
}
|
||||||
|
}, [selectedConvId, isHandingOff, navigate, pushToast]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
|
@ -257,17 +275,21 @@ export function PersonalAssistant() {
|
||||||
<Bot className="h-5 w-5 text-primary" />
|
<Bot className="h-5 w-5 text-primary" />
|
||||||
<h1 className="text-base font-semibold text-foreground">Personal Assistant</h1>
|
<h1 className="text-base font-semibold text-foreground">Personal Assistant</h1>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="outline"
|
||||||
<span>
|
size="sm"
|
||||||
<Button variant="outline" size="sm" disabled className="gap-2 opacity-60 cursor-not-allowed">
|
className="gap-2"
|
||||||
<ArrowRight className="h-4 w-4" />
|
onClick={handleHandoff}
|
||||||
Turn into project
|
disabled={!selectedConvId || isHandingOff}
|
||||||
</Button>
|
title="Turn this conversation into a project"
|
||||||
</span>
|
>
|
||||||
</TooltipTrigger>
|
{isHandingOff ? (
|
||||||
<TooltipContent>Coming soon — will create a project from this conversation</TooltipContent>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
</Tooltip>
|
) : (
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Turn into project
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue