Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in
Toggle navigation
Menu
Open sidebar
math_tutor_dev
public_math_tutor
Commits
2445b3f6
Commit
2445b3f6
authored
Jan 26, 2026
by
Kantz
Browse files
save und load von Chats ist möglich
parent
77c29417
Changes
4
Show whitespace changes
Inline
Side-by-side
math-tutor/backend/app/api/chat.py
View file @
2445b3f6
...
...
@@ -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
)
math-tutor/backend/app/deterministic_services/session_store.py
0 → 100644
View file @
2445b3f6
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
math-tutor/frontend/src/pages/App.tsx
View file @
2445b3f6
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
>
);
}
math-tutor/frontend/src/styles/theme.css
View file @
2445b3f6
...
...
@@ -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
{
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment