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
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Path, Query
from pydantic import BaseModel, Field
from app.deterministic_services import orchestrator
from app.deterministic_services import orchestrator, session_store
router = APIRouter()
logger = logging.getLogger(__name__)
......@@ -28,6 +28,24 @@ class ChatResponse(BaseModel):
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)
def chat(request: ChatRequest) -> ChatResponse:
if not request.messages:
......@@ -47,3 +65,47 @@ def chat(request: ChatRequest) -> ChatResponse:
raise HTTPException(status_code=502, detail="chat provider failed") from exc
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 ChatWindow from "../components/Chat/ChatWindow";
import CanvasDrawer from "../components/Canvas/CanvasDrawer";
......@@ -8,9 +8,33 @@ import type { RetrievedDoc } from "../components/Retrieval/DocPanel";
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() {
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
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 [directChildren, setDirectChildren] = useState<RetrievedDoc[]>([]);
const [indirectChildren, setIndirectChildren] = useState<RetrievedDoc[]>([]);
......@@ -69,6 +93,7 @@ export default function App() {
role: message.role,
text: message.text,
})),
draft: chatSessionId,
}),
});
......@@ -109,6 +134,93 @@ export default function App() {
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) => {
setCanvasStatus({
kind: "info",
......@@ -227,6 +339,13 @@ export default function App() {
return (
<div className="app-shell">
<header className="app-header">
<button
type="button"
className="btn sidebar-toggle"
onClick={() => setIsSidebarOpen(true)}
>
Chats
</button>
<div className="brand">
<span className="brand-mark">SUM</span>
<div className="brand-text">
......@@ -275,6 +394,64 @@ export default function App() {
/>
</aside>
</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>
);
}
......@@ -99,6 +99,133 @@ body {
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 {
font-weight: 600;
}
......@@ -255,6 +382,7 @@ body {
.retrieval-column {
display: flex;
flex-direction: column;
gap: 16px;
}
.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