Commit 2445b3f6 authored by Kantz's avatar Kantz
Browse files

save und load von Chats ist möglich

parent 77c29417
...@@ -4,10 +4,10 @@ from typing import List, Optional ...@@ -4,10 +4,10 @@ from typing import List, Optional
import logging import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Path, Query
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.deterministic_services import orchestrator from app.deterministic_services import orchestrator, session_store
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -28,6 +28,24 @@ class ChatResponse(BaseModel): ...@@ -28,6 +28,24 @@ class ChatResponse(BaseModel):
sources: List[str] = [] sources: List[str] = []
class ChatArchiveResponse(BaseModel):
status: str
chat_id: str
class ChatArchiveSummary(BaseModel):
chat_id: str
saved_at: str
message_count: int
preview: str
class ChatArchiveDetail(BaseModel):
chat_id: str
saved_at: str
history: List[ChatMessage]
@router.post("/api/chat", response_model=ChatResponse) @router.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse: def chat(request: ChatRequest) -> ChatResponse:
if not request.messages: if not request.messages:
...@@ -47,3 +65,47 @@ def chat(request: ChatRequest) -> ChatResponse: ...@@ -47,3 +65,47 @@ def chat(request: ChatRequest) -> ChatResponse:
raise HTTPException(status_code=502, detail="chat provider failed") from exc raise HTTPException(status_code=502, detail="chat provider failed") from exc
return ChatResponse(reply=reply, sources=sources) return ChatResponse(reply=reply, sources=sources)
@router.get("/api/chat/archives", response_model=List[ChatArchiveSummary])
def list_archives(limit: int = Query(20, ge=1, le=200)) -> List[ChatArchiveSummary]:
try:
return session_store.list_archives(limit=limit)
except Exception as exc:
logger.exception("Chat archive list failed")
raise HTTPException(status_code=502, detail="chat archive list failed") from exc
@router.get("/api/chat/archive/{chat_id}", response_model=ChatArchiveDetail)
def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
try:
record = session_store.load_archive(chat_id)
except Exception as exc:
logger.exception("Chat archive load failed")
raise HTTPException(status_code=502, detail="chat archive load failed") from exc
if not record:
raise HTTPException(status_code=404, detail="chat archive not found")
return ChatArchiveDetail(
chat_id=record["chat_id"],
saved_at=record.get("saved_at", ""),
history=[ChatMessage(role=item["role"], text=item["text"]) for item in record["history"]],
)
@router.post("/api/chat/archive", response_model=ChatArchiveResponse)
def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
if not request.messages:
return ChatArchiveResponse(status="skipped", chat_id="unknown")
try:
chat_id = session_store.archive_chat(
[{"role": m.role, "content": m.text} for m in request.messages],
draft=request.draft,
)
except Exception as exc:
logger.exception("Chat archive failed")
raise HTTPException(status_code=502, detail="chat archive failed") from exc
return ChatArchiveResponse(status="ok", chat_id=chat_id)
from __future__ import annotations
import json
import os
from datetime import datetime
from threading import Lock
from typing import Any
from collections import deque
from app.deterministic_services import context_store
_LOCK = Lock()
_LOG_DIR = os.path.join("logs", "chat_sessions")
_LOG_PATH = os.path.join(_LOG_DIR, "archive.jsonl")
def _utc_now() -> str:
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> str:
chat_id = context_store.get_chat_id(messages, draft=draft)
sheet = context_store.load_sheet(chat_id)
if not sheet:
sheet = context_store.init_sheet(chat_id, messages)
context_store.update_history(sheet, messages)
context_store.save_sheet(sheet)
record = {
"chat_id": chat_id,
"saved_at": _utc_now(),
"history": sheet.get("history", []),
"context_sheet": context_store.format_sheet(sheet),
"retrieval_contexts": sheet.get("retrieval_contexts", []),
"math_solutions": sheet.get("math_solutions", []),
"sources": sheet.get("sources", []),
}
os.makedirs(_LOG_DIR, exist_ok=True)
payload = json.dumps(record, ensure_ascii=True)
with _LOCK:
with open(_LOG_PATH, "a", encoding="utf-8") as f:
f.write(payload + "\n")
return chat_id
def _summarize_record(record: dict[str, Any]) -> dict[str, Any]:
history = record.get("history", [])
preview = ""
for entry in reversed(history):
if entry.get("role") == "user" and entry.get("content"):
preview = entry["content"][:120]
break
return {
"chat_id": record.get("chat_id", "unknown"),
"saved_at": record.get("saved_at", ""),
"message_count": len(history),
"preview": preview,
}
def list_archives(limit: int = 20) -> list[dict[str, Any]]:
if limit <= 0:
return []
if not os.path.exists(_LOG_PATH):
return []
recent: deque[dict[str, Any]] = deque(maxlen=limit)
with _LOCK:
with open(_LOG_PATH, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
recent.append(json.loads(line))
except json.JSONDecodeError:
continue
return [_summarize_record(item) for item in reversed(recent)]
def load_archive(chat_id: str) -> dict[str, Any] | None:
if not chat_id or not os.path.exists(_LOG_PATH):
return None
with _LOCK:
with open(_LOG_PATH, "r", encoding="utf-8") as f:
lines = f.readlines()
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if record.get("chat_id") == chat_id:
history = [
{"role": entry.get("role", "user"), "text": entry.get("content", "")}
for entry in record.get("history", [])
]
return {
"chat_id": record.get("chat_id", chat_id),
"saved_at": record.get("saved_at", ""),
"history": history,
}
return None
import { useState } from "react"; import { useEffect, useState } from "react";
import "../styles/theme.css"; import "../styles/theme.css";
import ChatWindow from "../components/Chat/ChatWindow"; import ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer"; import CanvasDrawer from "../components/Canvas/CanvasDrawer";
...@@ -8,9 +8,33 @@ import type { RetrievedDoc } from "../components/Retrieval/DocPanel"; ...@@ -8,9 +8,33 @@ import type { RetrievedDoc } from "../components/Retrieval/DocPanel";
const initialMessages: ChatMessage[] = []; const initialMessages: ChatMessage[] = [];
type ArchivedChatSummary = {
chat_id: string;
saved_at: string;
message_count: number;
preview: string;
};
type ArchivedChatDetail = {
chat_id: string;
saved_at: string;
history: ChatMessage[];
};
const createSessionId = () =>
`session_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
export default function App() { export default function App() {
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages); const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [chatSessionId, setChatSessionId] = useState(createSessionId);
const [isArchiving, setIsArchiving] = useState(false);
const [archivedChats, setArchivedChats] = useState<ArchivedChatSummary[]>([]);
const [selectedArchiveId, setSelectedArchiveId] = useState("");
const [archiveError, setArchiveError] = useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isCanvasVisible, setIsCanvasVisible] = useState(false); const [isCanvasVisible, setIsCanvasVisible] = useState(false);
const [directChildren, setDirectChildren] = useState<RetrievedDoc[]>([]); const [directChildren, setDirectChildren] = useState<RetrievedDoc[]>([]);
const [indirectChildren, setIndirectChildren] = useState<RetrievedDoc[]>([]); const [indirectChildren, setIndirectChildren] = useState<RetrievedDoc[]>([]);
...@@ -69,6 +93,7 @@ export default function App() { ...@@ -69,6 +93,7 @@ export default function App() {
role: message.role, role: message.role,
text: message.text, text: message.text,
})), })),
draft: chatSessionId,
}), }),
}); });
...@@ -109,6 +134,93 @@ export default function App() { ...@@ -109,6 +134,93 @@ export default function App() {
setIsCanvasVisible((prev) => !prev); setIsCanvasVisible((prev) => !prev);
}; };
const resetChatState = () => {
setMessages(initialMessages);
setDraft("");
setDirectChildren([]);
setIndirectChildren([]);
setSubsections([]);
setSections([]);
setRetrievalLoading(false);
setRetrievalError(null);
setCanvasStatus(null);
setIsCanvasVisible(false);
};
const loadArchives = async () => {
setArchiveError(null);
try {
const response = await fetch(
"http://localhost:8000/api/chat/archives?limit=50"
);
if (!response.ok) {
throw new Error(`Archive list failed: ${response.status}`);
}
const payload: ArchivedChatSummary[] = await response.json();
setArchivedChats(payload);
if (payload.length && !selectedArchiveId) {
setSelectedArchiveId(payload[0].chat_id);
}
} catch (error) {
setArchiveError("Failed to load saved chats.");
void error;
}
};
const handleLoadArchive = async (chatId?: string) => {
const targetId = chatId || selectedArchiveId;
if (!targetId) {
return;
}
setArchiveError(null);
try {
const response = await fetch(
`http://localhost:8000/api/chat/archive/${targetId}`
);
if (!response.ok) {
throw new Error(`Archive load failed: ${response.status}`);
}
const payload: ArchivedChatDetail = await response.json();
resetChatState();
setMessages(payload.history || []);
setChatSessionId(payload.chat_id);
} catch (error) {
setArchiveError("Failed to load selected chat.");
void error;
}
};
const handleNewChat = async () => {
if (messages.length) {
setIsArchiving(true);
try {
await fetch("http://localhost:8000/api/chat/archive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: messages.map((message) => ({
role: message.role,
text: message.text,
})),
draft: chatSessionId,
}),
});
} catch (error) {
void error;
} finally {
setIsArchiving(false);
}
}
resetChatState();
setChatSessionId(createSessionId());
await loadArchives();
};
useEffect(() => {
void loadArchives();
}, []);
const handleCanvasSave = async (dataUrl: string) => { const handleCanvasSave = async (dataUrl: string) => {
setCanvasStatus({ setCanvasStatus({
kind: "info", kind: "info",
...@@ -227,6 +339,13 @@ export default function App() { ...@@ -227,6 +339,13 @@ export default function App() {
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="app-header"> <header className="app-header">
<button
type="button"
className="btn sidebar-toggle"
onClick={() => setIsSidebarOpen(true)}
>
Chats
</button>
<div className="brand"> <div className="brand">
<span className="brand-mark">SUM</span> <span className="brand-mark">SUM</span>
<div className="brand-text"> <div className="brand-text">
...@@ -275,6 +394,64 @@ export default function App() { ...@@ -275,6 +394,64 @@ export default function App() {
/> />
</aside> </aside>
</main> </main>
<div className={`sidebar ${isSidebarOpen ? "active" : ""}`}>
<div className="sd-header">
<h4 className="sd-title">Saved Chats</h4>
<button
type="button"
className="sidebar-button"
onClick={() => setIsSidebarOpen(false)}
>
X
</button>
</div>
<div className="sd-body">
<ul className="sd-list">
<li>
<button
type="button"
className="sd-link"
onClick={handleNewChat}
disabled={isArchiving}
>
{isArchiving ? "Saving..." : "New Chat"}
</button>
</li>
<li>
<div className="sd-separator" />
</li>
{archivedChats.length ? (
archivedChats.map((item) => (
<li key={item.chat_id}>
<button
type="button"
className={`sd-link ${
selectedArchiveId === item.chat_id ? "active" : ""
}`}
onClick={() => {
setSelectedArchiveId(item.chat_id);
void handleLoadArchive(item.chat_id);
}}
>
{item.preview || item.chat_id} ({item.message_count})
</button>
</li>
))
) : (
<li>
<div className="sd-empty">No saved chats yet.</div>
</li>
)}
</ul>
{archiveError ? (
<div className="chat-archive-error">{archiveError}</div>
) : null}
</div>
</div>
<div
className={`sidebar-overlay ${isSidebarOpen ? "active" : ""}`}
onClick={() => setIsSidebarOpen(false)}
/>
</div> </div>
); );
} }
...@@ -99,6 +99,133 @@ body { ...@@ -99,6 +99,133 @@ body {
flex: 1; flex: 1;
} }
.chat-archive-error {
padding: 8px 10px;
border-radius: 10px;
background: #f3d9d6;
color: #8a3b2f;
border: 1px solid #e2b4ae;
font-size: 12px;
}
.sidebar-toggle {
background: #ffffff;
align-self: flex-start;
}
.sidebar {
width: 280px;
min-height: 100vh;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.16);
background-color: #ffffff;
position: fixed;
top: 0;
left: -100%;
z-index: 10;
transition: 0.5s;
border-radius: 0 16px 16px 0;
}
.sidebar.active {
left: 0;
}
.sd-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #e2ded5;
}
.sd-title {
font-size: 16px;
margin: 0;
}
.sidebar-button {
border: none;
background: #1d1b16;
color: #fef9f0;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
transition: 0.5s;
opacity: 0;
visibility: hidden;
z-index: 5;
}
.sidebar-overlay.active {
opacity: 1;
visibility: visible;
}
.sd-body {
padding: 16px;
max-height: calc(100vh - 66px);
overflow-x: hidden;
}
.sd-list {
display: inline-block;
width: 100%;
margin: 0;
padding: 0;
}
.sd-list li {
list-style: none;
margin-bottom: 8px;
}
.sd-link {
display: inline-flex;
width: 100%;
padding: 10px 14px;
color: #475f7b;
background-color: #e5e8ec;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
border: none;
text-align: left;
font-family: inherit;
}
.sd-link.active {
background-color: #1d1b16;
color: #fef9f0;
}
.sd-link:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sd-separator {
height: 1px;
background: #d8d1c4;
margin: 6px 0 10px;
}
.sd-empty {
padding: 10px 12px;
background: #f6f0e4;
border-radius: 8px;
color: #6f675d;
font-size: 12px;
}
.chat-title { .chat-title {
font-weight: 600; font-weight: 600;
} }
...@@ -255,6 +382,7 @@ body { ...@@ -255,6 +382,7 @@ body {
.retrieval-column { .retrieval-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;
} }
.retrieval-panel { .retrieval-panel {
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment