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
4cb2901e
Commit
4cb2901e
authored
Mar 02, 2026
by
Kantz
Browse files
erster entwurf mit seperiertem Auswahlfenster
parent
6e95391a
Changes
13
Hide whitespace changes
Inline
Side-by-side
math-tutor/backend/app/api/chat.py
View file @
4cb2901e
from
__future__
import
annotations
from
__future__
import
annotations
from
typing
import
List
,
Optional
import
logging
import
logging
from
typing
import
List
,
Optional
import
app.config
as
config
from
app.deterministic_services
import
session_store
from
fastapi
import
APIRouter
,
HTTPException
,
Path
,
Query
from
fastapi
import
APIRouter
,
HTTPException
,
Path
,
Query
from
pydantic
import
BaseModel
,
Field
from
pydantic
import
BaseModel
,
Field
from
app.deterministic_services
import
session_store
import
app.config
as
config
if
config
.
get_orchestrator
()
==
"tutor"
:
if
config
.
get_orchestrator
()
==
"tutor"
:
from
app.deterministic_services.orchestrators
import
orchestrator_tutor
as
orchestrator
from
app.deterministic_services.orchestrators
import
(
orchestrator_tutor
as
orchestrator
,
)
elif
config
.
get_orchestrator
()
==
"task"
:
elif
config
.
get_orchestrator
()
==
"task"
:
from
app.deterministic_services.orchestrators
import
orchestrator_task
as
orchestrator
from
app.deterministic_services.orchestrators
import
orchestrator_task
as
orchestrator
else
:
else
:
...
@@ -50,10 +50,16 @@ class ChatArchiveSummary(BaseModel):
...
@@ -50,10 +50,16 @@ class ChatArchiveSummary(BaseModel):
preview
:
str
preview
:
str
class
SelectedTaskRef
(
BaseModel
):
file_id
:
str
task_id
:
str
class
ChatArchiveDetail
(
BaseModel
):
class
ChatArchiveDetail
(
BaseModel
):
chat_id
:
str
chat_id
:
str
saved_at
:
str
saved_at
:
str
history
:
List
[
ChatMessage
]
history
:
List
[
ChatMessage
]
selected_task
:
Optional
[
SelectedTaskRef
]
=
None
@
router
.
post
(
"/api/chat"
,
response_model
=
ChatResponse
)
@
router
.
post
(
"/api/chat"
,
response_model
=
ChatResponse
)
...
@@ -80,8 +86,7 @@ def chat(request: ChatRequest) -> ChatResponse:
...
@@ -80,8 +86,7 @@ def chat(request: ChatRequest) -> ChatResponse:
raise
HTTPException
(
status_code
=
500
,
detail
=
str
(
exc
))
from
exc
raise
HTTPException
(
status_code
=
500
,
detail
=
str
(
exc
))
from
exc
except
Exception
as
exc
:
except
Exception
as
exc
:
logger
.
exception
(
"Chat request failed"
)
logger
.
exception
(
"Chat request failed"
)
raise
HTTPException
(
raise
HTTPException
(
status_code
=
502
,
detail
=
"chat provider failed"
)
from
exc
status_code
=
502
,
detail
=
"chat provider failed"
)
from
exc
return
ChatResponse
(
reply
=
reply
,
sources
=
sources
)
return
ChatResponse
(
reply
=
reply
,
sources
=
sources
)
...
@@ -92,8 +97,7 @@ def list_archives(limit: int = Query(20, ge=1, le=200)) -> List[ChatArchiveSumma
...
@@ -92,8 +97,7 @@ def list_archives(limit: int = Query(20, ge=1, le=200)) -> List[ChatArchiveSumma
return
session_store
.
list_archives
(
limit
=
limit
)
return
session_store
.
list_archives
(
limit
=
limit
)
except
Exception
as
exc
:
except
Exception
as
exc
:
logger
.
exception
(
"Chat archive list failed"
)
logger
.
exception
(
"Chat archive list failed"
)
raise
HTTPException
(
raise
HTTPException
(
status_code
=
502
,
detail
=
"chat archive list failed"
)
from
exc
status_code
=
502
,
detail
=
"chat archive list failed"
)
from
exc
@
router
.
get
(
"/api/chat/archive/{chat_id}"
,
response_model
=
ChatArchiveDetail
)
@
router
.
get
(
"/api/chat/archive/{chat_id}"
,
response_model
=
ChatArchiveDetail
)
...
@@ -102,17 +106,24 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
...
@@ -102,17 +106,24 @@ def get_archive(chat_id: str = Path(..., min_length=1)) -> ChatArchiveDetail:
record
=
session_store
.
load_archive
(
chat_id
)
record
=
session_store
.
load_archive
(
chat_id
)
except
Exception
as
exc
:
except
Exception
as
exc
:
logger
.
exception
(
"Chat archive load failed"
)
logger
.
exception
(
"Chat archive load failed"
)
raise
HTTPException
(
raise
HTTPException
(
status_code
=
502
,
detail
=
"chat archive load failed"
)
from
exc
status_code
=
502
,
detail
=
"chat archive load failed"
)
from
exc
if
not
record
:
if
not
record
:
raise
HTTPException
(
status_code
=
404
,
detail
=
"chat archive not found"
)
raise
HTTPException
(
status_code
=
404
,
detail
=
"chat archive not found"
)
selected_task_raw
=
record
.
get
(
"selected_task"
)
selected_task
:
Optional
[
SelectedTaskRef
]
=
None
if
isinstance
(
selected_task_raw
,
dict
):
file_id
=
str
(
selected_task_raw
.
get
(
"file_id"
,
""
)).
strip
()
task_id
=
str
(
selected_task_raw
.
get
(
"task_id"
,
""
)).
strip
()
if
file_id
and
task_id
:
selected_task
=
SelectedTaskRef
(
file_id
=
file_id
,
task_id
=
task_id
)
return
ChatArchiveDetail
(
return
ChatArchiveDetail
(
chat_id
=
record
[
"chat_id"
],
chat_id
=
record
[
"chat_id"
],
saved_at
=
record
.
get
(
"saved_at"
,
""
),
saved_at
=
record
.
get
(
"saved_at"
,
""
),
history
=
[
ChatMessage
(
role
=
item
[
"role"
],
text
=
item
[
"text"
])
history
=
[
ChatMessage
(
role
=
item
[
"role"
],
text
=
item
[
"text"
])
for
item
in
record
[
"history"
]],
for
item
in
record
[
"history"
]]
,
selected_task
=
selected_task
,
)
)
...
@@ -128,7 +139,6 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
...
@@ -128,7 +139,6 @@ def archive_chat(request: ChatRequest) -> ChatArchiveResponse:
)
)
except
Exception
as
exc
:
except
Exception
as
exc
:
logger
.
exception
(
"Chat archive failed"
)
logger
.
exception
(
"Chat archive failed"
)
raise
HTTPException
(
raise
HTTPException
(
status_code
=
502
,
detail
=
"chat archive failed"
)
from
exc
status_code
=
502
,
detail
=
"chat archive failed"
)
from
exc
return
ChatArchiveResponse
(
status
=
"ok"
,
chat_id
=
chat_id
)
return
ChatArchiveResponse
(
status
=
"ok"
,
chat_id
=
chat_id
)
math-tutor/backend/app/deterministic_services/session_store.py
View file @
4cb2901e
...
@@ -2,10 +2,10 @@ from __future__ import annotations
...
@@ -2,10 +2,10 @@ from __future__ import annotations
import
json
import
json
import
os
import
os
from
collections
import
deque
from
datetime
import
datetime
from
datetime
import
datetime
from
threading
import
Lock
from
threading
import
Lock
from
typing
import
Any
from
typing
import
Any
from
collections
import
deque
from
app.deterministic_services
import
context_store
from
app.deterministic_services
import
context_store
...
@@ -18,6 +18,14 @@ def _utc_now() -> str:
...
@@ -18,6 +18,14 @@ def _utc_now() -> str:
return
datetime
.
utcnow
().
strftime
(
"%Y-%m-%dT%H:%M:%SZ"
)
return
datetime
.
utcnow
().
strftime
(
"%Y-%m-%dT%H:%M:%SZ"
)
def
_extract_selected_task
(
sheet
:
dict
[
str
,
Any
])
->
dict
[
str
,
str
]
|
None
:
file_id
=
str
(
sheet
.
get
(
"task_file_id"
,
""
)).
strip
()
task_id
=
str
(
sheet
.
get
(
"task_id"
,
""
)).
strip
()
if
not
file_id
or
not
task_id
:
return
None
return
{
"file_id"
:
file_id
,
"task_id"
:
task_id
}
def
archive_chat
(
messages
:
list
[
dict
[
str
,
Any
]],
draft
:
str
|
None
=
None
)
->
str
:
def
archive_chat
(
messages
:
list
[
dict
[
str
,
Any
]],
draft
:
str
|
None
=
None
)
->
str
:
chat_id
=
context_store
.
get_chat_id
(
messages
,
draft
=
draft
)
chat_id
=
context_store
.
get_chat_id
(
messages
,
draft
=
draft
)
sheet
=
context_store
.
load_sheet
(
chat_id
)
sheet
=
context_store
.
load_sheet
(
chat_id
)
...
@@ -35,6 +43,7 @@ def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> st
...
@@ -35,6 +43,7 @@ def archive_chat(messages: list[dict[str, Any]], draft: str | None = None) -> st
"retrieval_contexts"
:
sheet
.
get
(
"retrieval_contexts"
,
[]),
"retrieval_contexts"
:
sheet
.
get
(
"retrieval_contexts"
,
[]),
"math_solutions"
:
sheet
.
get
(
"math_solutions"
,
[]),
"math_solutions"
:
sheet
.
get
(
"math_solutions"
,
[]),
"sources"
:
sheet
.
get
(
"sources"
,
[]),
"sources"
:
sheet
.
get
(
"sources"
,
[]),
"selected_task"
:
_extract_selected_task
(
sheet
),
}
}
os
.
makedirs
(
_LOG_DIR
,
exist_ok
=
True
)
os
.
makedirs
(
_LOG_DIR
,
exist_ok
=
True
)
...
@@ -100,13 +109,21 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
...
@@ -100,13 +109,21 @@ def load_archive(chat_id: str) -> dict[str, Any] | None:
continue
continue
if
record
.
get
(
"chat_id"
)
==
chat_id
:
if
record
.
get
(
"chat_id"
)
==
chat_id
:
history
=
[
history
=
[
{
"role"
:
entry
.
get
(
"role"
,
"user"
),
{
"role"
:
entry
.
get
(
"role"
,
"user"
),
"text"
:
entry
.
get
(
"content"
,
""
)}
"text"
:
entry
.
get
(
"content"
,
""
)}
for
entry
in
record
.
get
(
"history"
,
[])
for
entry
in
record
.
get
(
"history"
,
[])
]
]
selected_task_raw
=
record
.
get
(
"selected_task"
)
selected_task
:
dict
[
str
,
str
]
|
None
=
None
if
isinstance
(
selected_task_raw
,
dict
):
file_id
=
str
(
selected_task_raw
.
get
(
"file_id"
,
""
)).
strip
()
task_id
=
str
(
selected_task_raw
.
get
(
"task_id"
,
""
)).
strip
()
if
file_id
and
task_id
:
selected_task
=
{
"file_id"
:
file_id
,
"task_id"
:
task_id
}
return
{
return
{
"chat_id"
:
record
.
get
(
"chat_id"
,
chat_id
),
"chat_id"
:
record
.
get
(
"chat_id"
,
chat_id
),
"saved_at"
:
record
.
get
(
"saved_at"
,
""
),
"saved_at"
:
record
.
get
(
"saved_at"
,
""
),
"history"
:
history
,
"history"
:
history
,
"selected_task"
:
selected_task
,
}
}
return
None
return
None
math-tutor/frontend/package-lock.json
View file @
4cb2901e
...
@@ -11,7 +11,8 @@
...
@@ -11,7 +11,8 @@
"iconoir-react"
:
"^7.11.0"
,
"iconoir-react"
:
"^7.11.0"
,
"react"
:
"^19.2.0"
,
"react"
:
"^19.2.0"
,
"react-dom"
:
"^19.2.0"
,
"react-dom"
:
"^19.2.0"
,
"react-markdown"
:
"^10.1.0"
"react-markdown"
:
"^10.1.0"
,
"react-router-dom"
:
"^7.9.4"
},
},
"devDependencies"
:
{
"devDependencies"
:
{
"@eslint/js"
:
"^9.39.1"
,
"@eslint/js"
:
"^9.39.1"
,
...
@@ -1234,13 +1235,13 @@
...
@@ -1234,13 +1235,13 @@
}
}
},
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch"
:
{
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch"
:
{
"version"
:
"9.0.
5
"
,
"version"
:
"9.0.
9
"
,
"resolved"
:
"https://registry.npmjs.org/minimatch/-/minimatch-9.0.
5
.tgz"
,
"resolved"
:
"https://registry.npmjs.org/minimatch/-/minimatch-9.0.
9
.tgz"
,
"integrity"
:
"sha512-
G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow
=="
,
"integrity"
:
"sha512-
OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg
=="
,
"dev"
:
true
,
"dev"
:
true
,
"license"
:
"ISC"
,
"license"
:
"ISC"
,
"dependencies"
:
{
"dependencies"
:
{
"brace-expansion"
:
"^2.0.
1
"
"brace-expansion"
:
"^2.0.
2
"
},
},
"engines"
:
{
"engines"
:
{
"node"
:
">=16 || 14 >=14.17"
"node"
:
">=16 || 14 >=14.17"
...
@@ -1356,9 +1357,9 @@
...
@@ -1356,9 +1357,9 @@
}
}
},
},
"node_modules/ajv"
:
{
"node_modules/ajv"
:
{
"version"
:
"6.1
2.6
"
,
"version"
:
"6.1
4.0
"
,
"resolved"
:
"https://registry.npmjs.org/ajv/-/ajv-6.1
2.6
.tgz"
,
"resolved"
:
"https://registry.npmjs.org/ajv/-/ajv-6.1
4.0
.tgz"
,
"integrity"
:
"sha512-
j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g
=="
,
"integrity"
:
"sha512-
IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw
=="
,
"dev"
:
true
,
"dev"
:
true
,
"license"
:
"MIT"
,
"license"
:
"MIT"
,
"dependencies"
:
{
"dependencies"
:
{
...
@@ -1610,6 +1611,19 @@
...
@@ -1610,6 +1611,19 @@
"dev"
:
true
,
"dev"
:
true
,
"license"
:
"MIT"
"license"
:
"MIT"
},
},
"node_modules/cookie"
:
{
"version"
:
"1.1.1"
,
"resolved"
:
"https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz"
,
"integrity"
:
"sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="
,
"license"
:
"MIT"
,
"engines"
:
{
"node"
:
">=18"
},
"funding"
:
{
"type"
:
"opencollective"
,
"url"
:
"https://opencollective.com/express"
}
},
"node_modules/cross-spawn"
:
{
"node_modules/cross-spawn"
:
{
"version"
:
"7.0.6"
,
"version"
:
"7.0.6"
,
"resolved"
:
"https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz"
,
"resolved"
:
"https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz"
,
...
@@ -3282,9 +3296,9 @@
...
@@ -3282,9 +3296,9 @@
"license"
:
"MIT"
"license"
:
"MIT"
},
},
"node_modules/minimatch"
:
{
"node_modules/minimatch"
:
{
"version"
:
"3.1.
2
"
,
"version"
:
"3.1.
5
"
,
"resolved"
:
"https://registry.npmjs.org/minimatch/-/minimatch-3.1.
2
.tgz"
,
"resolved"
:
"https://registry.npmjs.org/minimatch/-/minimatch-3.1.
5
.tgz"
,
"integrity"
:
"sha512-
J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIH
w=="
,
"integrity"
:
"sha512-
VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4
w=="
,
"dev"
:
true
,
"dev"
:
true
,
"license"
:
"ISC"
,
"license"
:
"ISC"
,
"dependencies"
:
{
"dependencies"
:
{
...
@@ -3536,6 +3550,7 @@
...
@@ -3536,6 +3550,7 @@
"resolved"
:
"https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz"
,
"resolved"
:
"https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz"
,
"integrity"
:
"sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="
,
"integrity"
:
"sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="
,
"license"
:
"MIT"
,
"license"
:
"MIT"
,
"peer"
:
true
,
"dependencies"
:
{
"dependencies"
:
{
"scheduler"
:
"^0.27.0"
"scheduler"
:
"^0.27.0"
},
},
...
@@ -3580,6 +3595,44 @@
...
@@ -3580,6 +3595,44 @@
"node"
:
">=0.10.0"
"node"
:
">=0.10.0"
}
}
},
},
"node_modules/react-router"
:
{
"version"
:
"7.13.1"
,
"resolved"
:
"https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz"
,
"integrity"
:
"sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA=="
,
"license"
:
"MIT"
,
"dependencies"
:
{
"cookie"
:
"^1.0.1"
,
"set-cookie-parser"
:
"^2.6.0"
},
"engines"
:
{
"node"
:
">=20.0.0"
},
"peerDependencies"
:
{
"react"
:
">=18"
,
"react-dom"
:
">=18"
},
"peerDependenciesMeta"
:
{
"react-dom"
:
{
"optional"
:
true
}
}
},
"node_modules/react-router-dom"
:
{
"version"
:
"7.13.1"
,
"resolved"
:
"https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz"
,
"integrity"
:
"sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw=="
,
"license"
:
"MIT"
,
"dependencies"
:
{
"react-router"
:
"7.13.1"
},
"engines"
:
{
"node"
:
">=20.0.0"
},
"peerDependencies"
:
{
"react"
:
">=18"
,
"react-dom"
:
">=18"
}
},
"node_modules/remark-parse"
:
{
"node_modules/remark-parse"
:
{
"version"
:
"11.0.0"
,
"version"
:
"11.0.0"
,
"resolved"
:
"https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz"
,
"resolved"
:
"https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz"
,
...
@@ -3679,6 +3732,12 @@
...
@@ -3679,6 +3732,12 @@
"semver"
:
"bin/semver.js"
"semver"
:
"bin/semver.js"
}
}
},
},
"node_modules/set-cookie-parser"
:
{
"version"
:
"2.7.2"
,
"resolved"
:
"https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz"
,
"integrity"
:
"sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
,
"license"
:
"MIT"
},
"node_modules/shebang-command"
:
{
"node_modules/shebang-command"
:
{
"version"
:
"2.0.0"
,
"version"
:
"2.0.0"
,
"resolved"
:
"https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz"
,
"resolved"
:
"https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz"
,
...
...
math-tutor/frontend/package.json
View file @
4cb2901e
...
@@ -13,7 +13,8 @@
...
@@ -13,7 +13,8 @@
"iconoir-react"
:
"^7.11.0"
,
"iconoir-react"
:
"^7.11.0"
,
"react"
:
"^19.2.0"
,
"react"
:
"^19.2.0"
,
"react-dom"
:
"^19.2.0"
,
"react-dom"
:
"^19.2.0"
,
"react-markdown"
:
"^10.1.0"
"react-markdown"
:
"^10.1.0"
,
"react-router-dom"
:
"^7.9.4"
},
},
"devDependencies"
:
{
"devDependencies"
:
{
"@eslint/js"
:
"^9.39.1"
,
"@eslint/js"
:
"^9.39.1"
,
...
...
math-tutor/frontend/src/api/taskApi.ts
0 → 100644
View file @
4cb2901e
export
type
TaskItem
=
{
task_id
:
string
;
statement
:
string
;
full_text
:
string
;
};
export
type
TaskFile
=
{
file_id
:
string
;
title
:
string
;
intro
:
string
;
tasks
:
TaskItem
[];
};
export
type
TasksResponse
=
{
orchestrator
:
string
;
enabled
:
boolean
;
task_files
:
TaskFile
[];
};
export
type
SelectedTaskRef
=
{
fileId
:
string
;
taskId
:
string
;
};
export
type
SelectTaskResponse
=
{
status
:
string
;
file_id
:
string
;
task_id
:
string
;
};
export
async
function
fetchTasks
():
Promise
<
TasksResponse
>
{
const
response
=
await
fetch
(
"
/api/tasks
"
);
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Tasks failed:
${
response
.
status
}
`
);
}
return
response
.
json
();
}
export
async
function
selectTask
(
input
:
{
draft
:
string
;
fileId
:
string
;
taskId
:
string
;
}):
Promise
<
SelectTaskResponse
>
{
const
response
=
await
fetch
(
"
/api/tasks/select
"
,
{
method
:
"
POST
"
,
headers
:
{
"
Content-Type
"
:
"
application/json
"
},
body
:
JSON
.
stringify
({
draft
:
input
.
draft
,
file_id
:
input
.
fileId
,
task_id
:
input
.
taskId
,
}),
});
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Task selection failed:
${
response
.
status
}
`
);
}
return
response
.
json
();
}
math-tutor/frontend/src/components/Task/TaskPanel.tsx
View file @
4cb2901e
import
{
useEffect
,
useRef
}
from
"
react
"
;
import
{
useEffect
,
useRef
}
from
"
react
"
;
import
{
t
}
from
"
../../i18n
"
;
import
{
t
}
from
"
../../i18n
"
;
type
SelectOption
=
{
type
SelectOption
=
{
...
@@ -7,21 +7,25 @@ type SelectOption = {
...
@@ -7,21 +7,25 @@ type SelectOption = {
};
};
type
TaskPanelProps
=
{
type
TaskPanelProps
=
{
selectedFileId
:
string
;
readOnly
?:
boolean
;
fileOptions
:
SelectOption
[];
selectedFileLabel
?:
string
;
selectedTaskId
:
string
;
selectedFileId
?:
string
;
taskOptions
:
SelectOption
[];
fileOptions
?:
SelectOption
[];
selectedTaskId
?:
string
;
taskOptions
?:
SelectOption
[];
selectedTaskText
:
string
;
selectedTaskText
:
string
;
tasksError
?:
string
|
null
;
tasksError
?:
string
|
null
;
onTaskFileChange
:
(
fileId
:
string
)
=>
void
;
onTaskFileChange
?
:
(
fileId
:
string
)
=>
void
;
onTaskChange
:
(
taskId
:
string
)
=>
void
;
onTaskChange
?
:
(
taskId
:
string
)
=>
void
;
};
};
export
default
function
TaskPanel
({
export
default
function
TaskPanel
({
selectedFileId
,
readOnly
=
false
,
fileOptions
,
selectedFileLabel
,
selectedTaskId
,
selectedFileId
=
""
,
taskOptions
,
fileOptions
=
[],
selectedTaskId
=
""
,
taskOptions
=
[],
selectedTaskText
,
selectedTaskText
,
tasksError
,
tasksError
,
onTaskFileChange
,
onTaskFileChange
,
...
@@ -44,41 +48,53 @@ export default function TaskPanel({
...
@@ -44,41 +48,53 @@ export default function TaskPanel({
<
section
className
=
"task-panel"
>
<
section
className
=
"task-panel"
>
<
div
className
=
"task-panel-header"
>
<
div
className
=
"task-panel-header"
>
<
div
className
=
"task-panel-title"
>
{
t
(
"
task
"
)
}
</
div
>
<
div
className
=
"task-panel-title"
>
{
t
(
"
task
"
)
}
</
div
>
<
div
className
=
"task-panel-controls"
>
{
readOnly
?
(
<
select
<
div
className
=
"task-panel-lock"
>
{
t
(
"
taskLocked
"
)
}
</
div
>
className
=
"task-select"
)
:
(
value
=
{
selectedFileId
}
<
div
className
=
"task-panel-controls"
>
onChange
=
{
(
event
)
=>
onTaskFileChange
(
event
.
target
.
value
)
}
<
select
disabled
=
{
!
fileOptions
.
length
}
className
=
"task-select"
>
value
=
{
selectedFileId
}
{
fileOptions
.
length
?
(
onChange
=
{
(
event
)
=>
onTaskFileChange
?.(
event
.
target
.
value
)
}
fileOptions
.
map
((
option
)
=>
(
disabled
=
{
!
fileOptions
.
length
}
<
option
key
=
{
option
.
value
}
value
=
{
option
.
value
}
>
>
{
option
.
label
}
{
fileOptions
.
length
?
(
</
option
>
fileOptions
.
map
((
option
)
=>
(
))
<
option
key
=
{
option
.
value
}
value
=
{
option
.
value
}
>
)
:
(
{
option
.
label
}
<
option
value
=
""
>
{
t
(
"
noTasksAvailable
"
)
}
</
option
>
</
option
>
)
}
))
</
select
>
)
:
(
<
select
<
option
value
=
""
>
{
t
(
"
noTasksAvailable
"
)
}
</
option
>
className
=
"task-select"
)
}
value
=
{
selectedTaskId
}
</
select
>
onChange
=
{
(
event
)
=>
onTaskChange
(
event
.
target
.
value
)
}
<
select
disabled
=
{
!
taskOptions
.
length
}
className
=
"task-select"
>
value
=
{
selectedTaskId
}
{
taskOptions
.
length
?
(
onChange
=
{
(
event
)
=>
onTaskChange
?.(
event
.
target
.
value
)
}
taskOptions
.
map
((
option
)
=>
(
disabled
=
{
!
taskOptions
.
length
}
<
option
key
=
{
option
.
value
}
value
=
{
option
.
value
}
>
>
{
option
.
label
}
{
taskOptions
.
length
?
(
</
option
>
taskOptions
.
map
((
option
)
=>
(
))
<
option
key
=
{
option
.
value
}
value
=
{
option
.
value
}
>
)
:
(
{
option
.
label
}
<
option
value
=
""
>
{
t
(
"
noTasksAvailable
"
)
}
</
option
>
</
option
>
)
}
))
</
select
>
)
:
(
</
div
>
<
option
value
=
""
>
{
t
(
"
noTasksAvailable
"
)
}
</
option
>
)
}
</
select
>
</
div
>
)
}
</
div
>
</
div
>
{
readOnly
?
(
<
div
className
=
"task-panel-meta"
>
<
div
>
{
selectedFileLabel
||
"
-
"
}
</
div
>
<
div
>
{
selectedTaskId
?
`
${
t
(
"
taskId
"
)}
:
${
selectedTaskId
}
`
:
""
}
</
div
>
</
div
>
)
:
null
}
<
div
className
=
"task-panel-content"
ref
=
{
taskDisplayRef
}
>
<
div
className
=
"task-panel-content"
ref
=
{
taskDisplayRef
}
>
{
selectedTaskText
||
t
(
"
noTaskSelected
"
)
}
{
selectedTaskText
||
t
(
"
noTaskSelected
"
)
}
</
div
>
</
div
>
...
...
math-tutor/frontend/src/i18n.ts
View file @
4cb2901e
const
translations
=
{
const
translations
=
{
en
:
{
en
:
{
chats
:
"
Chats
"
,
chats
:
"
Chats
"
,
prototypeWorkspace
:
"
Prototype Workspace
"
,
prototypeWorkspace
:
"
Prototype Workspace
"
,
...
@@ -45,6 +45,12 @@ const translations = {
...
@@ -45,6 +45,12 @@ const translations = {
canvasSaved
:
"
Canvas saved.
"
,
canvasSaved
:
"
Canvas saved.
"
,
canvasSaveFailedCheckLogs
:
canvasSaveFailedCheckLogs
:
"
Saving the canvas failed. Please check backend logs.
"
,
"
Saving the canvas failed. Please check backend logs.
"
,
taskSelectionTitle
:
"
Select a Task
"
,
taskSelectionSubtitle
:
"
Choose a task and start a tutor session
"
,
taskFile
:
"
Task Set
"
,
taskId
:
"
Task ID
"
,
solveWithTutor
:
"
Solve with Tutor
"
,
taskLocked
:
"
Task locked
"
,
},
},
de
:
{
de
:
{
chats
:
"
Chats
"
,
chats
:
"
Chats
"
,
...
@@ -54,7 +60,7 @@ const translations = {
...
@@ -54,7 +60,7 @@ const translations = {
canvas
:
"
Canvas
"
,
canvas
:
"
Canvas
"
,
retrievedNotes
:
"
Quellen
"
,
retrievedNotes
:
"
Quellen
"
,
typeQuestionOrLatex
:
typeQuestionOrLatex
:
"
Gebe hier deine Frage ein. Nutze $ $ f
ü
r mathematische Eingaben...
"
,
"
Gebe hier deine Frage ein. Nutze $ $ f
ue
r mathematische Eingaben...
"
,
noResultsYet
:
"
bisher keine Quellen
"
,
noResultsYet
:
"
bisher keine Quellen
"
,
eraser
:
"
Radierer
"
,
eraser
:
"
Radierer
"
,
pen
:
"
Stift
"
,
pen
:
"
Stift
"
,
...
@@ -77,7 +83,7 @@ const translations = {
...
@@ -77,7 +83,7 @@ const translations = {
roleUser
:
"
nutzer
"
,
roleUser
:
"
nutzer
"
,
roleAssistant
:
"
assistent
"
,
roleAssistant
:
"
assistent
"
,
noTasksAvailable
:
"
Keine Aufgaben verfuegbar
"
,
noTasksAvailable
:
"
Keine Aufgaben verfuegbar
"
,
noTaskSelected
:
"
Keine Aufgabe ausgew
ae
hlt.
"
,
noTaskSelected
:
"
Keine Aufgabe ausgew
ä
hlt.
"
,
savedChats
:
"
Gespeicherte Chats
"
,
savedChats
:
"
Gespeicherte Chats
"
,
saving
:
"
Speichere...
"
,
saving
:
"
Speichere...
"
,
noSavedChatsYet
:
"
Noch keine gespeicherten Chats.
"
,
noSavedChatsYet
:
"
Noch keine gespeicherten Chats.
"
,
...
@@ -88,7 +94,7 @@ const translations = {
...
@@ -88,7 +94,7 @@ const translations = {
retrievalFailed
:
retrievalFailed
:
"
Quellenabruf ist fehlgeschlagen. Bitte pruefe die Backend-Logs.
"
,
"
Quellenabruf ist fehlgeschlagen. Bitte pruefe die Backend-Logs.
"
,
failedLoadSavedChats
:
"
Gespeicherte Chats konnten nicht geladen werden.
"
,
failedLoadSavedChats
:
"
Gespeicherte Chats konnten nicht geladen werden.
"
,
failedLoadSelectedChat
:
"
Der ausgew
ae
hlte Chat konnte nicht geladen werden.
"
,
failedLoadSelectedChat
:
"
Der ausgew
ä
hlte Chat konnte nicht geladen werden.
"
,
savingDrawingConverting
:
savingDrawingConverting
:
"
Zeichnung wird gespeichert und in LaTeX konvertiert...
"
,
"
Zeichnung wird gespeichert und in LaTeX konvertiert...
"
,
saveFailedStatus
:
"
Speichern fehlgeschlagen (Status: {status}).
"
,
saveFailedStatus
:
"
Speichern fehlgeschlagen (Status: {status}).
"
,
...
@@ -96,6 +102,12 @@ const translations = {
...
@@ -96,6 +102,12 @@ const translations = {
canvasSaved
:
"
Canvas gespeichert.
"
,
canvasSaved
:
"
Canvas gespeichert.
"
,
canvasSaveFailedCheckLogs
:
canvasSaveFailedCheckLogs
:
"
Speichern des Canvas fehlgeschlagen. Bitte pruefe die Backend-Logs.
"
,
"
Speichern des Canvas fehlgeschlagen. Bitte pruefe die Backend-Logs.
"
,
taskSelectionTitle
:
"
Aufgabe auswaehlen
"
,
taskSelectionSubtitle
:
"
Waehle eine Aufgabe und starte den Tutor-Chat
"
,
taskFile
:
"
Aufgabenset
"
,
taskId
:
"
Aufgaben-ID
"
,
solveWithTutor
:
"
Mit Tutor lösen
"
,
taskLocked
:
"
Aufgabe fixiert
"
,
},
},
}
as
const
;
}
as
const
;
...
...
math-tutor/frontend/src/main.tsx
View file @
4cb2901e
import
{
StrictMode
}
from
'
react
'
import
{
StrictMode
}
from
"
react
"
;
import
{
createRoot
}
from
'
react-dom/client
'
import
{
createRoot
}
from
"
react-dom/client
"
;
import
'
./index.css
'
import
{
BrowserRouter
}
from
"
react-router-dom
"
;
import
App
from
'
./pages/App.tsx
'
import
"
./index.css
"
;
import
App
from
"
./pages/App.tsx
"
;
createRoot
(
document
.
getElementById
(
'
root
'
)
!
).
render
(
createRoot
(
document
.
getElementById
(
"
root
"
)
!
).
render
(
<
StrictMode
>
<
StrictMode
>
<
App
/>
<
BrowserRouter
>
</
StrictMode
>,
<
App
/>
)
</
BrowserRouter
>
</
StrictMode
>
);
math-tutor/frontend/src/pages/App.tsx
View file @
4cb2901e
import
{
useEffect
,
useMemo
,
useState
}
from
"
react
"
;
import
{
Navigate
,
Route
,
Routes
}
from
"
react-router-dom
"
;
import
"
../styles/theme.css
"
;
import
ChatWindow
from
"
../components/Chat/ChatWindow
"
;
import
CanvasDrawer
from
"
../components/Canvas/CanvasDrawer
"
;
import
DocPanel
from
"
../components/Retrieval/DocPanel
"
;
import
TaskPanel
from
"
../components/Task/TaskPanel
"
;
import
type
{
ChatMessage
}
from
"
../components/Chat/MessageList
"
;
import
type
{
RetrievedDoc
}
from
"
../components/Retrieval/DocPanel
"
;
import
{
t
}
from
"
../i18n
"
;
import
{
t
}
from
"
../i18n
"
;
import
ChatPage
from
"
./ChatPage
"
;
import
TaskSelectionPage
from
"
./TaskSelectionPage
"
;
import
{
TutorSessionProvider
,
useTutorSession
}
from
"
../state/tutorSession
"
;
const
initialMessages
:
ChatMessage
[]
=
[];
function
StartRoute
()
{
const
{
isTasksInitialized
,
isTaskModeEnabled
}
=
useTutorSession
();
type
ArchivedChatSummary
=
{
if
(
!
isTasksInitialized
)
{
chat_id
:
string
;
return
<
div
className
=
"app-loading"
>
{
t
(
"
loading
"
)
}
</
div
>;
saved_at
:
string
;
}
message_count
:
number
;
preview
:
string
;
};
type
ArchivedChatDetail
=
{
return
<
Navigate
to
=
{
isTaskModeEnabled
?
"
/select-task
"
:
"
/chat
"
}
replace
/>;
chat_id
:
string
;
}
saved_at
:
string
;
history
:
ChatMessage
[];
};
type
ContextSource
=
{
source_id
:
{
chapter_title
?:
string
|
null
;
section_title
?:
string
|
null
;
subsection_title
?:
string
|
null
;
title
:
string
;
doc_type
:
string
;
};
retrieved_as
?:
string
|
null
;
source_type
?:
string
|
null
;
score
?:
number
;
markdown
?:
string
|
null
;
};
const
createSessionId
=
()
=>
`session_
${
Date
.
now
().
toString
(
36
)}
_
${
Math
.
random
()
.
toString
(
36
)
.
slice
(
2
,
8
)}
`
;
const
sourceIdToUid
=
(
source
:
ContextSource
,
index
:
number
)
=>
{
const
parts
=
[
source
.
source_id
.
chapter_title
,
source
.
source_id
.
section_title
,
source
.
source_id
.
subsection_title
,
source
.
source_id
.
title
,
source
.
source_id
.
doc_type
,
].
filter
(
Boolean
);
return
parts
.
length
?
parts
.
join
(
"
|
"
)
:
`source_
${
index
}
`
;
};
type
TaskItem
=
{
task_id
:
string
;
statement
:
string
;
full_text
:
string
;
};
type
TaskFile
=
{
file_id
:
string
;
title
:
string
;
intro
:
string
;
tasks
:
TaskItem
[];
};
type
TasksResponse
=
{
orchestrator
:
string
;
enabled
:
boolean
;
task_files
:
TaskFile
[];
};
type
SelectedTaskRef
=
{
fileId
:
string
;
taskId
:
string
;
};
type
SelectedTask
=
SelectedTaskRef
&
{
title
:
string
;
fullText
:
string
;
};
const
getDefaultTaskId
=
(
tasks
:
TaskItem
[]):
string
=>
tasks
.
find
((
task
)
=>
task
.
task_id
===
"
01
"
)?.
task_id
||
tasks
[
0
]?.
task_id
||
""
;
const
sourceIdToKey
=
(
sourceId
:
ContextSource
[
"
source_id
"
])
=>
{
const
parts
=
[
sourceId
.
chapter_title
??
""
,
sourceId
.
section_title
??
""
,
sourceId
.
subsection_title
??
""
,
sourceId
.
title
??
""
,
sourceId
.
doc_type
??
""
,
];
return
parts
.
join
(
"
|
"
);
};
const
sourceIdToPath
=
(
source
:
ContextSource
)
=>
{
const
parts
=
[
source
.
source_id
.
chapter_title
,
source
.
source_id
.
section_title
,
source
.
source_id
.
subsection_title
,
].
filter
(
Boolean
);
return
parts
.
length
?
parts
.
join
(
"
/
"
)
:
null
;
};
const
toRetrievedDoc
=
(
source
:
ContextSource
,
index
:
number
):
RetrievedDoc
=>
({
uid
:
sourceIdToUid
(
source
,
index
),
sourceKey
:
sourceIdToKey
(
source
.
source_id
),
doc_type
:
source
.
source_id
.
doc_type
,
score
:
source
.
score
,
metadata
:
{
section_title
:
source
.
source_id
.
section_title
??
null
,
subsection_title
:
source
.
source_id
.
subsection_title
??
null
,
title
:
source
.
source_id
.
title
??
null
,
type
:
source
.
source_type
??
null
,
path
:
sourceIdToPath
(
source
),
},
markdown
:
source
.
markdown
||
""
,
});
export
default
function
App
()
{
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
[
taskChildren
,
setTaskChildren
]
=
useState
<
RetrievedDoc
[]
>
([]);
const
[
indirectChildren
,
setIndirectChildren
]
=
useState
<
RetrievedDoc
[]
>
([]);
const
[
subsections
,
setSubsections
]
=
useState
<
RetrievedDoc
[]
>
([]);
const
[
sections
,
setSections
]
=
useState
<
RetrievedDoc
[]
>
([]);
const
[
retrievalLoading
,
setRetrievalLoading
]
=
useState
(
false
);
const
[
retrievalError
,
setRetrievalError
]
=
useState
<
string
|
null
>
(
null
);
const
[
canvasStatus
,
setCanvasStatus
]
=
useState
<
{
kind
:
"
info
"
|
"
error
"
|
"
ok
"
;
message
:
string
;
}
|
null
>
(
null
);
const
[
isTaskModeEnabled
,
setIsTaskModeEnabled
]
=
useState
(
false
);
const
[
taskFiles
,
setTaskFiles
]
=
useState
<
TaskFile
[]
>
([]);
const
[
selectedTaskRef
,
setSelectedTaskRef
]
=
useState
<
SelectedTaskRef
|
null
>
(
null
);
const
[
tasksError
,
setTasksError
]
=
useState
<
string
|
null
>
(
null
);
const
docIndexes
=
useMemo
(()
=>
{
const
bySourceKey
:
Record
<
string
,
RetrievedDoc
>
=
{};
const
bySlug
:
Record
<
string
,
RetrievedDoc
>
=
{};
const
allDocs
=
[
...
directChildren
,
...
taskChildren
,
...
indirectChildren
,
...
subsections
,
...
sections
,
];
const
slugify
=
(
value
:
string
)
=>
value
.
toLowerCase
()
.
replace
(
/ä/g
,
"
ae
"
)
.
replace
(
/ö/g
,
"
oe
"
)
.
replace
(
/ü/g
,
"
ue
"
)
.
replace
(
/ß/g
,
"
ss
"
)
.
replace
(
/
[^
a-z0-9
]
+/g
,
"
-
"
)
.
replace
(
/^-+|-+$/g
,
""
);
allDocs
.
forEach
((
doc
)
=>
{
if
(
doc
.
sourceKey
)
{
bySourceKey
[
doc
.
sourceKey
]
=
doc
;
}
const
candidates
=
[
doc
.
metadata
.
title
,
doc
.
metadata
.
subsection_title
,
doc
.
metadata
.
section_title
,
doc
.
metadata
.
path
,
]
.
filter
(
Boolean
)
.
map
((
item
)
=>
String
(
item
));
candidates
.
forEach
((
candidate
)
=>
{
const
slug
=
slugify
(
candidate
);
if
(
slug
&&
!
bySlug
[
slug
])
{
bySlug
[
slug
]
=
doc
;
}
});
});
return
{
bySourceKey
,
bySlug
};
},
[
directChildren
,
taskChildren
,
indirectChildren
,
subsections
,
sections
]);
const
selectedTask
=
useMemo
<
SelectedTask
|
null
>
(()
=>
{
if
(
!
selectedTaskRef
)
{
return
null
;
}
const
file
=
taskFiles
.
find
((
item
)
=>
item
.
file_id
===
selectedTaskRef
.
fileId
);
if
(
!
file
)
{
return
null
;
}
const
task
=
file
.
tasks
.
find
((
item
)
=>
item
.
task_id
===
selectedTaskRef
.
taskId
);
if
(
!
task
)
{
return
null
;
}
return
{
fileId
:
file
.
file_id
,
taskId
:
task
.
task_id
,
title
:
file
.
title
,
fullText
:
task
.
full_text
,
};
},
[
selectedTaskRef
,
taskFiles
]);
const
selectedTaskFile
=
useMemo
(
()
=>
taskFiles
.
find
((
file
)
=>
file
.
file_id
===
selectedTaskRef
?.
fileId
)
||
null
,
[
selectedTaskRef
?.
fileId
,
taskFiles
]
);
const
taskFileOptions
=
useMemo
(
()
=>
taskFiles
.
map
((
file
)
=>
({
value
:
file
.
file_id
,
label
:
file
.
title
||
file
.
file_id
,
})),
[
taskFiles
]
);
const
taskOptions
=
useMemo
(
()
=>
(
selectedTaskFile
?.
tasks
||
[]).
map
((
task
)
=>
({
value
:
task
.
task_id
,
label
:
task
.
task_id
,
})),
[
selectedTaskFile
]
);
useEffect
(()
=>
{
let
isActive
=
true
;
const
loadTasks
=
async
()
=>
{
setTasksError
(
null
);
try
{
const
response
=
await
fetch
(
`/api/tasks`
);
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Tasks failed:
${
response
.
status
}
`
);
}
const
payload
:
TasksResponse
=
await
response
.
json
();
if
(
!
isActive
)
{
return
;
}
const
enabled
=
Boolean
(
payload
.
enabled
);
setIsTaskModeEnabled
(
enabled
);
setTaskFiles
(
payload
.
task_files
||
[]);
if
(
!
enabled
)
{
setSelectedTaskRef
(
null
);
}
}
catch
(
error
)
{
if
(
!
isActive
)
{
return
;
}
setTasksError
(
t
(
"
failedLoadTasks
"
));
setIsTaskModeEnabled
(
false
);
setTaskFiles
([]);
setSelectedTaskRef
(
null
);
void
error
;
}
};
void
loadTasks
();
return
()
=>
{
isActive
=
false
;
};
},
[]);
useEffect
(()
=>
{
if
(
!
isTaskModeEnabled
)
{
return
;
}
if
(
!
taskFiles
.
length
)
{
setSelectedTaskRef
(
null
);
return
;
}
setSelectedTaskRef
((
prev
)
=>
{
if
(
prev
)
{
const
file
=
taskFiles
.
find
((
item
)
=>
item
.
file_id
===
prev
.
fileId
);
if
(
file
)
{
const
hasTask
=
file
.
tasks
.
some
((
task
)
=>
task
.
task_id
===
prev
.
taskId
);
if
(
hasTask
)
{
return
prev
;
}
const
defaultTaskId
=
getDefaultTaskId
(
file
.
tasks
);
if
(
defaultTaskId
)
{
return
{
fileId
:
file
.
file_id
,
taskId
:
defaultTaskId
};
}
}
}
const
firstFile
=
taskFiles
[
0
];
const
defaultTaskId
=
getDefaultTaskId
(
firstFile
.
tasks
||
[]);
if
(
!
defaultTaskId
)
{
return
null
;
}
return
{
fileId
:
firstFile
.
file_id
,
taskId
:
defaultTaskId
};
});
},
[
isTaskModeEnabled
,
taskFiles
]);
useEffect
(()
=>
{
if
(
!
isTaskModeEnabled
||
!
selectedTaskRef
)
{
return
;
}
const
syncTaskSelection
=
async
()
=>
{
try
{
await
fetch
(
`/api/tasks/select`
,
{
method
:
"
POST
"
,
headers
:
{
"
Content-Type
"
:
"
application/json
"
},
body
:
JSON
.
stringify
({
draft
:
chatSessionId
,
file_id
:
selectedTaskRef
.
fileId
,
task_id
:
selectedTaskRef
.
taskId
,
}),
});
}
catch
(
error
)
{
void
error
;
}
};
void
syncTaskSelection
();
},
[
chatSessionId
,
isTaskModeEnabled
,
selectedTaskRef
]);
const
handleSend
=
async
()
=>
{
const
trimmed
=
draft
.
trim
();
if
(
!
trimmed
)
{
return
;
}
const
userMessage
:
ChatMessage
=
{
id
:
`m-
${
Date
.
now
()}
-user`
,
role
:
"
user
"
,
text
:
trimmed
,
};
setMessages
((
prev
)
=>
[...
prev
,
userMessage
]);
setDraft
(
""
);
setRetrievalLoading
(
true
);
setRetrievalError
(
null
);
try
{
const
chatPayload
:
{
messages
:
Array
<
{
role
:
"
user
"
|
"
assistant
"
;
text
:
string
}
>
;
draft
:
string
;
selected_task
?:
{
file_id
:
string
;
task_id
:
string
};
}
=
{
messages
:
[...
messages
,
userMessage
].
map
((
message
)
=>
({
role
:
message
.
role
,
text
:
message
.
text
,
})),
draft
:
chatSessionId
,
};
if
(
isTaskModeEnabled
&&
selectedTaskRef
)
{
chatPayload
.
selected_task
=
{
file_id
:
selectedTaskRef
.
fileId
,
task_id
:
selectedTaskRef
.
taskId
,
};
}
const
response
=
await
fetch
(
`/api/chat`
,
{
method
:
"
POST
"
,
headers
:
{
"
Content-Type
"
:
"
application/json
"
},
body
:
JSON
.
stringify
(
chatPayload
),
});
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Chat failed:
${
response
.
status
}
`
);
}
const
payload
:
{
reply
?:
string
}
=
await
response
.
json
();
const
reply
=
payload
.
reply
;
if
(
typeof
reply
===
"
string
"
&&
reply
.
length
>
0
)
{
setMessages
((
prev
)
=>
[
...
prev
,
{
id
:
`m-
${
Date
.
now
()}
-assistant`
,
role
:
"
assistant
"
,
text
:
reply
,
},
]);
}
}
catch
(
error
)
{
setMessages
((
prev
)
=>
[
...
prev
,
{
id
:
`m-
${
Date
.
now
()}
-assistant`
,
role
:
"
assistant
"
,
text
:
t
(
"
chatRequestFailed
"
),
},
]);
setRetrievalError
(
t
(
"
retrievalFailed
"
));
setRetrievalLoading
(
false
);
void
error
;
return
;
}
try
{
const
contextResponse
=
await
fetch
(
`/api/context/retrieval`
,
{
method
:
"
POST
"
,
headers
:
{
"
Content-Type
"
:
"
application/json
"
},
body
:
JSON
.
stringify
({
messages
:
[...
messages
,
userMessage
].
map
((
message
)
=>
({
role
:
message
.
role
,
text
:
message
.
text
,
})),
draft
:
chatSessionId
,
}),
});
if
(
!
contextResponse
.
ok
)
{
throw
new
Error
(
`Retrieval failed:
${
contextResponse
.
status
}
`
);
}
const
sources
:
ContextSource
[]
=
await
contextResponse
.
json
();
const
nextDirect
:
RetrievedDoc
[]
=
[];
const
nextTask
:
RetrievedDoc
[]
=
[];
const
nextIndirect
:
RetrievedDoc
[]
=
[];
const
nextSubsections
:
RetrievedDoc
[]
=
[];
const
nextSections
:
RetrievedDoc
[]
=
[];
sources
.
forEach
((
source
,
index
)
=>
{
const
doc
=
toRetrievedDoc
(
source
,
index
);
switch
(
source
.
retrieved_as
)
{
case
"
children_direct
"
:
nextDirect
.
push
(
doc
);
break
;
case
"
children_expanded
"
:
nextIndirect
.
push
(
doc
);
break
;
case
"
task_childs
"
:
nextTask
.
push
(
doc
);
break
;
case
"
subsections
"
:
nextSubsections
.
push
(
doc
);
break
;
case
"
sections
"
:
nextSections
.
push
(
doc
);
break
;
case
"
neighbors
"
:
nextIndirect
.
push
(
doc
);
break
;
default
:
nextIndirect
.
push
(
doc
);
}
});
setDirectChildren
(
nextDirect
);
setTaskChildren
(
nextTask
);
setIndirectChildren
(
nextIndirect
);
setSubsections
(
nextSubsections
);
setSections
(
nextSections
);
}
catch
(
error
)
{
setRetrievalError
(
t
(
"
retrievalFailed
"
));
void
error
;
}
finally
{
setRetrievalLoading
(
false
);
}
};
const
handleToggleCanvas
=
()
=>
{
setIsCanvasVisible
((
prev
)
=>
!
prev
);
};
const
handleTaskFileChange
=
(
fileId
:
string
)
=>
{
if
(
!
fileId
)
{
return
;
}
const
file
=
taskFiles
.
find
((
item
)
=>
item
.
file_id
===
fileId
);
if
(
!
file
)
{
return
;
}
const
defaultTaskId
=
getDefaultTaskId
(
file
.
tasks
||
[]);
if
(
!
defaultTaskId
)
{
return
;
}
setSelectedTaskRef
({
fileId
:
file
.
file_id
,
taskId
:
defaultTaskId
});
};
const
handleTaskChange
=
(
taskId
:
string
)
=>
{
if
(
!
taskId
||
!
selectedTaskRef
?.
fileId
)
{
return
;
}
setSelectedTaskRef
({
fileId
:
selectedTaskRef
.
fileId
,
taskId
});
};
const
resetChatState
=
()
=>
{
setMessages
(
initialMessages
);
setDraft
(
""
);
setDirectChildren
([]);
setTaskChildren
([]);
setIndirectChildren
([]);
setSubsections
([]);
setSections
([]);
setRetrievalLoading
(
false
);
setRetrievalError
(
null
);
setCanvasStatus
(
null
);
setIsCanvasVisible
(
false
);
};
const
loadArchives
=
async
()
=>
{
setArchiveError
(
null
);
try
{
const
response
=
await
fetch
(
`/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
(
t
(
"
failedLoadSavedChats
"
));
void
error
;
}
};
const
handleLoadArchive
=
async
(
chatId
?:
string
)
=>
{
const
targetId
=
chatId
||
selectedArchiveId
;
if
(
!
targetId
)
{
return
;
}
setArchiveError
(
null
);
try
{
const
response
=
await
fetch
(
`/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
(
t
(
"
failedLoadSelectedChat
"
));
void
error
;
}
};
const
handleNewChat
=
async
()
=>
{
if
(
messages
.
length
)
{
setIsArchiving
(
true
);
try
{
await
fetch
(
`/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
();
};
const
handleOpenSidebar
=
()
=>
{
setIsSidebarOpen
(
true
);
void
loadArchives
();
};
const
handleCanvasSave
=
async
(
dataUrl
:
string
)
=>
{
setCanvasStatus
({
kind
:
"
info
"
,
message
:
t
(
"
savingDrawingConverting
"
),
});
try
{
const
response
=
await
fetch
(
`/api/canvas/save`
,
{
method
:
"
POST
"
,
headers
:
{
"
Content-Type
"
:
"
application/json
"
},
body
:
JSON
.
stringify
({
data_url
:
dataUrl
,
filename_hint
:
"
canvas
"
}),
});
if
(
!
response
.
ok
)
{
let
message
=
t
(
"
saveFailedStatus
"
,
{
status
:
response
.
status
});
try
{
const
errorPayload
:
{
detail
?:
string
}
=
await
response
.
json
();
if
(
errorPayload
.
detail
)
{
message
=
errorPayload
.
detail
;
}
}
catch
(
error
)
{
void
error
;
}
setCanvasStatus
({
kind
:
"
error
"
,
message
});
return
;
}
const
payload
:
{
status
?:
string
;
latex
?:
string
}
=
await
response
.
json
();
if
(
payload
.
status
&&
payload
.
status
!==
"
ok
"
)
{
setCanvasStatus
({
kind
:
"
error
"
,
message
:
payload
.
latex
||
t
(
"
canvasSaveFailed
"
),
});
return
;
}
const
latex
=
payload
.
latex
;
if
(
typeof
latex
===
"
string
"
&&
latex
.
length
>
0
)
{
setDraft
((
prev
)
=>
(
prev
?
`
${
prev
}
${
latex
}
`
:
latex
));
setCanvasStatus
({
kind
:
"
ok
"
,
message
:
t
(
"
canvasSaved
"
)
});
setIsCanvasVisible
(
false
);
}
}
catch
(
error
)
{
setCanvasStatus
({
kind
:
"
error
"
,
message
:
t
(
"
canvasSaveFailedCheckLogs
"
),
});
void
error
;
}
};
const
handleCiteDoc
=
(
docId
:
string
)
=>
{
setDraft
((
prev
)
=>
(
prev
?
`
${
prev
}
[
${
docId
}
]`
:
`[
${
docId
}
]`
));
};
const
handleInspectDoc
=
(
doc
:
RetrievedDoc
)
=>
{
const
title
=
doc
.
metadata
.
title
||
doc
.
metadata
.
subsection_title
||
doc
.
metadata
.
section_title
||
doc
.
metadata
.
path
||
t
(
"
untitled
"
);
const
html
=
`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
${
title
}
</title>
<style>
body { font-family: "Space Grotesk", sans-serif; margin: 24px; color: #1d1b16; }
h1 { font-size: 22px; margin-bottom: 12px; }
.meta { color: #6f675d; font-size: 12px; margin-bottom: 16px; }
.content { max-width: 900px; }
pre, code { background: #f6f0e4; padding: 2px 4px; border-radius: 4px; }
</style>
</head>
<body>
<h1 id="title"></h1>
<div class="meta" id="meta"></div>
<div class="content" id="content"></div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
const markdown =
${
JSON
.
stringify
(
doc
.
markdown
)}
;
const title =
${
JSON
.
stringify
(
title
)}
;
const path =
${
JSON
.
stringify
(
doc
.
metadata
.
path
||
""
)}
;
document.getElementById("title").textContent = title;
document.getElementById("meta").textContent = path;
document.getElementById("content").innerHTML = marked.parse(markdown);
</script>
<script>
window.MathJax = {
tex: {
inlineMath: [["$", "$"], ["\\\\(", "\\\\)"]],
displayMath: [["$$", "$$"], ["\\\\[", "\\\\]"]],
}
};
</script>
<script
id="mathjax-script"
async
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"
></script>
</body>
</html>`
;
const
tab
=
window
.
open
(
""
,
"
_blank
"
);
if
(
!
tab
)
{
return
;
}
tab
.
document
.
open
();
tab
.
document
.
write
(
html
);
tab
.
document
.
close
();
};
return
(
return
(
<
div
className
=
"app-shell"
>
<
TutorSessionProvider
>
<
header
className
=
"app-header"
>
<
Routes
>
<
button
<
Route
path
=
"/"
element
=
{
<
StartRoute
/>
}
/>
type
=
"button"
<
Route
path
=
"/select-task"
element
=
{
<
TaskSelectionPage
/>
}
/>
className
=
"btn sidebar-toggle"
<
Route
path
=
"/chat"
element
=
{
<
ChatPage
/>
}
/>
onClick
=
{
handleOpenSidebar
}
<
Route
path
=
"*"
element
=
{
<
StartRoute
/>
}
/>
>
</
Routes
>
{
t
(
"
chats
"
)
}
</
TutorSessionProvider
>
</
button
>
<
div
className
=
"brand"
>
<
span
className
=
"brand-mark"
>
SUM
</
span
>
<
div
className
=
"brand-text"
>
<
div
className
=
"brand-title"
>
Math Tutor
</
div
>
<
div
className
=
"brand-subtitle"
>
{
t
(
"
prototypeWorkspace
"
)
}
</
div
>
</
div
>
</
div
>
<
div
className
=
"header-meta"
>
<
span
className
=
"pill"
>
Postgres + pgvector
</
span
>
<
span
className
=
"pill"
>
Python FastAPI
</
span
>
<
span
className
=
"pill"
>
Mathpix
</
span
>
</
div
>
</
header
>
<
main
className
=
"app-main"
>
<
section
className
=
"chat-column"
>
<
ChatWindow
messages
=
{
messages
}
draft
=
{
draft
}
onDraftChange
=
{
setDraft
}
onSend
=
{
handleSend
}
onToggleCanvas
=
{
handleToggleCanvas
}
onInspectDoc
=
{
handleInspectDoc
}
docIndex
=
{
docIndexes
.
bySourceKey
}
docSlugIndex
=
{
docIndexes
.
bySlug
}
/>
{
isCanvasVisible
?
(
<
CanvasDrawer
isVisible
=
{
isCanvasVisible
}
onToggle
=
{
handleToggleCanvas
}
onSave
=
{
handleCanvasSave
}
statusMessage
=
{
canvasStatus
?.
message
}
statusKind
=
{
canvasStatus
?.
kind
}
/>
)
:
null
}
</
section
>
<
aside
className
=
"retrieval-column"
>
{
isTaskModeEnabled
?
(
<
TaskPanel
selectedFileId
=
{
selectedTaskRef
?.
fileId
||
""
}
fileOptions
=
{
taskFileOptions
}
selectedTaskId
=
{
selectedTaskRef
?.
taskId
||
""
}
taskOptions
=
{
taskOptions
}
selectedTaskText
=
{
selectedTask
?.
fullText
||
""
}
tasksError
=
{
tasksError
}
onTaskFileChange
=
{
handleTaskFileChange
}
onTaskChange
=
{
handleTaskChange
}
/>
)
:
null
}
<
DocPanel
directChildren
=
{
directChildren
}
taskChildren
=
{
taskChildren
}
indirectChildren
=
{
indirectChildren
}
subsections
=
{
subsections
}
sections
=
{
sections
}
isLoading
=
{
retrievalLoading
}
error
=
{
retrievalError
}
onCiteDoc
=
{
handleCiteDoc
}
onInspectDoc
=
{
handleInspectDoc
}
/>
</
aside
>
</
main
>
<
div
className
=
{
`sidebar
${
isSidebarOpen
?
"
active
"
:
""
}
`
}
>
<
div
className
=
"sd-header"
>
<
h4
className
=
"sd-title"
>
{
t
(
"
savedChats
"
)
}
</
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
?
t
(
"
saving
"
)
:
t
(
"
newChat
"
)
}
</
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"
>
{
t
(
"
noSavedChatsYet
"
)
}
</
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/pages/ChatPage.tsx
0 → 100644
View file @
4cb2901e
import
{
useEffect
,
useMemo
,
useState
}
from
"
react
"
;
import
{
useNavigate
}
from
"
react-router-dom
"
;
import
"
../styles/theme.css
"
;
import
ChatWindow
from
"
../components/Chat/ChatWindow
"
;
import
CanvasDrawer
from
"
../components/Canvas/CanvasDrawer
"
;
import
DocPanel
from
"
../components/Retrieval/DocPanel
"
;
import
TaskPanel
from
"
../components/Task/TaskPanel
"
;
import
type
{
ChatMessage
}
from
"
../components/Chat/MessageList
"
;
import
type
{
RetrievedDoc
}
from
"
../components/Retrieval/DocPanel
"
;
import
{
t
}
from
"
../i18n
"
;
import
{
selectTask
}
from
"
../api/taskApi
"
;
import
{
useTutorSession
}
from
"
../state/tutorSession
"
;
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
[];
selected_task
?:
{
file_id
:
string
;
task_id
:
string
;
}
|
null
;
};
type
ContextSource
=
{
source_id
:
{
chapter_title
?:
string
|
null
;
section_title
?:
string
|
null
;
subsection_title
?:
string
|
null
;
title
:
string
;
doc_type
:
string
;
};
retrieved_as
?:
string
|
null
;
source_type
?:
string
|
null
;
score
?:
number
;
markdown
?:
string
|
null
;
};
const
sourceIdToUid
=
(
source
:
ContextSource
,
index
:
number
)
=>
{
const
parts
=
[
source
.
source_id
.
chapter_title
,
source
.
source_id
.
section_title
,
source
.
source_id
.
subsection_title
,
source
.
source_id
.
title
,
source
.
source_id
.
doc_type
,
].
filter
(
Boolean
);
return
parts
.
length
?
parts
.
join
(
"
|
"
)
:
`source_
${
index
}
`
;
};
const
sourceIdToKey
=
(
sourceId
:
ContextSource
[
"
source_id
"
])
=>
{
const
parts
=
[
sourceId
.
chapter_title
??
""
,
sourceId
.
section_title
??
""
,
sourceId
.
subsection_title
??
""
,
sourceId
.
title
??
""
,
sourceId
.
doc_type
??
""
,
];
return
parts
.
join
(
"
|
"
);
};
const
sourceIdToPath
=
(
source
:
ContextSource
)
=>
{
const
parts
=
[
source
.
source_id
.
chapter_title
,
source
.
source_id
.
section_title
,
source
.
source_id
.
subsection_title
,
].
filter
(
Boolean
);
return
parts
.
length
?
parts
.
join
(
"
/
"
)
:
null
;
};
const
toRetrievedDoc
=
(
source
:
ContextSource
,
index
:
number
):
RetrievedDoc
=>
({
uid
:
sourceIdToUid
(
source
,
index
),
sourceKey
:
sourceIdToKey
(
source
.
source_id
),
doc_type
:
source
.
source_id
.
doc_type
,
score
:
source
.
score
,
metadata
:
{
section_title
:
source
.
source_id
.
section_title
??
null
,
subsection_title
:
source
.
source_id
.
subsection_title
??
null
,
title
:
source
.
source_id
.
title
??
null
,
type
:
source
.
source_type
??
null
,
path
:
sourceIdToPath
(
source
),
},
markdown
:
source
.
markdown
||
""
,
});
export
default
function
ChatPage
()
{
const
navigate
=
useNavigate
();
const
{
chatSessionId
,
setChatSessionId
,
isTaskModeEnabled
,
isTasksInitialized
,
selectedTaskRef
,
selectedTask
,
taskLocked
,
lockTask
,
unlockTask
,
setTaskRef
,
resetForNewChat
,
}
=
useTutorSession
();
const
[
messages
,
setMessages
]
=
useState
<
ChatMessage
[]
>
(
initialMessages
);
const
[
draft
,
setDraft
]
=
useState
(
""
);
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
[
taskChildren
,
setTaskChildren
]
=
useState
<
RetrievedDoc
[]
>
([]);
const
[
indirectChildren
,
setIndirectChildren
]
=
useState
<
RetrievedDoc
[]
>
([]);
const
[
subsections
,
setSubsections
]
=
useState
<
RetrievedDoc
[]
>
([]);
const
[
sections
,
setSections
]
=
useState
<
RetrievedDoc
[]
>
([]);
const
[
retrievalLoading
,
setRetrievalLoading
]
=
useState
(
false
);
const
[
retrievalError
,
setRetrievalError
]
=
useState
<
string
|
null
>
(
null
);
const
[
canvasStatus
,
setCanvasStatus
]
=
useState
<
{
kind
:
"
info
"
|
"
error
"
|
"
ok
"
;
message
:
string
;
}
|
null
>
(
null
);
useEffect
(()
=>
{
if
(
!
isTasksInitialized
||
!
isTaskModeEnabled
)
{
return
;
}
if
(
!
selectedTaskRef
||
!
taskLocked
)
{
navigate
(
"
/select-task
"
,
{
replace
:
true
});
}
},
[
isTaskModeEnabled
,
isTasksInitialized
,
navigate
,
selectedTaskRef
,
taskLocked
,
]);
const
docIndexes
=
useMemo
(()
=>
{
const
bySourceKey
:
Record
<
string
,
RetrievedDoc
>
=
{};
const
bySlug
:
Record
<
string
,
RetrievedDoc
>
=
{};
const
allDocs
=
[
...
directChildren
,
...
taskChildren
,
...
indirectChildren
,
...
subsections
,
...
sections
,
];
const
slugify
=
(
value
:
string
)
=>
value
.
toLowerCase
()
.
replace
(
/
[^
a-z0-9
]
+/g
,
"
-
"
)
.
replace
(
/^-+|-+$/g
,
""
);
allDocs
.
forEach
((
doc
)
=>
{
if
(
doc
.
sourceKey
)
{
bySourceKey
[
doc
.
sourceKey
]
=
doc
;
}
const
candidates
=
[
doc
.
metadata
.
title
,
doc
.
metadata
.
subsection_title
,
doc
.
metadata
.
section_title
,
doc
.
metadata
.
path
,
]
.
filter
(
Boolean
)
.
map
((
item
)
=>
String
(
item
));
candidates
.
forEach
((
candidate
)
=>
{
const
slug
=
slugify
(
candidate
);
if
(
slug
&&
!
bySlug
[
slug
])
{
bySlug
[
slug
]
=
doc
;
}
});
});
return
{
bySourceKey
,
bySlug
};
},
[
directChildren
,
taskChildren
,
indirectChildren
,
subsections
,
sections
]);
const
handleSend
=
async
()
=>
{
const
trimmed
=
draft
.
trim
();
if
(
!
trimmed
)
{
return
;
}
const
userMessage
:
ChatMessage
=
{
id
:
`m-
${
Date
.
now
()}
-user`
,
role
:
"
user
"
,
text
:
trimmed
,
};
setMessages
((
prev
)
=>
[...
prev
,
userMessage
]);
setDraft
(
""
);
setRetrievalLoading
(
true
);
setRetrievalError
(
null
);
try
{
const
chatPayload
:
{
messages
:
Array
<
{
role
:
"
user
"
|
"
assistant
"
;
text
:
string
}
>
;
draft
:
string
;
selected_task
?:
{
file_id
:
string
;
task_id
:
string
};
}
=
{
messages
:
[...
messages
,
userMessage
].
map
((
message
)
=>
({
role
:
message
.
role
,
text
:
message
.
text
,
})),
draft
:
chatSessionId
,
};
if
(
isTaskModeEnabled
&&
selectedTaskRef
)
{
chatPayload
.
selected_task
=
{
file_id
:
selectedTaskRef
.
fileId
,
task_id
:
selectedTaskRef
.
taskId
,
};
}
const
response
=
await
fetch
(
`/api/chat`
,
{
method
:
"
POST
"
,
headers
:
{
"
Content-Type
"
:
"
application/json
"
},
body
:
JSON
.
stringify
(
chatPayload
),
});
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Chat failed:
${
response
.
status
}
`
);
}
const
payload
:
{
reply
?:
string
}
=
await
response
.
json
();
const
reply
=
payload
.
reply
;
if
(
typeof
reply
===
"
string
"
&&
reply
.
length
>
0
)
{
setMessages
((
prev
)
=>
[
...
prev
,
{
id
:
`m-
${
Date
.
now
()}
-assistant`
,
role
:
"
assistant
"
,
text
:
reply
,
},
]);
}
}
catch
(
error
)
{
setMessages
((
prev
)
=>
[
...
prev
,
{
id
:
`m-
${
Date
.
now
()}
-assistant`
,
role
:
"
assistant
"
,
text
:
t
(
"
chatRequestFailed
"
),
},
]);
setRetrievalError
(
t
(
"
retrievalFailed
"
));
setRetrievalLoading
(
false
);
void
error
;
return
;
}
try
{
const
contextResponse
=
await
fetch
(
`/api/context/retrieval`
,
{
method
:
"
POST
"
,
headers
:
{
"
Content-Type
"
:
"
application/json
"
},
body
:
JSON
.
stringify
({
messages
:
[...
messages
,
userMessage
].
map
((
message
)
=>
({
role
:
message
.
role
,
text
:
message
.
text
,
})),
draft
:
chatSessionId
,
}),
});
if
(
!
contextResponse
.
ok
)
{
throw
new
Error
(
`Retrieval failed:
${
contextResponse
.
status
}
`
);
}
const
sources
:
ContextSource
[]
=
await
contextResponse
.
json
();
const
nextDirect
:
RetrievedDoc
[]
=
[];
const
nextTask
:
RetrievedDoc
[]
=
[];
const
nextIndirect
:
RetrievedDoc
[]
=
[];
const
nextSubsections
:
RetrievedDoc
[]
=
[];
const
nextSections
:
RetrievedDoc
[]
=
[];
sources
.
forEach
((
source
,
index
)
=>
{
const
doc
=
toRetrievedDoc
(
source
,
index
);
switch
(
source
.
retrieved_as
)
{
case
"
children_direct
"
:
nextDirect
.
push
(
doc
);
break
;
case
"
children_expanded
"
:
nextIndirect
.
push
(
doc
);
break
;
case
"
task_childs
"
:
nextTask
.
push
(
doc
);
break
;
case
"
subsections
"
:
nextSubsections
.
push
(
doc
);
break
;
case
"
sections
"
:
nextSections
.
push
(
doc
);
break
;
case
"
neighbors
"
:
nextIndirect
.
push
(
doc
);
break
;
default
:
nextIndirect
.
push
(
doc
);
}
});
setDirectChildren
(
nextDirect
);
setTaskChildren
(
nextTask
);
setIndirectChildren
(
nextIndirect
);
setSubsections
(
nextSubsections
);
setSections
(
nextSections
);
}
catch
(
error
)
{
setRetrievalError
(
t
(
"
retrievalFailed
"
));
void
error
;
}
finally
{
setRetrievalLoading
(
false
);
}
};
const
handleToggleCanvas
=
()
=>
{
setIsCanvasVisible
((
prev
)
=>
!
prev
);
};
const
resetChatState
=
()
=>
{
setMessages
(
initialMessages
);
setDraft
(
""
);
setDirectChildren
([]);
setTaskChildren
([]);
setIndirectChildren
([]);
setSubsections
([]);
setSections
([]);
setRetrievalLoading
(
false
);
setRetrievalError
(
null
);
setCanvasStatus
(
null
);
setIsCanvasVisible
(
false
);
};
const
loadArchives
=
async
()
=>
{
setArchiveError
(
null
);
try
{
const
response
=
await
fetch
(
`/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
(
t
(
"
failedLoadSavedChats
"
));
void
error
;
}
};
const
handleLoadArchive
=
async
(
chatId
?:
string
)
=>
{
const
targetId
=
chatId
||
selectedArchiveId
;
if
(
!
targetId
)
{
return
;
}
setArchiveError
(
null
);
try
{
const
response
=
await
fetch
(
`/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
);
if
(
isTaskModeEnabled
)
{
const
restoredTask
=
payload
.
selected_task
;
if
(
restoredTask
?.
file_id
&&
restoredTask
?.
task_id
)
{
setTaskRef
({
fileId
:
restoredTask
.
file_id
,
taskId
:
restoredTask
.
task_id
,
});
lockTask
();
try
{
await
selectTask
({
draft
:
payload
.
chat_id
,
fileId
:
restoredTask
.
file_id
,
taskId
:
restoredTask
.
task_id
,
});
}
catch
(
error
)
{
void
error
;
}
}
else
{
setTaskRef
(
null
);
unlockTask
();
navigate
(
"
/select-task
"
,
{
replace
:
true
});
}
}
}
catch
(
error
)
{
setArchiveError
(
t
(
"
failedLoadSelectedChat
"
));
void
error
;
}
};
const
handleNewChat
=
async
()
=>
{
if
(
messages
.
length
)
{
setIsArchiving
(
true
);
try
{
await
fetch
(
`/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
();
resetForNewChat
();
await
loadArchives
();
navigate
(
isTaskModeEnabled
?
"
/select-task
"
:
"
/chat
"
);
};
const
handleOpenSidebar
=
()
=>
{
setIsSidebarOpen
(
true
);
void
loadArchives
();
};
const
handleCanvasSave
=
async
(
dataUrl
:
string
)
=>
{
setCanvasStatus
({
kind
:
"
info
"
,
message
:
t
(
"
savingDrawingConverting
"
),
});
try
{
const
response
=
await
fetch
(
`/api/canvas/save`
,
{
method
:
"
POST
"
,
headers
:
{
"
Content-Type
"
:
"
application/json
"
},
body
:
JSON
.
stringify
({
data_url
:
dataUrl
,
filename_hint
:
"
canvas
"
}),
});
if
(
!
response
.
ok
)
{
let
message
=
t
(
"
saveFailedStatus
"
,
{
status
:
response
.
status
});
try
{
const
errorPayload
:
{
detail
?:
string
}
=
await
response
.
json
();
if
(
errorPayload
.
detail
)
{
message
=
errorPayload
.
detail
;
}
}
catch
(
error
)
{
void
error
;
}
setCanvasStatus
({
kind
:
"
error
"
,
message
});
return
;
}
const
payload
:
{
status
?:
string
;
latex
?:
string
}
=
await
response
.
json
();
if
(
payload
.
status
&&
payload
.
status
!==
"
ok
"
)
{
setCanvasStatus
({
kind
:
"
error
"
,
message
:
payload
.
latex
||
t
(
"
canvasSaveFailed
"
),
});
return
;
}
const
latex
=
payload
.
latex
;
if
(
typeof
latex
===
"
string
"
&&
latex
.
length
>
0
)
{
setDraft
((
prev
)
=>
(
prev
?
`
${
prev
}
${
latex
}
`
:
latex
));
setCanvasStatus
({
kind
:
"
ok
"
,
message
:
t
(
"
canvasSaved
"
)
});
setIsCanvasVisible
(
false
);
}
}
catch
(
error
)
{
setCanvasStatus
({
kind
:
"
error
"
,
message
:
t
(
"
canvasSaveFailedCheckLogs
"
),
});
void
error
;
}
};
const
handleCiteDoc
=
(
docId
:
string
)
=>
{
setDraft
((
prev
)
=>
(
prev
?
`
${
prev
}
[
${
docId
}
]`
:
`[
${
docId
}
]`
));
};
const
handleInspectDoc
=
(
doc
:
RetrievedDoc
)
=>
{
const
title
=
doc
.
metadata
.
title
||
doc
.
metadata
.
subsection_title
||
doc
.
metadata
.
section_title
||
doc
.
metadata
.
path
||
t
(
"
untitled
"
);
const
html
=
`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
${
title
}
</title>
<style>
body { font-family: "Space Grotesk", sans-serif; margin: 24px; color: #1d1b16; }
h1 { font-size: 22px; margin-bottom: 12px; }
.meta { color: #6f675d; font-size: 12px; margin-bottom: 16px; }
.content { max-width: 900px; }
pre, code { background: #f6f0e4; padding: 2px 4px; border-radius: 4px; }
</style>
</head>
<body>
<h1 id="title"></h1>
<div class="meta" id="meta"></div>
<div class="content" id="content"></div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
const markdown =
${
JSON
.
stringify
(
doc
.
markdown
)}
;
const title =
${
JSON
.
stringify
(
title
)}
;
const path =
${
JSON
.
stringify
(
doc
.
metadata
.
path
||
""
)}
;
document.getElementById("title").textContent = title;
document.getElementById("meta").textContent = path;
document.getElementById("content").innerHTML = marked.parse(markdown);
</script>
<script>
window.MathJax = {
tex: {
inlineMath: [["$", "$"], ["\\\\(", "\\\\)"]],
displayMath: [["$$", "$$"], ["\\\\[", "\\\\]"]],
}
};
</script>
<script
id="mathjax-script"
async
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"
></script>
</body>
</html>`
;
const
tab
=
window
.
open
(
""
,
"
_blank
"
);
if
(
!
tab
)
{
return
;
}
tab
.
document
.
open
();
tab
.
document
.
write
(
html
);
tab
.
document
.
close
();
};
if
(
!
isTasksInitialized
)
{
return
<
div
className
=
"app-loading"
>
{
t
(
"
loading
"
)
}
</
div
>;
}
return
(
<
div
className
=
"app-shell"
>
<
header
className
=
"app-header"
>
<
button
type
=
"button"
className
=
"btn sidebar-toggle"
onClick
=
{
handleOpenSidebar
}
>
{
t
(
"
chats
"
)
}
</
button
>
<
div
className
=
"brand"
>
<
span
className
=
"brand-mark"
>
SUM
</
span
>
<
div
className
=
"brand-text"
>
<
div
className
=
"brand-title"
>
Math Tutor
</
div
>
<
div
className
=
"brand-subtitle"
>
{
t
(
"
prototypeWorkspace
"
)
}
</
div
>
</
div
>
</
div
>
<
div
className
=
"header-meta"
>
<
span
className
=
"pill"
>
Postgres + pgvector
</
span
>
<
span
className
=
"pill"
>
Python FastAPI
</
span
>
<
span
className
=
"pill"
>
Mathpix
</
span
>
</
div
>
</
header
>
<
main
className
=
"app-main"
>
<
section
className
=
"chat-column"
>
<
ChatWindow
messages
=
{
messages
}
draft
=
{
draft
}
onDraftChange
=
{
setDraft
}
onSend
=
{
handleSend
}
onToggleCanvas
=
{
handleToggleCanvas
}
onInspectDoc
=
{
handleInspectDoc
}
docIndex
=
{
docIndexes
.
bySourceKey
}
docSlugIndex
=
{
docIndexes
.
bySlug
}
/>
{
isCanvasVisible
?
(
<
CanvasDrawer
isVisible
=
{
isCanvasVisible
}
onToggle
=
{
handleToggleCanvas
}
onSave
=
{
handleCanvasSave
}
statusMessage
=
{
canvasStatus
?.
message
}
statusKind
=
{
canvasStatus
?.
kind
}
/>
)
:
null
}
</
section
>
<
aside
className
=
"retrieval-column"
>
{
isTaskModeEnabled
&&
selectedTask
?
(
<
TaskPanel
readOnly
selectedTaskText
=
{
selectedTask
.
fullText
}
selectedFileLabel
=
{
selectedTask
.
title
||
selectedTask
.
fileId
}
selectedTaskId
=
{
selectedTask
.
taskId
}
/>
)
:
null
}
<
DocPanel
directChildren
=
{
directChildren
}
taskChildren
=
{
taskChildren
}
indirectChildren
=
{
indirectChildren
}
subsections
=
{
subsections
}
sections
=
{
sections
}
isLoading
=
{
retrievalLoading
}
error
=
{
retrievalError
}
onCiteDoc
=
{
handleCiteDoc
}
onInspectDoc
=
{
handleInspectDoc
}
/>
</
aside
>
</
main
>
<
div
className
=
{
`sidebar
${
isSidebarOpen
?
"
active
"
:
""
}
`
}
>
<
div
className
=
"sd-header"
>
<
h4
className
=
"sd-title"
>
{
t
(
"
savedChats
"
)
}
</
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
?
t
(
"
saving
"
)
:
t
(
"
newChat
"
)
}
</
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"
>
{
t
(
"
noSavedChatsYet
"
)
}
</
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/pages/TaskSelectionPage.tsx
0 → 100644
View file @
4cb2901e
import
{
useEffect
,
useRef
}
from
"
react
"
;
import
{
useNavigate
}
from
"
react-router-dom
"
;
import
"
../styles/theme.css
"
;
import
{
t
}
from
"
../i18n
"
;
import
{
selectTask
}
from
"
../api/taskApi
"
;
import
{
useTutorSession
}
from
"
../state/tutorSession
"
;
export
default
function
TaskSelectionPage
()
{
const
navigate
=
useNavigate
();
const
{
chatSessionId
,
isTaskModeEnabled
,
isTasksInitialized
,
selectedTaskRef
,
selectedTask
,
taskFileOptions
,
taskOptions
,
tasksError
,
setTaskFile
,
setTaskId
,
lockTask
,
unlockTask
,
}
=
useTutorSession
();
const
taskDisplayRef
=
useRef
<
HTMLDivElement
|
null
>
(
null
);
useEffect
(()
=>
{
if
(
!
isTasksInitialized
)
{
return
;
}
if
(
!
isTaskModeEnabled
)
{
navigate
(
"
/chat
"
,
{
replace
:
true
});
return
;
}
unlockTask
();
},
[
isTaskModeEnabled
,
isTasksInitialized
,
navigate
,
unlockTask
]);
useEffect
(()
=>
{
if
(
!
selectedTask
?.
fullText
||
!
taskDisplayRef
.
current
)
{
return
;
}
const
mathjax
=
window
.
MathJax
;
if
(
!
mathjax
?.
typesetPromise
)
{
return
;
}
mathjax
.
typesetPromise
([
taskDisplayRef
.
current
]).
catch
(()
=>
undefined
);
},
[
selectedTask
?.
fullText
]);
const
handleSolveWithTutor
=
async
()
=>
{
if
(
!
selectedTaskRef
)
{
return
;
}
try
{
await
selectTask
({
draft
:
chatSessionId
,
fileId
:
selectedTaskRef
.
fileId
,
taskId
:
selectedTaskRef
.
taskId
,
});
lockTask
();
navigate
(
"
/chat
"
);
}
catch
(
error
)
{
void
error
;
}
};
if
(
!
isTasksInitialized
)
{
return
<
div
className
=
"app-loading"
>
{
t
(
"
loading
"
)
}
</
div
>;
}
return
(
<
div
className
=
"app-shell"
>
<
header
className
=
"app-header"
>
<
div
className
=
"brand"
>
<
span
className
=
"brand-mark"
>
SUM
</
span
>
<
div
className
=
"brand-text"
>
<
div
className
=
"brand-title"
>
Math Tutor
</
div
>
<
div
className
=
"brand-subtitle"
>
{
t
(
"
taskSelectionSubtitle
"
)
}
</
div
>
</
div
>
</
div
>
</
header
>
<
main
className
=
"task-select-main"
>
<
section
className
=
"task-select-card"
>
<
div
className
=
"task-select-header"
>
<
h2
className
=
"task-select-title"
>
{
t
(
"
taskSelectionTitle
"
)
}
</
h2
>
</
div
>
<
div
className
=
"task-select-controls"
>
<
label
className
=
"task-select-label"
htmlFor
=
"task-file-select"
>
{
t
(
"
taskFile
"
)
}
</
label
>
<
select
id
=
"task-file-select"
className
=
"task-select"
value
=
{
selectedTaskRef
?.
fileId
||
""
}
onChange
=
{
(
event
)
=>
setTaskFile
(
event
.
target
.
value
)
}
disabled
=
{
!
taskFileOptions
.
length
}
>
{
taskFileOptions
.
length
?
(
taskFileOptions
.
map
((
option
)
=>
(
<
option
key
=
{
option
.
value
}
value
=
{
option
.
value
}
>
{
option
.
label
}
</
option
>
))
)
:
(
<
option
value
=
""
>
{
t
(
"
noTasksAvailable
"
)
}
</
option
>
)
}
</
select
>
<
label
className
=
"task-select-label"
htmlFor
=
"task-id-select"
>
{
t
(
"
taskId
"
)
}
</
label
>
<
select
id
=
"task-id-select"
className
=
"task-select"
value
=
{
selectedTaskRef
?.
taskId
||
""
}
onChange
=
{
(
event
)
=>
setTaskId
(
event
.
target
.
value
)
}
disabled
=
{
!
taskOptions
.
length
}
>
{
taskOptions
.
length
?
(
taskOptions
.
map
((
option
)
=>
(
<
option
key
=
{
option
.
value
}
value
=
{
option
.
value
}
>
{
option
.
label
}
</
option
>
))
)
:
(
<
option
value
=
""
>
{
t
(
"
noTasksAvailable
"
)
}
</
option
>
)
}
</
select
>
</
div
>
<
div
className
=
"task-panel-content"
ref
=
{
taskDisplayRef
}
>
{
selectedTask
?.
fullText
||
t
(
"
noTaskSelected
"
)
}
</
div
>
{
tasksError
?
<
div
className
=
"task-panel-error"
>
{
tasksError
}
</
div
>
:
null
}
<
button
type
=
"button"
className
=
"btn primary task-solve-btn"
onClick
=
{
handleSolveWithTutor
}
disabled
=
{
!
selectedTaskRef
}
>
{
t
(
"
solveWithTutor
"
)
}
</
button
>
</
section
>
</
main
>
</
div
>
);
}
math-tutor/frontend/src/state/tutorSession.tsx
0 → 100644
View file @
4cb2901e
/* eslint-disable react-refresh/only-export-components */
import
{
useCallback
,
createContext
,
useContext
,
useEffect
,
useMemo
,
useState
,
type
PropsWithChildren
,
}
from
"
react
"
;
import
{
t
}
from
"
../i18n
"
;
import
{
fetchTasks
,
type
SelectedTaskRef
,
type
TaskFile
}
from
"
../api/taskApi
"
;
export
type
SelectOption
=
{
value
:
string
;
label
:
string
;
};
export
type
SelectedTask
=
SelectedTaskRef
&
{
title
:
string
;
fullText
:
string
;
};
export
type
TaskSelectionState
=
{
taskFiles
:
TaskFile
[];
selectedTaskRef
:
SelectedTaskRef
|
null
;
selectedTask
:
SelectedTask
|
null
;
selectedTaskFile
:
TaskFile
|
null
;
taskFileOptions
:
SelectOption
[];
taskOptions
:
SelectOption
[];
tasksError
:
string
|
null
;
isTaskModeEnabled
:
boolean
;
isTasksInitialized
:
boolean
;
taskLocked
:
boolean
;
};
export
type
TutorSessionState
=
TaskSelectionState
&
{
chatSessionId
:
string
;
setChatSessionId
:
(
value
:
string
)
=>
void
;
initTasks
:
()
=>
Promise
<
void
>
;
setTaskRef
:
(
value
:
SelectedTaskRef
|
null
)
=>
void
;
setTaskFile
:
(
fileId
:
string
)
=>
void
;
setTaskId
:
(
taskId
:
string
)
=>
void
;
lockTask
:
()
=>
void
;
unlockTask
:
()
=>
void
;
resetForNewChat
:
()
=>
void
;
};
const
TutorSessionContext
=
createContext
<
TutorSessionState
|
null
>
(
null
);
export
const
createSessionId
=
()
=>
`session_
${
Date
.
now
().
toString
(
36
)}
_
${
Math
.
random
().
toString
(
36
).
slice
(
2
,
8
)}
`
;
export
const
getDefaultTaskId
=
(
tasks
:
Array
<
{
task_id
:
string
}
>
):
string
=>
tasks
.
find
((
task
)
=>
task
.
task_id
===
"
01
"
)?.
task_id
||
tasks
[
0
]?.
task_id
||
""
;
export
function
TutorSessionProvider
({
children
}:
PropsWithChildren
)
{
const
[
chatSessionId
,
setChatSessionId
]
=
useState
<
string
>
(()
=>
createSessionId
());
const
[
isTaskModeEnabled
,
setIsTaskModeEnabled
]
=
useState
(
false
);
const
[
isTasksInitialized
,
setIsTasksInitialized
]
=
useState
(
false
);
const
[
taskFiles
,
setTaskFiles
]
=
useState
<
TaskFile
[]
>
([]);
const
[
selectedTaskRef
,
setSelectedTaskRef
]
=
useState
<
SelectedTaskRef
|
null
>
(
null
);
const
[
tasksError
,
setTasksError
]
=
useState
<
string
|
null
>
(
null
);
const
[
taskLocked
,
setTaskLocked
]
=
useState
(
false
);
const
selectedTask
=
useMemo
<
SelectedTask
|
null
>
(()
=>
{
if
(
!
selectedTaskRef
)
{
return
null
;
}
const
file
=
taskFiles
.
find
((
item
)
=>
item
.
file_id
===
selectedTaskRef
.
fileId
);
if
(
!
file
)
{
return
null
;
}
const
task
=
file
.
tasks
.
find
((
item
)
=>
item
.
task_id
===
selectedTaskRef
.
taskId
);
if
(
!
task
)
{
return
null
;
}
return
{
fileId
:
file
.
file_id
,
taskId
:
task
.
task_id
,
title
:
file
.
title
,
fullText
:
task
.
full_text
,
};
},
[
selectedTaskRef
,
taskFiles
]);
const
selectedTaskFile
=
useMemo
(
()
=>
taskFiles
.
find
((
file
)
=>
file
.
file_id
===
selectedTaskRef
?.
fileId
)
||
null
,
[
selectedTaskRef
?.
fileId
,
taskFiles
]
);
const
taskFileOptions
=
useMemo
(
()
=>
taskFiles
.
map
((
file
)
=>
({
value
:
file
.
file_id
,
label
:
file
.
title
||
file
.
file_id
,
})),
[
taskFiles
]
);
const
taskOptions
=
useMemo
(
()
=>
(
selectedTaskFile
?.
tasks
||
[]).
map
((
task
)
=>
({
value
:
task
.
task_id
,
label
:
task
.
task_id
,
})),
[
selectedTaskFile
]
);
const
initTasks
=
useCallback
(
async
()
=>
{
setTasksError
(
null
);
try
{
const
payload
=
await
fetchTasks
();
const
enabled
=
Boolean
(
payload
.
enabled
);
const
files
=
payload
.
task_files
||
[];
setIsTaskModeEnabled
(
enabled
);
setTaskFiles
(
files
);
if
(
!
enabled
)
{
setSelectedTaskRef
(
null
);
}
else
{
setSelectedTaskRef
((
prev
)
=>
{
if
(
prev
)
{
const
file
=
files
.
find
((
item
)
=>
item
.
file_id
===
prev
.
fileId
);
if
(
file
&&
file
.
tasks
.
some
((
task
)
=>
task
.
task_id
===
prev
.
taskId
))
{
return
prev
;
}
}
const
firstFile
=
files
[
0
];
if
(
!
firstFile
)
{
return
null
;
}
const
defaultTaskId
=
getDefaultTaskId
(
firstFile
.
tasks
||
[]);
if
(
!
defaultTaskId
)
{
return
null
;
}
return
{
fileId
:
firstFile
.
file_id
,
taskId
:
defaultTaskId
};
});
}
}
catch
(
error
)
{
setTasksError
(
t
(
"
failedLoadTasks
"
));
setIsTaskModeEnabled
(
false
);
setTaskFiles
([]);
setSelectedTaskRef
(
null
);
void
error
;
}
finally
{
setIsTasksInitialized
(
true
);
}
},
[]);
useEffect
(()
=>
{
void
initTasks
();
},
[
initTasks
]);
const
setTaskFile
=
useCallback
((
fileId
:
string
)
=>
{
if
(
!
fileId
)
{
return
;
}
const
file
=
taskFiles
.
find
((
item
)
=>
item
.
file_id
===
fileId
);
if
(
!
file
)
{
return
;
}
const
defaultTaskId
=
getDefaultTaskId
(
file
.
tasks
||
[]);
if
(
!
defaultTaskId
)
{
return
;
}
setSelectedTaskRef
({
fileId
:
file
.
file_id
,
taskId
:
defaultTaskId
});
},
[
taskFiles
]);
const
setTaskId
=
useCallback
((
taskId
:
string
)
=>
{
if
(
!
taskId
||
!
selectedTaskRef
?.
fileId
)
{
return
;
}
setSelectedTaskRef
({
fileId
:
selectedTaskRef
.
fileId
,
taskId
});
},
[
selectedTaskRef
]);
const
lockTask
=
useCallback
(()
=>
{
setTaskLocked
(
true
);
},
[]);
const
unlockTask
=
useCallback
(()
=>
{
setTaskLocked
(
false
);
},
[]);
const
resetForNewChat
=
useCallback
(()
=>
{
setChatSessionId
(
createSessionId
());
setSelectedTaskRef
(
null
);
setTaskLocked
(
false
);
},
[]);
const
value
:
TutorSessionState
=
{
chatSessionId
,
setChatSessionId
,
initTasks
,
taskFiles
,
selectedTaskRef
,
selectedTask
,
selectedTaskFile
,
taskFileOptions
,
taskOptions
,
setTaskRef
:
setSelectedTaskRef
,
setTaskFile
,
setTaskId
,
tasksError
,
isTaskModeEnabled
,
isTasksInitialized
,
taskLocked
,
lockTask
,
unlockTask
,
resetForNewChat
,
};
return
<
TutorSessionContext
.
Provider
value
=
{
value
}
>
{
children
}
</
TutorSessionContext
.
Provider
>;
}
export
function
useTutorSession
():
TutorSessionState
{
const
context
=
useContext
(
TutorSessionContext
);
if
(
!
context
)
{
throw
new
Error
(
"
useTutorSession must be used within TutorSessionProvider
"
);
}
return
context
;
}
math-tutor/frontend/src/styles/theme.css
View file @
4cb2901e
...
@@ -572,3 +572,76 @@ body {
...
@@ -572,3 +572,76 @@ body {
margin-left
:
0
;
margin-left
:
0
;
}
}
}
}
.app-loading
{
min-height
:
100vh
;
display
:
grid
;
place-items
:
center
;
font-size
:
14px
;
color
:
#6f675d
;
}
.task-select-main
{
flex
:
1
;
display
:
grid
;
place-items
:
center
;
}
.task-select-card
{
width
:
min
(
820px
,
100%
);
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
background
:
#fef9f0
;
border
:
1px
solid
#d8d1c4
;
border-radius
:
16px
;
padding
:
18px
;
}
.task-select-title
{
margin
:
0
;
font-size
:
20px
;
}
.task-select-controls
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
10px
;
}
.task-select-label
{
font-size
:
12px
;
color
:
#6f675d
;
}
.task-solve-btn
{
align-self
:
flex-end
;
}
.task-panel-lock
{
margin-left
:
auto
;
font-size
:
12px
;
color
:
#6f675d
;
border
:
1px
solid
#d8d1c4
;
border-radius
:
999px
;
padding
:
4px
8px
;
background
:
#fff
;
}
.task-panel-meta
{
display
:
flex
;
justify-content
:
space-between
;
gap
:
8px
;
font-size
:
12px
;
color
:
#6f675d
;
}
@media
(
max-width
:
900px
)
{
.task-select-controls
{
grid-template-columns
:
1
fr
;
}
.task-solve-btn
{
width
:
100%
;
}
}
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