session fixed

This commit is contained in:
qixinbo
2026-03-14 23:15:41 +08:00
parent 101a1e03cb
commit 860998de09
4 changed files with 367 additions and 30 deletions
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
class SessionAliasStore:
def __init__(self) -> None:
backend_root = Path(__file__).resolve().parents[2]
data_dir = backend_root / "data"
data_dir.mkdir(parents=True, exist_ok=True)
self.db_path = data_dir / "nanobot_sessions.db"
self._init_db()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS session_cache (
session_key TEXT PRIMARY KEY,
created_at TEXT,
updated_at TEXT,
alias TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
last_seen_at TEXT NOT NULL
)
"""
)
cols = {
str(row["name"])
for row in conn.execute("PRAGMA table_info(session_cache)").fetchall()
}
if "pinned" not in cols:
conn.execute("ALTER TABLE session_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
if "archived" not in cols:
conn.execute("ALTER TABLE session_cache ADD COLUMN archived INTEGER NOT NULL DEFAULT 0")
def sync_sessions(self, sessions: list[dict[str, Any]]) -> None:
now = datetime.now(timezone.utc).isoformat()
keys: list[str] = []
with self._connect() as conn:
for item in sessions:
key = str(item.get("key") or "").strip()
if not key:
continue
keys.append(key)
created_at = str(item.get("created_at") or "")
updated_at = str(item.get("updated_at") or "")
conn.execute(
"""
INSERT INTO session_cache (session_key, created_at, updated_at, last_seen_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_key) DO UPDATE SET
created_at = excluded.created_at,
updated_at = excluded.updated_at,
last_seen_at = excluded.last_seen_at
""",
(key, created_at, updated_at, now),
)
if keys:
placeholders = ",".join("?" for _ in keys)
conn.execute(
f"DELETE FROM session_cache WHERE session_key NOT IN ({placeholders})",
keys,
)
else:
conn.execute("DELETE FROM session_cache")
def list_cached_sessions(self) -> list[dict[str, Any]]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT session_key, created_at, updated_at, alias, pinned, archived
FROM session_cache
ORDER BY pinned DESC, archived ASC, updated_at DESC
"""
).fetchall()
return [self._row_to_session_item(row) for row in rows]
def sync_and_list(self, sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
self.sync_sessions(sessions)
return self.list_cached_sessions()
def set_alias(self, session_key: str, alias: str) -> None:
now = datetime.now(timezone.utc).isoformat()
clean_alias = alias.strip()
with self._connect() as conn:
conn.execute(
"""
INSERT INTO session_cache (session_key, created_at, updated_at, alias, last_seen_at)
VALUES (?, '', '', ?, ?)
ON CONFLICT(session_key) DO UPDATE SET
alias = excluded.alias,
last_seen_at = excluded.last_seen_at
""",
(session_key, clean_alias, now),
)
def update_alias_meta(
self,
session_key: str,
alias: str | None = None,
pinned: bool | None = None,
archived: bool | None = None,
) -> dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
with self._connect() as conn:
row = conn.execute(
"SELECT alias, pinned, archived FROM session_cache WHERE session_key = ?",
(session_key,),
).fetchone()
current_alias = (str(row["alias"]) if row and row["alias"] else "")
current_pinned = bool(row["pinned"]) if row else False
current_archived = bool(row["archived"]) if row else False
next_alias = current_alias if alias is None else alias.strip()
next_pinned = current_pinned if pinned is None else bool(pinned)
next_archived = current_archived if archived is None else bool(archived)
conn.execute(
"""
INSERT INTO session_cache (session_key, created_at, updated_at, alias, pinned, archived, last_seen_at)
VALUES (?, '', '', ?, ?, ?, ?)
ON CONFLICT(session_key) DO UPDATE SET
alias = excluded.alias,
pinned = excluded.pinned,
archived = excluded.archived,
last_seen_at = excluded.last_seen_at
""",
(session_key, next_alias, int(next_pinned), int(next_archived), now),
)
return {"alias": next_alias or None, "pinned": next_pinned, "archived": next_archived}
def get_alias(self, session_key: str) -> str | None:
with self._connect() as conn:
row = conn.execute(
"SELECT alias FROM session_cache WHERE session_key = ?",
(session_key,),
).fetchone()
if not row:
return None
alias = row["alias"]
return str(alias) if alias else None
def delete_session(self, session_key: str) -> None:
with self._connect() as conn:
conn.execute("DELETE FROM session_cache WHERE session_key = ?", (session_key,))
def _row_to_session_item(self, row: sqlite3.Row) -> dict[str, Any]:
alias = (row["alias"] or "").strip()
fallback = str(row["session_key"]).replace("api:", "")
title = alias or fallback
pinned = bool(row["pinned"]) if "pinned" in row.keys() else False
archived = bool(row["archived"]) if "archived" in row.keys() else False
return {
"key": row["session_key"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"metadata": {"title": title},
"alias": alias or None,
"pinned": pinned,
"archived": archived,
}
session_alias_store = SessionAliasStore()
+27 -13
View File
@@ -1,5 +1,5 @@
from typing import List, Optional from typing import List, Optional
from fastapi import FastAPI, HTTPException, Body from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
@@ -11,6 +11,7 @@ from app.connectors.postgres import postgres_connector
from app.connectors.clickhouse import clickhouse_connector from app.connectors.clickhouse import clickhouse_connector
from app.connectors.minio import minio_connector from app.connectors.minio import minio_connector
from app.core.nanobot import nanobot_service from app.core.nanobot import nanobot_service
from app.core.session_alias_store import session_alias_store
from app.agent.nl2sql import process_nl2sql, NL2SQLRequest, NL2SQLResponse from app.agent.nl2sql import process_nl2sql, NL2SQLRequest, NL2SQLResponse
app = FastAPI() app = FastAPI()
@@ -74,10 +75,21 @@ class ChatRequest(BaseModel):
skill_ids: Optional[List[str]] = None skill_ids: Optional[List[str]] = None
model_id: Optional[str] = None model_id: Optional[str] = None
class SessionAliasUpdateRequest(BaseModel):
title: Optional[str] = None
pinned: Optional[bool] = None
archived: Optional[bool] = None
@app.post("/nanobot/chat") @app.post("/nanobot/chat")
async def nanobot_chat(request: ChatRequest): async def nanobot_chat(request: ChatRequest):
try: try:
response = await nanobot_service.process_message(request.message, skill_ids=request.skill_ids, model_id=request.model_id) response = await nanobot_service.process_message(
request.message,
session_id=request.session_id,
skill_ids=request.skill_ids,
model_id=request.model_id,
)
return {"response": response} return {"response": response}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -115,21 +127,22 @@ async def nanobot_chat_stream(request: ChatRequest):
@app.get("/nanobot/sessions") @app.get("/nanobot/sessions")
def get_sessions(): def get_sessions():
if not nanobot_service.agent: if not nanobot_service.agent:
return [] return session_alias_store.list_cached_sessions()
# session_manager has list_sessions()
sessions = nanobot_service.agent.sessions.list_sessions() sessions = nanobot_service.agent.sessions.list_sessions()
return sessions return session_alias_store.sync_and_list(sessions)
@app.get("/nanobot/sessions/{session_id}") @app.get("/nanobot/sessions/{session_id}")
def get_session(session_id: str): def get_session(session_id: str):
if not nanobot_service.agent: if not nanobot_service.agent:
raise HTTPException(status_code=400, detail="Nanobot not running") raise HTTPException(status_code=400, detail="Nanobot not running")
session = nanobot_service.agent.sessions.get_or_create(session_id) session = nanobot_service.agent.sessions.get_or_create(session_id)
alias = session_alias_store.get_alias(session_id)
return { return {
"key": session.key, "key": session.key,
"created_at": session.created_at, "created_at": session.created_at,
"updated_at": session.updated_at, "updated_at": session.updated_at,
"metadata": session.metadata, "metadata": session.metadata,
"alias": alias,
"messages": session.messages "messages": session.messages
} }
@@ -145,18 +158,19 @@ def delete_session(session_id: str):
path = nanobot_service.agent.sessions._get_session_path(session_id) path = nanobot_service.agent.sessions._get_session_path(session_id)
if path.exists(): if path.exists():
path.unlink() path.unlink()
session_alias_store.delete_session(session_id)
return {"status": "success"} return {"status": "success"}
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
@app.put("/nanobot/sessions/{session_id}") @app.put("/nanobot/sessions/{session_id}")
def update_session(session_id: str, title: str = Body(..., embed=True)): def update_session(session_id: str, payload: SessionAliasUpdateRequest):
if not nanobot_service.agent: updated = session_alias_store.update_alias_meta(
raise HTTPException(status_code=400, detail="Nanobot not running") session_key=session_id,
alias=payload.title,
session = nanobot_service.agent.sessions.get_or_create(session_id) pinned=payload.pinned,
session.metadata["title"] = title archived=payload.archived,
nanobot_service.agent.sessions.save(session) )
return {"status": "success", "title": title} return {"status": "success", **updated}
@app.post("/api/v1/agent/nl2sql", response_model=NL2SQLResponse) @app.post("/api/v1/agent/nl2sql", response_model=NL2SQLResponse)
async def run_nl2sql(request: NL2SQLRequest): async def run_nl2sql(request: NL2SQLRequest):
+33 -9
View File
@@ -17,6 +17,7 @@ interface Message {
id: string; id: string;
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; content: string;
awaitingFirstToken?: boolean;
} }
interface ModelConfig { interface ModelConfig {
@@ -131,10 +132,12 @@ export function ChatInterface() {
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: assistantId, id: assistantId,
role: "assistant", role: "assistant",
content: "" content: "",
awaitingFirstToken: true
}]); }]);
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const effectiveModelId = selectedModelId || currentModel?.id || "";
const response = await fetch("/nanobot/chat/stream", { const response = await fetch("/nanobot/chat/stream", {
method: "POST", method: "POST",
headers: { headers: {
@@ -144,7 +147,7 @@ export function ChatInterface() {
body: JSON.stringify({ body: JSON.stringify({
message: newMessage.content, message: newMessage.content,
session_id: activeSessionKey, session_id: activeSessionKey,
model_id: selectedModelId, model_id: effectiveModelId,
}), }),
}); });
@@ -178,7 +181,7 @@ export function ChatInterface() {
streamedText = `${streamedText}${payload.content}`; streamedText = `${streamedText}${payload.content}`;
setMessages((prev) => setMessages((prev) =>
prev.map((msg) => prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: streamedText } : msg msg.id === assistantId ? { ...msg, content: streamedText, awaitingFirstToken: false } : msg
) )
); );
} }
@@ -187,7 +190,7 @@ export function ChatInterface() {
streamedText = payload.content; streamedText = payload.content;
setMessages((prev) => setMessages((prev) =>
prev.map((msg) => prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: payload.content || "" } : msg msg.id === assistantId ? { ...msg, content: payload.content || "", awaitingFirstToken: false } : msg
) )
); );
} }
@@ -197,6 +200,19 @@ export function ChatInterface() {
} }
} }
} }
if (!streamedText) {
const fallback = await api.post<{ response: string }>("/nanobot/chat", {
message: newMessage.content,
session_id: activeSessionKey,
model_id: effectiveModelId,
});
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: fallback.response || "暂无回复", awaitingFirstToken: false } : msg
)
);
}
} else { } else {
// Fallback to existing NL2SQL or other skills (e.g. for "表格问答" or "深度问数") // Fallback to existing NL2SQL or other skills (e.g. for "表格问答" or "深度问数")
const source = selectedDataSource.split('-')[0]; // postgres-main -> postgres const source = selectedDataSource.split('-')[0]; // postgres-main -> postgres
@@ -235,6 +251,7 @@ export function ChatInterface() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
setVizLoading(false); setVizLoading(false);
window.dispatchEvent(new Event("nanobot:sessions-changed"));
} }
}; };
@@ -372,11 +389,18 @@ export function ChatInterface() {
}`} }`}
> >
{msg.role === "assistant" ? ( {msg.role === "assistant" ? (
<div className="prose prose-sm prose-zinc max-w-none prose-p:leading-normal prose-p:my-2 prose-headings:my-3 prose-ul:my-2 prose-li:my-0.5 prose-pre:bg-zinc-50 prose-pre:text-zinc-800 prose-pre:border prose-pre:border-zinc-200"> msg.awaitingFirstToken && !msg.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}> <div className="flex items-center gap-2 text-zinc-500 text-sm py-1">
{msg.content} <Loader2 className="h-4 w-4 animate-spin" />
</ReactMarkdown> <span>...</span>
</div> </div>
) : (
<div className="prose prose-sm prose-zinc max-w-none prose-p:leading-normal prose-p:my-2 prose-headings:my-3 prose-ul:my-2 prose-li:my-0.5 prose-pre:bg-zinc-50 prose-pre:text-zinc-800 prose-pre:border prose-pre:border-zinc-200">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
{msg.content}
</ReactMarkdown>
</div>
)
) : ( ) : (
msg.content msg.content
)} )}
+134 -8
View File
@@ -1,7 +1,7 @@
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil } from "lucide-react"; import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil, Pin, Archive } from "lucide-react";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom"; import { Link, useNavigate, useLocation } from "react-router-dom";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
@@ -14,6 +14,9 @@ interface SessionInfo {
key: string; key: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
alias?: string | null;
pinned?: boolean;
archived?: boolean;
metadata?: { metadata?: {
title?: string; title?: string;
}; };
@@ -26,6 +29,8 @@ function Section({
onSelect, onSelect,
onDelete, onDelete,
onRename, onRename,
onTogglePinned,
onToggleArchived,
activeKey activeKey
}: { }: {
title: string; title: string;
@@ -34,6 +39,8 @@ function Section({
onSelect: (key: string) => void; onSelect: (key: string) => void;
onDelete: (key: string) => void; onDelete: (key: string) => void;
onRename: (key: string, currentTitle: string) => void; onRename: (key: string, currentTitle: string) => void;
onTogglePinned: (key: string, pinned: boolean) => void;
onToggleArchived: (key: string, archived: boolean) => void;
activeKey: string | null; activeKey: string | null;
}) { }) {
return ( return (
@@ -64,11 +71,64 @@ function Section({
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32"> <DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onRename(item.key, displayTitle); }}> <DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRename(item.key, displayTitle);
}}
onSelect={(e) => {
e.preventDefault();
e.stopPropagation();
onRename(item.key, displayTitle);
}}
>
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
<span></span> <span></span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDelete(item.key); }} className="text-red-600 focus:text-red-600 focus:bg-red-50"> <DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onTogglePinned(item.key, !!item.pinned);
}}
onSelect={(e) => {
e.preventDefault();
e.stopPropagation();
onTogglePinned(item.key, !!item.pinned);
}}
>
<Pin className="mr-2 h-4 w-4" />
<span>{item.pinned ? "取消置顶" : "置顶"}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleArchived(item.key, !!item.archived);
}}
onSelect={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleArchived(item.key, !!item.archived);
}}
>
<Archive className="mr-2 h-4 w-4" />
<span>{item.archived ? "取消归档" : "归档"}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(item.key);
}}
onSelect={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(item.key);
}}
className="text-red-600 focus:text-red-600 focus:bg-red-50"
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
<span></span> <span></span>
</DropdownMenuItem> </DropdownMenuItem>
@@ -110,9 +170,17 @@ function SidebarBody() {
useEffect(() => { useEffect(() => {
fetchSessions(); fetchSessions();
// Set up polling to refresh session list }, [location.pathname, location.search]);
const interval = setInterval(fetchSessions, 5000);
return () => clearInterval(interval); useEffect(() => {
const onFocus = () => fetchSessions();
const onSessionsChanged = () => fetchSessions();
window.addEventListener("focus", onFocus);
window.addEventListener("nanobot:sessions-changed", onSessionsChanged);
return () => {
window.removeEventListener("focus", onFocus);
window.removeEventListener("nanobot:sessions-changed", onSessionsChanged);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -142,11 +210,12 @@ function SidebarBody() {
const handleDeleteSession = async (key: string) => { const handleDeleteSession = async (key: string) => {
if (!window.confirm("确定要删除这个会话吗?")) return; if (!window.confirm("确定要删除这个会话吗?")) return;
try { try {
await api.delete(`/nanobot/sessions/${key}`); await api.delete(`/nanobot/sessions/${encodeURIComponent(key)}`);
if (activeSessionKey === key) { if (activeSessionKey === key) {
navigate("/"); navigate("/");
} }
fetchSessions(); fetchSessions();
window.dispatchEvent(new Event("nanobot:sessions-changed"));
} catch (e) { } catch (e) {
console.error("Failed to delete session", e); console.error("Failed to delete session", e);
} }
@@ -161,14 +230,69 @@ function SidebarBody() {
const handleRename = async () => { const handleRename = async () => {
if (!sessionToRename || !newTitle.trim()) return; if (!sessionToRename || !newTitle.trim()) return;
try { try {
await api.put(`/nanobot/sessions/${sessionToRename.key}`, { title: newTitle.trim() }); const nextTitle = newTitle.trim();
await api.put(`/nanobot/sessions/${encodeURIComponent(sessionToRename.key)}`, { title: nextTitle });
setSessions((prev) =>
prev.map((item) =>
item.key === sessionToRename.key
? { ...item, alias: nextTitle, metadata: { ...(item.metadata || {}), title: nextTitle } }
: item
)
);
setRenameDialogOpen(false); setRenameDialogOpen(false);
fetchSessions(); fetchSessions();
window.dispatchEvent(new Event("nanobot:sessions-changed"));
} catch (e) { } catch (e) {
console.error("Failed to rename session", e); console.error("Failed to rename session", e);
} }
}; };
const handleTogglePinned = async (key: string, pinned: boolean) => {
const nextPinned = !pinned;
try {
await api.put(`/nanobot/sessions/${encodeURIComponent(key)}`, { pinned: nextPinned });
setSessions((prev) =>
prev
.map((item) => (item.key === key ? { ...item, pinned: nextPinned } : item))
.sort((a, b) => {
const ap = a.pinned ? 1 : 0;
const bp = b.pinned ? 1 : 0;
if (bp !== ap) return bp - ap;
const aa = a.archived ? 1 : 0;
const ba = b.archived ? 1 : 0;
if (aa !== ba) return aa - ba;
return (b.updated_at || "").localeCompare(a.updated_at || "");
})
);
window.dispatchEvent(new Event("nanobot:sessions-changed"));
} catch (e) {
console.error("Failed to toggle pinned", e);
}
};
const handleToggleArchived = async (key: string, archived: boolean) => {
const nextArchived = !archived;
try {
await api.put(`/nanobot/sessions/${encodeURIComponent(key)}`, { archived: nextArchived });
setSessions((prev) =>
prev
.map((item) => (item.key === key ? { ...item, archived: nextArchived } : item))
.sort((a, b) => {
const ap = a.pinned ? 1 : 0;
const bp = b.pinned ? 1 : 0;
if (bp !== ap) return bp - ap;
const aa = a.archived ? 1 : 0;
const ba = b.archived ? 1 : 0;
if (aa !== ba) return aa - ba;
return (b.updated_at || "").localeCompare(a.updated_at || "");
})
);
window.dispatchEvent(new Event("nanobot:sessions-changed"));
} catch (e) {
console.error("Failed to toggle archived", e);
}
};
return ( return (
<div className="h-full flex flex-col bg-zinc-50/30 border-r border-zinc-200 relative"> <div className="h-full flex flex-col bg-zinc-50/30 border-r border-zinc-200 relative">
{/* Header */} {/* Header */}
@@ -214,6 +338,8 @@ function SidebarBody() {
onSelect={handleSelectSession} onSelect={handleSelectSession}
onDelete={handleDeleteSession} onDelete={handleDeleteSession}
onRename={openRenameDialog} onRename={openRenameDialog}
onTogglePinned={handleTogglePinned}
onToggleArchived={handleToggleArchived}
activeKey={activeSessionKey} activeKey={activeSessionKey}
/> />
</ScrollArea> </ScrollArea>