feat: persistent AI memory — chat history + knowledge store
agent-chat/route.ts: - Loads conversation history from chat_conversations before each turn - Passes history + knowledge context to agent runner - Saves returned history back to chat_conversations after each turn - Saves AI-generated memory updates to fs_knowledge_items knowledge/route.ts (new): - GET /api/projects/[id]/knowledge — list all knowledge items - POST /api/projects/[id]/knowledge — add/update item by key - DELETE /api/projects/[id]/knowledge?id=xxx — remove item OrchestratorChat.tsx: - Added "Saved to memory" label for save_memory tool calls Made-with: Cursor
This commit is contained in:
@@ -5,6 +5,84 @@ import { query } from "@/lib/db-postgres";
|
|||||||
|
|
||||||
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers — chat_conversations + fs_knowledge_items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadConversation(projectId: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const rows = await query<{ messages: any[] }>(
|
||||||
|
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
return rows[0]?.messages ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConversation(projectId: string, messages: any[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO chat_conversations (project_id, messages, updated_at)
|
||||||
|
VALUES ($1, $2::jsonb, NOW())
|
||||||
|
ON CONFLICT (project_id) DO UPDATE
|
||||||
|
SET messages = $2::jsonb, updated_at = NOW()`,
|
||||||
|
[projectId, JSON.stringify(messages)]
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[agent-chat] Failed to save conversation:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKnowledge(projectId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const rows = await query<{ data: any }>(
|
||||||
|
`SELECT data FROM fs_knowledge_items WHERE project_id = $1 ORDER BY updated_at DESC LIMIT 50`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return "";
|
||||||
|
return rows
|
||||||
|
.map((r) => `[${r.data.type ?? "note"}] ${r.data.key}: ${r.data.value}`)
|
||||||
|
.join("\n");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMemoryUpdates(
|
||||||
|
projectId: string,
|
||||||
|
updates: Array<{ key: string; type: string; value: string }>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!updates?.length) return;
|
||||||
|
try {
|
||||||
|
for (const u of updates) {
|
||||||
|
// Upsert by project_id + key
|
||||||
|
const existing = await query<{ id: string }>(
|
||||||
|
`SELECT id FROM fs_knowledge_items WHERE project_id = $1 AND data->>'key' = $2 LIMIT 1`,
|
||||||
|
[projectId, u.key]
|
||||||
|
);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_knowledge_items SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`,
|
||||||
|
[JSON.stringify({ key: u.key, type: u.type, value: u.value, source: "ai" }), existing[0].id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO fs_knowledge_items (project_id, data) VALUES ($1, $2::jsonb)`,
|
||||||
|
[projectId, JSON.stringify({ key: u.key, type: u.type, value: u.value, source: "ai" })]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[agent-chat] Failed to save memory updates:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST — send a message
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
@@ -21,7 +99,7 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: '"message" is required' }, { status: 400 });
|
return NextResponse.json({ error: '"message" is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load project context to inject into the orchestrator session
|
// Load project context
|
||||||
let projectContext = "";
|
let projectContext = "";
|
||||||
try {
|
try {
|
||||||
const rows = await query<{ data: any }>(
|
const rows = await query<{ data: any }>(
|
||||||
@@ -40,15 +118,20 @@ export async function POST(
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
projectContext = lines.join("\n");
|
projectContext = lines.join("\n");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch { /* non-fatal */ }
|
||||||
// Non-fatal — orchestrator still works without extra context
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use projectId as the session ID so each project has its own conversation
|
|
||||||
const sessionId = `project_${projectId}`;
|
const sessionId = `project_${projectId}`;
|
||||||
|
|
||||||
// First message in a new session? Prepend project context
|
// Load persistent conversation history and knowledge from DB
|
||||||
const enrichedMessage = projectContext
|
const [history, knowledgeContext] = await Promise.all([
|
||||||
|
loadConversation(projectId),
|
||||||
|
loadKnowledge(projectId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Enrich user message with project context on the very first message
|
||||||
|
const isFirstMessage = history.length === 0;
|
||||||
|
const enrichedMessage =
|
||||||
|
isFirstMessage && projectContext
|
||||||
? `[Project context]\n${projectContext}\n\n[User message]\n${message}`
|
? `[Project context]\n${projectContext}\n\n[User message]\n${message}`
|
||||||
: message;
|
: message;
|
||||||
|
|
||||||
@@ -56,8 +139,13 @@ export async function POST(
|
|||||||
const res = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, {
|
const res = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ message: enrichedMessage, session_id: sessionId }),
|
body: JSON.stringify({
|
||||||
signal: AbortSignal.timeout(120_000), // 2 min — agents can take time
|
message: enrichedMessage,
|
||||||
|
session_id: sessionId,
|
||||||
|
history,
|
||||||
|
knowledge_context: knowledgeContext || undefined,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(120_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -69,6 +157,13 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Persist conversation and any memory updates the AI generated
|
||||||
|
await Promise.all([
|
||||||
|
saveConversation(projectId, data.history ?? []),
|
||||||
|
saveMemoryUpdates(projectId, data.memoryUpdates ?? []),
|
||||||
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
reply: data.reply,
|
reply: data.reply,
|
||||||
reasoning: data.reasoning ?? null,
|
reasoning: data.reasoning ?? null,
|
||||||
@@ -76,6 +171,7 @@ export async function POST(
|
|||||||
turns: data.turns ?? 0,
|
turns: data.turns ?? 0,
|
||||||
model: data.model || null,
|
model: data.model || null,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
memoryUpdates: data.memoryUpdates ?? [],
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -86,7 +182,10 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear session for this project
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE — clear session + conversation history
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
@@ -99,13 +198,12 @@ export async function DELETE(
|
|||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const sessionId = `project_${projectId}`;
|
const sessionId = `project_${projectId}`;
|
||||||
|
|
||||||
try {
|
await Promise.all([
|
||||||
await fetch(`${AGENT_RUNNER_URL}/orchestrator/sessions/${sessionId}`, {
|
// Clear in-memory session from agent runner
|
||||||
method: "DELETE",
|
fetch(`${AGENT_RUNNER_URL}/orchestrator/sessions/${sessionId}`, { method: "DELETE" }).catch(() => {}),
|
||||||
});
|
// Clear persisted conversation from DB
|
||||||
} catch {
|
query(`DELETE FROM chat_conversations WHERE project_id = $1`, [projectId]).catch(() => {}),
|
||||||
// Best-effort
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ cleared: true });
|
return NextResponse.json({ cleared: true });
|
||||||
}
|
}
|
||||||
|
|||||||
103
app/api/projects/[projectId]/knowledge/route.ts
Normal file
103
app/api/projects/[projectId]/knowledge/route.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth/next";
|
||||||
|
import { authOptions } from "@/lib/auth/authOptions";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
async function assertOwnership(projectId: string, email: string): Promise<boolean> {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT p.id FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, email]
|
||||||
|
);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/projects/[projectId]/knowledge
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { projectId } = await params;
|
||||||
|
if (!(await assertOwnership(projectId, session.user.email))) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query<{ id: string; data: any; updated_at: string }>(
|
||||||
|
`SELECT id, data, updated_at FROM fs_knowledge_items WHERE project_id = $1 ORDER BY updated_at DESC`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
items: rows.map((r) => ({ id: r.id, ...r.data, updatedAt: r.updated_at })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/projects/[projectId]/knowledge — add or update an item
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { projectId } = await params;
|
||||||
|
if (!(await assertOwnership(projectId, session.user.email))) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { key, type, value } = await req.json();
|
||||||
|
if (!key || !value) {
|
||||||
|
return NextResponse.json({ error: "key and value are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemType = type ?? "note";
|
||||||
|
const data = JSON.stringify({ key, type: itemType, value, source: "user" });
|
||||||
|
|
||||||
|
// Upsert by key
|
||||||
|
const existing = await query<{ id: string }>(
|
||||||
|
`SELECT id FROM fs_knowledge_items WHERE project_id = $1 AND data->>'key' = $2 LIMIT 1`,
|
||||||
|
[projectId, key]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_knowledge_items SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`,
|
||||||
|
[data, existing[0].id]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ id: existing[0].id, key, type: itemType, value, updated: true });
|
||||||
|
} else {
|
||||||
|
const rows = await query<{ id: string }>(
|
||||||
|
`INSERT INTO fs_knowledge_items (project_id, data) VALUES ($1, $2::jsonb) RETURNING id`,
|
||||||
|
[projectId, data]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ id: rows[0].id, key, type: itemType, value, created: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/projects/[projectId]/knowledge?id=xxx
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { projectId } = await params;
|
||||||
|
if (!(await assertOwnership(projectId, session.user.email))) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = req.nextUrl.searchParams.get("id");
|
||||||
|
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 });
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM fs_knowledge_items WHERE id = $1 AND project_id = $2`,
|
||||||
|
[id, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ deleted: true });
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ const TOOL_LABELS: Record<string, string> = {
|
|||||||
gitea_list_issues: "Listed issues",
|
gitea_list_issues: "Listed issues",
|
||||||
gitea_close_issue: "Closed issue",
|
gitea_close_issue: "Closed issue",
|
||||||
gitea_comment_issue: "Added comment",
|
gitea_comment_issue: "Added comment",
|
||||||
|
save_memory: "Saved to memory",
|
||||||
};
|
};
|
||||||
|
|
||||||
function friendlyToolName(raw: string): string {
|
function friendlyToolName(raw: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user