Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
S
Son Of Anton
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Son Of Anton
Commits
bde969b1
Commit
bde969b1
authored
Mar 17, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
jj
parent
18d64a46
Changes
5
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
505 additions
and
186 deletions
+505
-186
api.js
frontend/src/api.js
+3
-2
ChatView.jsx
frontend/src/components/ChatView.jsx
+131
-128
Sidebar.jsx
frontend/src/components/Sidebar.jsx
+133
-54
store.jsx
frontend/src/store.jsx
+61
-2
streamManager.js
frontend/src/streamManager.js
+177
-0
No files found.
frontend/src/api.js
View file @
bde969b1
...
@@ -49,12 +49,13 @@ export const deleteChat = (token, chatId) =>
...
@@ -49,12 +49,13 @@ export const deleteChat = (token, chatId) =>
export
const
getMessages
=
(
token
,
chatId
)
=>
export
const
getMessages
=
(
token
,
chatId
)
=>
request
(
"GET"
,
`/chats/
${
chatId
}
/messages`
,
token
);
request
(
"GET"
,
`/chats/
${
chatId
}
/messages`
,
token
);
/* ── Streaming message
──────────────────────
─ */
/* ── Streaming message
(accepts AbortSignal)
─ */
export
async
function
*
streamMessage
(
token
,
chatId
,
body
)
{
export
async
function
*
streamMessage
(
token
,
chatId
,
body
,
signal
)
{
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/messages`
,
{
const
res
=
await
fetch
(
`
${
BASE
}
/chats/
${
chatId
}
/messages`
,
{
method
:
"POST"
,
method
:
"POST"
,
headers
:
headers
(
token
),
headers
:
headers
(
token
),
body
:
JSON
.
stringify
(
body
),
body
:
JSON
.
stringify
(
body
),
signal
,
});
});
if
(
!
res
.
ok
)
{
if
(
!
res
.
ok
)
{
...
...
frontend/src/components/ChatView.jsx
View file @
bde969b1
import
React
,
{
useState
,
useEffect
,
useRef
,
useCallback
}
from
"react"
;
import
React
,
{
useState
,
useEffect
,
useRef
,
useCallback
}
from
"react"
;
import
{
useApp
}
from
"../store"
;
import
{
useApp
}
from
"../store"
;
import
{
import
{
getMessages
,
streamMessage
,
downloadZip
,
listKnowledgeBases
,
updateChat
,
getMessages
,
downloadZip
,
listKnowledgeBases
,
updateChat
,
}
from
"../api"
;
}
from
"../api"
;
import
*
as
streamManager
from
"../streamManager"
;
import
MessageBubble
from
"./MessageBubble"
;
import
MessageBubble
from
"./MessageBubble"
;
import
{
import
{
Send
,
Square
,
Settings2
,
X
,
Brain
,
BookOpen
,
ChevronDown
,
Send
,
Square
,
Settings2
,
X
,
Brain
,
BookOpen
,
ChevronDown
,
...
@@ -16,27 +17,37 @@ const MODELS = [
...
@@ -16,27 +17,37 @@ const MODELS = [
export
default
function
ChatView
({
chatId
})
{
export
default
function
ChatView
({
chatId
})
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
// ──
Load p
ersisted settings from the chat object ──
// ──
P
ersisted settings from the chat object ──
const
currentChat
=
state
.
chats
.
find
((
c
)
=>
c
.
id
===
chatId
);
const
currentChat
=
state
.
chats
.
find
((
c
)
=>
c
.
id
===
chatId
);
const
[
messages
,
setMessages
]
=
useState
([]);
const
messages
=
state
.
chatMessages
[
chatId
]
||
[];
const
isStreamingGlobal
=
!!
state
.
activeStreams
[
chatId
];
const
[
input
,
setInput
]
=
useState
(
""
);
const
[
input
,
setInput
]
=
useState
(
""
);
const
[
streaming
,
setStreaming
]
=
useState
(
false
);
const
[
showSettings
,
setShowSettings
]
=
useState
(
false
);
const
[
showSettings
,
setShowSettings
]
=
useState
(
false
);
const
[
model
,
setModel
]
=
useState
(
currentChat
?.
model
||
MODELS
[
0
].
id
);
const
[
model
,
setModel
]
=
useState
(
currentChat
?.
model
||
MODELS
[
0
].
id
);
const
[
maxTokens
,
setMaxTokens
]
=
useState
(
currentChat
?.
max_tokens
||
4096
);
const
[
maxTokens
,
setMaxTokens
]
=
useState
(
currentChat
?.
max_tokens
||
4096
);
const
[
reasoningBudget
,
setReasoningBudget
]
=
useState
(
currentChat
?.
reasoning_budget
??
0
);
const
[
reasoningBudget
,
setReasoningBudget
]
=
useState
(
currentChat
?.
reasoning_budget
??
0
);
const
[
selectedKbId
,
setSelectedKbId
]
=
useState
(
currentChat
?.
knowledge_base_id
||
null
);
const
[
selectedKbId
,
setSelectedKbId
]
=
useState
(
currentChat
?.
knowledge_base_id
||
null
);
const
[
kbs
,
setKbs
]
=
useState
([]);
const
[
kbs
,
setKbs
]
=
useState
([]);
const
[
streamText
,
setStreamText
]
=
useState
(
""
);
const
[
streamThinking
,
setStreamThinking
]
=
useState
(
""
);
// High-frequency stream data — lives outside the global store to avoid
const
[
isThinking
,
setIsThinking
]
=
useState
(
false
);
// re-rendering every component on every token
const
[
streamData
,
setStreamData
]
=
useState
(
streamManager
.
getStreamData
(
chatId
));
const
scrollContainerRef
=
useRef
(
null
);
const
scrollContainerRef
=
useRef
(
null
);
const
inputRef
=
useRef
(
null
);
const
inputRef
=
useRef
(
null
);
const
abortRef
=
useRef
(
null
);
const
shouldAutoScrollRef
=
useRef
(
true
);
const
shouldAutoScrollRef
=
useRef
(
true
);
const
rafRef
=
useRef
(
null
);
const
rafRef
=
useRef
(
null
);
// ── Subscribe to background stream data for THIS chat ──
useEffect
(()
=>
{
setStreamData
(
streamManager
.
getStreamData
(
chatId
));
const
unsub
=
streamManager
.
subscribe
(
chatId
,
()
=>
{
setStreamData
(
streamManager
.
getStreamData
(
chatId
));
});
return
unsub
;
},
[
chatId
]);
// ── Scroll helpers ──
// ── Scroll helpers ──
function
handleContainerScroll
()
{
function
handleContainerScroll
()
{
const
el
=
scrollContainerRef
.
current
;
const
el
=
scrollContainerRef
.
current
;
...
@@ -47,7 +58,7 @@ export default function ChatView({ chatId }) {
...
@@ -47,7 +58,7 @@ export default function ChatView({ chatId }) {
const
scrollToBottom
=
useCallback
(()
=>
{
const
scrollToBottom
=
useCallback
(()
=>
{
if
(
!
shouldAutoScrollRef
.
current
)
return
;
if
(
!
shouldAutoScrollRef
.
current
)
return
;
if
(
rafRef
.
current
)
return
;
// already scheduled
if
(
rafRef
.
current
)
return
;
rafRef
.
current
=
requestAnimationFrame
(()
=>
{
rafRef
.
current
=
requestAnimationFrame
(()
=>
{
const
el
=
scrollContainerRef
.
current
;
const
el
=
scrollContainerRef
.
current
;
if
(
el
)
el
.
scrollTop
=
el
.
scrollHeight
;
if
(
el
)
el
.
scrollTop
=
el
.
scrollHeight
;
...
@@ -55,26 +66,28 @@ export default function ChatView({ chatId }) {
...
@@ -55,26 +66,28 @@ export default function ChatView({ chatId }) {
});
});
},
[]);
},
[]);
// ── Load messages & KB
s
on mount ──
// ── Load messages & KB
list
on mount ──
useEffect
(()
=>
{
useEffect
(()
=>
{
(
async
()
=>
{
(
async
()
=>
{
try
{
try
{
const
msgs
=
await
getMessages
(
state
.
token
,
chatId
);
const
[
msgs
,
kbData
]
=
await
Promise
.
all
([
setMessages
(
msgs
);
getMessages
(
state
.
token
,
chatId
),
const
kbData
=
await
listKnowledgeBases
(
state
.
token
);
listKnowledgeBases
(
state
.
token
),
]);
dispatch
({
type
:
"SET_MESSAGES"
,
chatId
,
messages
:
msgs
});
setKbs
(
kbData
);
setKbs
(
kbData
);
}
catch
{
/* */
}
}
catch
{
/* */
}
})();
})();
},
[
chatId
,
state
.
token
]);
},
[
chatId
,
state
.
token
,
dispatch
]);
// Scroll when
messages change or stream updat
es
// Scroll when
content chang
es
useEffect
(
scrollToBottom
,
[
messages
,
stream
Text
,
streamT
hinking
,
scrollToBottom
]);
useEffect
(
scrollToBottom
,
[
messages
,
stream
Data
.
text
,
streamData
.
t
hinking
,
scrollToBottom
]);
useEffect
(()
=>
{
useEffect
(()
=>
{
inputRef
.
current
?.
focus
();
inputRef
.
current
?.
focus
();
},
[
chatId
]);
},
[
chatId
]);
// ── S
ave settings to backend
──
// ── S
ettings persistence
──
async
function
saveSettings
()
{
async
function
saveSettings
()
{
const
data
=
{
const
data
=
{
model
,
model
,
...
@@ -86,115 +99,67 @@ export default function ChatView({ chatId }) {
...
@@ -86,115 +99,67 @@ export default function ChatView({ chatId }) {
await
updateChat
(
state
.
token
,
chatId
,
data
);
await
updateChat
(
state
.
token
,
chatId
,
data
);
dispatch
({
dispatch
({
type
:
"UPDATE_CHAT"
,
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
},
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
},
});
});
}
catch
{
/*
ignore
*/
}
}
catch
{
/* */
}
}
}
function
toggleSettings
()
{
function
toggleSettings
()
{
const
closing
=
showSettings
;
const
closing
=
showSettings
;
setShowSettings
(
!
showSettings
);
setShowSettings
(
!
showSettings
);
if
(
closing
)
{
if
(
closing
)
saveSettings
();
saveSettings
();
}
}
}
// ── Send message ──
// ── Send message ──
async
function
handleSend
()
{
function
handleSend
()
{
const
content
=
input
.
trim
();
const
content
=
input
.
trim
();
if
(
!
content
||
streaming
)
return
;
if
(
!
content
||
isStreamingGlobal
)
return
;
const
userMsg
=
{
id
:
`tmp-
${
Date
.
now
()}
`
,
role
:
"user"
,
content
,
created_at
:
new
Date
().
toISOString
()
};
// Optimistic user message
setMessages
((
p
)
=>
[...
p
,
userMsg
]);
const
userMsg
=
{
id
:
`tmp-
${
Date
.
now
()}
`
,
role
:
"user"
,
content
,
created_at
:
new
Date
().
toISOString
(),
};
dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
userMsg
});
setInput
(
""
);
setInput
(
""
);
setStreaming
(
true
);
shouldAutoScrollRef
.
current
=
true
;
setStreamText
(
""
);
setStreamThinking
(
""
);
setIsThinking
(
false
);
shouldAutoScrollRef
.
current
=
true
;
// Force scroll for user's own message
const
ac
=
new
AbortController
();
// Sync settings to store immediately
abortRef
.
current
=
ac
;
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
},
});
try
{
// Kick off background stream — survives chat switching
const
body
=
{
streamManager
.
startStream
({
token
:
state
.
token
,
chatId
,
body
:
{
content
,
content
,
model
,
model
,
max_tokens
:
maxTokens
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
,
knowledge_base_id
:
selectedKbId
,
};
},
});
let
fullText
=
""
;
let
fullThinking
=
""
;
let
usage
=
{};
let
msgId
=
""
;
for
await
(
const
evt
of
streamMessage
(
state
.
token
,
chatId
,
body
))
{
if
(
ac
.
signal
.
aborted
)
break
;
switch
(
evt
.
type
)
{
case
"thinking_start"
:
setIsThinking
(
true
);
break
;
case
"thinking_delta"
:
fullThinking
+=
evt
.
content
;
setStreamThinking
(
fullThinking
);
break
;
case
"thinking_end"
:
setIsThinking
(
false
);
break
;
case
"text_delta"
:
fullText
+=
evt
.
content
;
setStreamText
(
fullText
);
break
;
case
"usage"
:
usage
=
{
input_tokens
:
evt
.
input_tokens
,
output_tokens
:
evt
.
output_tokens
};
break
;
case
"title_update"
:
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
title
:
evt
.
title
}
});
break
;
case
"done"
:
msgId
=
evt
.
message_id
;
break
;
case
"error"
:
fullText
+=
`\n\n**Error:**
${
evt
.
message
}
`
;
setStreamText
(
fullText
);
break
;
}
}
const
assistantMsg
=
{
id
:
msgId
||
`gen-
${
Date
.
now
()}
`
,
role
:
"assistant"
,
content
:
fullText
,
thinking_content
:
fullThinking
||
null
,
input_tokens
:
usage
.
input_tokens
||
0
,
output_tokens
:
usage
.
output_tokens
||
0
,
created_at
:
new
Date
().
toISOString
(),
};
setMessages
((
p
)
=>
[...
p
,
assistantMsg
]);
// Sync settings to store (backend already saved them from the message)
dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
model
,
max_tokens
:
maxTokens
,
reasoning_budget
:
reasoningBudget
,
knowledge_base_id
:
selectedKbId
},
});
}
catch
(
err
)
{
setMessages
((
p
)
=>
[
...
p
,
{
id
:
`err-
${
Date
.
now
()}
`
,
role
:
"assistant"
,
content
:
`**Error:**
${
err
.
message
}
`
,
created_at
:
new
Date
().
toISOString
()
},
]);
}
finally
{
setStreamText
(
""
);
setStreamThinking
(
""
);
setStreaming
(
false
);
abortRef
.
current
=
null
;
}
}
}
function
handleStop
()
{
function
handleStop
()
{
abortRef
.
current
?.
abort
(
);
streamManager
.
abortStream
(
chatId
);
}
}
function
handleKeyDown
(
e
)
{
function
handleKeyDown
(
e
)
{
...
@@ -210,10 +175,14 @@ export default function ChatView({ chatId }) {
...
@@ -210,10 +175,14 @@ export default function ChatView({ chatId }) {
.
map
((
m
)
=>
m
.
content
)
.
map
((
m
)
=>
m
.
content
)
.
join
(
"
\n\n
---
\n\n
"
);
.
join
(
"
\n\n
---
\n\n
"
);
if
(
all
)
{
if
(
all
)
{
try
{
await
downloadZip
(
state
.
token
,
all
);
}
catch
{
/* */
}
try
{
await
downloadZip
(
state
.
token
,
all
);
}
catch
{
/* */
}
}
}
}
}
const
streaming
=
streamData
.
streaming
;
return
(
return
(
<
div
className=
"flex-1 flex flex-col min-h-0"
>
<
div
className=
"flex-1 flex flex-col min-h-0"
>
{
/* Messages */
}
{
/* Messages */
}
...
@@ -226,21 +195,22 @@ export default function ChatView({ chatId }) {
...
@@ -226,21 +195,22 @@ export default function ChatView({ chatId }) {
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
/>
<
MessageBubble
key=
{
m
.
id
}
message=
{
m
}
/>
))
}
))
}
{
/* Streaming
in progress
*/
}
{
/* Streaming
overlay — in-progress response
*/
}
{
streaming
&&
(
stream
Thinking
||
streamT
ext
)
&&
(
{
streaming
&&
(
stream
Data
.
thinking
||
streamData
.
t
ext
)
&&
(
<
MessageBubble
<
MessageBubble
message=
{
{
message=
{
{
id
:
"streaming"
,
id
:
"streaming"
,
role
:
"assistant"
,
role
:
"assistant"
,
content
:
stream
T
ext
,
content
:
stream
Data
.
t
ext
,
thinking_content
:
stream
T
hinking
||
null
,
thinking_content
:
stream
Data
.
t
hinking
||
null
,
}
}
}
}
isStreaming
isStreaming
isThinking=
{
isThinking
}
isThinking=
{
streamData
.
isThinking
}
/>
/>
)
}
)
}
{
streaming
&&
!
streamText
&&
!
streamThinking
&&
(
{
/* Waiting indicator */
}
{
streaming
&&
!
streamData
.
text
&&
!
streamData
.
thinking
&&
(
<
div
className=
"flex items-center gap-2 px-4 py-3 animate-fade-in"
>
<
div
className=
"flex items-center gap-2 px-4 py-3 animate-fade-in"
>
<
div
className=
"flex gap-1"
>
<
div
className=
"flex gap-1"
>
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"0ms"
}
}
/>
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-bounce"
style=
{
{
animationDelay
:
"0ms"
}
}
/>
...
@@ -254,24 +224,30 @@ export default function ChatView({ chatId }) {
...
@@ -254,24 +224,30 @@ export default function ChatView({ chatId }) {
{
/* Input area */
}
{
/* Input area */
}
<
div
className=
"border-t border-anton-border bg-anton-surface p-4"
>
<
div
className=
"border-t border-anton-border bg-anton-surface p-4"
>
{
/* Settings
bar
*/
}
{
/* Settings
panel
*/
}
{
showSettings
&&
(
{
showSettings
&&
(
<
div
className=
"mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in"
>
<
div
className=
"mb-3 bg-anton-card border border-anton-border rounded-xl p-4 space-y-4 animate-fade-in"
>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"flex items-center justify-between"
>
<
h3
className=
"text-sm font-semibold text-white flex items-center gap-1.5"
>
<
h3
className=
"text-sm font-semibold text-white flex items-center gap-1.5"
>
<
Settings2
size=
{
14
}
className=
"text-anton-accent"
/>
Generation Settings
<
Settings2
size=
{
14
}
className=
"text-anton-accent"
/>
Generation Settings
</
h3
>
</
h3
>
<
button
onClick=
{
toggleSettings
}
className=
"text-anton-muted hover:text-white"
><
X
size=
{
14
}
/></
button
>
<
button
onClick=
{
toggleSettings
}
className=
"text-anton-muted hover:text-white"
>
<
X
size=
{
14
}
/>
</
button
>
</
div
>
</
div
>
{
/* Model */
}
{
/* Model */
}
<
div
>
<
div
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Model
</
label
>
<
label
className=
"text-xs text-anton-muted mb-1 block"
>
Model
</
label
>
<
select
value=
{
model
}
onChange=
{
(
e
)
=>
setModel
(
e
.
target
.
value
)
}
<
select
value=
{
model
}
onChange=
{
(
e
)
=>
setModel
(
e
.
target
.
value
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
>
{
MODELS
.
map
((
m
)
=>
(
{
MODELS
.
map
((
m
)
=>
(
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>
<
option
key=
{
m
.
id
}
value=
{
m
.
id
}
>
{
m
.
label
}
</
option
>
))
}
))
}
</
select
>
</
select
>
</
div
>
</
div
>
...
@@ -282,11 +258,17 @@ export default function ChatView({ chatId }) {
...
@@ -282,11 +258,17 @@ export default function ChatView({ chatId }) {
<
span
className=
"text-anton-muted"
>
Max Output Tokens
</
span
>
<
span
className=
"text-anton-muted"
>
Max Output Tokens
</
span
>
<
span
className=
"text-anton-accent font-mono"
>
{
maxTokens
.
toLocaleString
()
}
</
span
>
<
span
className=
"text-anton-accent font-mono"
>
{
maxTokens
.
toLocaleString
()
}
</
span
>
</
div
>
</
div
>
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
<
input
type=
"range"
min=
{
256
}
max=
{
65536
}
step=
{
256
}
value=
{
maxTokens
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
onChange=
{
(
e
)
=>
setMaxTokens
(
Number
(
e
.
target
.
value
))
}
/>
/>
<
div
className=
"flex justify-between text-[10px] text-anton-muted mt-0.5"
>
<
div
className=
"flex justify-between text-[10px] text-anton-muted mt-0.5"
>
<
span
>
256
</
span
><
span
>
64K
</
span
>
<
span
>
256
</
span
>
<
span
>
64K
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -300,11 +282,17 @@ export default function ChatView({ chatId }) {
...
@@ -300,11 +282,17 @@ export default function ChatView({ chatId }) {
{
reasoningBudget
===
0
?
"Off"
:
reasoningBudget
.
toLocaleString
()
}
{
reasoningBudget
===
0
?
"Off"
:
reasoningBudget
.
toLocaleString
()
}
</
span
>
</
span
>
</
div
>
</
div
>
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
<
input
type=
"range"
min=
{
0
}
max=
{
32000
}
step=
{
500
}
value=
{
reasoningBudget
}
onChange=
{
(
e
)
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
onChange=
{
(
e
)
=>
setReasoningBudget
(
Number
(
e
.
target
.
value
))
}
/>
/>
<
div
className=
"flex justify-between text-[10px] text-anton-muted mt-0.5"
>
<
div
className=
"flex justify-between text-[10px] text-anton-muted mt-0.5"
>
<
span
>
Off
</
span
><
span
>
32K tokens
</
span
>
<
span
>
Off
</
span
>
<
span
>
32K tokens
</
span
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -313,12 +301,16 @@ export default function ChatView({ chatId }) {
...
@@ -313,12 +301,16 @@ export default function ChatView({ chatId }) {
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
>
<
label
className=
"text-xs text-anton-muted mb-1 flex items-center gap-1"
>
<
BookOpen
size=
{
12
}
/>
Knowledge Base (RAG)
<
BookOpen
size=
{
12
}
/>
Knowledge Base (RAG)
</
label
>
</
label
>
<
select
value=
{
selectedKbId
||
""
}
onChange=
{
(
e
)
=>
setSelectedKbId
(
e
.
target
.
value
||
null
)
}
<
select
value=
{
selectedKbId
||
""
}
onChange=
{
(
e
)
=>
setSelectedKbId
(
e
.
target
.
value
||
null
)
}
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
className=
"w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
>
<
option
value=
""
>
None
</
option
>
<
option
value=
""
>
None
</
option
>
{
kbs
.
map
((
kb
)
=>
(
{
kbs
.
map
((
kb
)
=>
(
<
option
key=
{
kb
.
id
}
value=
{
kb
.
id
}
>
{
kb
.
name
}
(
{
kb
.
document_count
}
docs)
</
option
>
<
option
key=
{
kb
.
id
}
value=
{
kb
.
id
}
>
{
kb
.
name
}
(
{
kb
.
document_count
}
docs)
</
option
>
))
}
))
}
</
select
>
</
select
>
</
div
>
</
div
>
...
@@ -326,9 +318,12 @@ export default function ChatView({ chatId }) {
...
@@ -326,9 +318,12 @@ export default function ChatView({ chatId }) {
)
}
)
}
<
div
className=
"flex items-end gap-2"
>
<
div
className=
"flex items-end gap-2"
>
<
button
onClick=
{
toggleSettings
}
<
button
onClick=
{
toggleSettings
}
className=
{
`p-2.5 rounded-xl transition shrink-0 ${
className=
{
`p-2.5 rounded-xl transition shrink-0 ${
showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"
showSettings
? "bg-anton-accent/20 text-anton-accent"
: "text-anton-muted hover:text-white hover:bg-anton-card"
}`
}
}`
}
>
>
<
Settings2
size=
{
18
}
/>
<
Settings2
size=
{
18
}
/>
...
@@ -352,13 +347,16 @@ export default function ChatView({ chatId }) {
...
@@ -352,13 +347,16 @@ export default function ChatView({ chatId }) {
</
div
>
</
div
>
{
streaming
?
(
{
streaming
?
(
<
button
onClick=
{
handleStop
}
<
button
onClick=
{
handleStop
}
className=
"p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"
className=
"p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0"
>
>
<
Square
size=
{
18
}
/>
<
Square
size=
{
18
}
/>
</
button
>
</
button
>
)
:
(
)
:
(
<
button
onClick=
{
handleSend
}
disabled=
{
!
input
.
trim
()
}
<
button
onClick=
{
handleSend
}
disabled=
{
!
input
.
trim
()
||
isStreamingGlobal
}
className=
"p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
className=
"p-2.5 rounded-xl bg-anton-accent text-white hover:opacity-80 transition shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
>
>
<
Send
size=
{
18
}
/>
<
Send
size=
{
18
}
/>
...
@@ -374,7 +372,9 @@ export default function ChatView({ chatId }) {
...
@@ -374,7 +372,9 @@ export default function ChatView({ chatId }) {
{
reasoningBudget
>
0
&&
(
{
reasoningBudget
>
0
&&
(
<>
<>
<
span
>
•
</
span
>
<
span
>
•
</
span
>
<
span
className=
"text-purple-400"
>
🧠
{
reasoningBudget
.
toLocaleString
()
}
reasoning
</
span
>
<
span
className=
"text-purple-400"
>
🧠
{
reasoningBudget
.
toLocaleString
()
}
reasoning
</
span
>
</>
</>
)
}
)
}
{
selectedKbId
&&
(
{
selectedKbId
&&
(
...
@@ -384,7 +384,10 @@ export default function ChatView({ chatId }) {
...
@@ -384,7 +384,10 @@ export default function ChatView({ chatId }) {
</>
</>
)
}
)
}
{
messages
.
some
((
m
)
=>
m
.
role
===
"assistant"
)
&&
(
{
messages
.
some
((
m
)
=>
m
.
role
===
"assistant"
)
&&
(
<
button
onClick=
{
handleDownloadAll
}
className=
"ml-auto hover:text-anton-accent transition"
>
<
button
onClick=
{
handleDownloadAll
}
className=
"ml-auto hover:text-anton-accent transition"
>
⬇ Download all code
⬇ Download all code
</
button
>
</
button
>
)
}
)
}
...
...
frontend/src/components/Sidebar.jsx
View file @
bde969b1
...
@@ -5,6 +5,7 @@ import {
...
@@ -5,6 +5,7 @@ import {
createChat
,
deleteChat
,
renameChat
,
createChat
,
deleteChat
,
renameChat
,
listKnowledgeBases
,
createKnowledgeBase
,
deleteKnowledgeBase
,
uploadDocuments
,
listKnowledgeBases
,
createKnowledgeBase
,
deleteKnowledgeBase
,
uploadDocuments
,
}
from
"../api"
;
}
from
"../api"
;
import
*
as
streamManager
from
"../streamManager"
;
import
{
import
{
Plus
,
Trash2
,
Flame
,
LogOut
,
Shield
,
PanelLeftClose
,
PanelLeftOpen
,
Plus
,
Trash2
,
Flame
,
LogOut
,
Shield
,
PanelLeftClose
,
PanelLeftOpen
,
MessageSquare
,
BookOpen
,
Upload
,
X
,
ChevronDown
,
ChevronRight
,
Edit2
,
Check
,
MessageSquare
,
BookOpen
,
Upload
,
X
,
ChevronDown
,
ChevronRight
,
Edit2
,
Check
,
...
@@ -13,7 +14,7 @@ import {
...
@@ -13,7 +14,7 @@ import {
export
default
function
Sidebar
({
onRefresh
})
{
export
default
function
Sidebar
({
onRefresh
})
{
const
{
state
,
dispatch
}
=
useApp
();
const
{
state
,
dispatch
}
=
useApp
();
const
navigate
=
useNavigate
();
const
navigate
=
useNavigate
();
const
[
tab
,
setTab
]
=
useState
(
"chats"
);
// "chats" | "knowledge"
const
[
tab
,
setTab
]
=
useState
(
"chats"
);
const
[
kbs
,
setKbs
]
=
useState
([]);
const
[
kbs
,
setKbs
]
=
useState
([]);
const
[
kbLoaded
,
setKbLoaded
]
=
useState
(
false
);
const
[
kbLoaded
,
setKbLoaded
]
=
useState
(
false
);
const
[
newKbName
,
setNewKbName
]
=
useState
(
""
);
const
[
newKbName
,
setNewKbName
]
=
useState
(
""
);
...
@@ -25,6 +26,7 @@ export default function Sidebar({ onRefresh }) {
...
@@ -25,6 +26,7 @@ export default function Sidebar({ onRefresh }) {
const
[
renameVal
,
setRenameVal
]
=
useState
(
""
);
const
[
renameVal
,
setRenameVal
]
=
useState
(
""
);
const
open
=
state
.
sidebarOpen
;
const
open
=
state
.
sidebarOpen
;
const
streamingCount
=
Object
.
keys
(
state
.
activeStreams
).
length
;
async
function
handleNewChat
()
{
async
function
handleNewChat
()
{
try
{
try
{
...
@@ -36,6 +38,8 @@ export default function Sidebar({ onRefresh }) {
...
@@ -36,6 +38,8 @@ export default function Sidebar({ onRefresh }) {
async
function
handleDelete
(
id
)
{
async
function
handleDelete
(
id
)
{
try
{
try
{
// Abort any active stream for this chat before deleting
streamManager
.
abortStream
(
id
);
await
deleteChat
(
state
.
token
,
id
);
await
deleteChat
(
state
.
token
,
id
);
dispatch
({
type
:
"REMOVE_CHAT"
,
chatId
:
id
});
dispatch
({
type
:
"REMOVE_CHAT"
,
chatId
:
id
});
}
catch
{
/* */
}
}
catch
{
/* */
}
...
@@ -81,12 +85,11 @@ export default function Sidebar({ onRefresh }) {
...
@@ -81,12 +85,11 @@ export default function Sidebar({ onRefresh }) {
setUploadCount
(
files
.
length
);
setUploadCount
(
files
.
length
);
try
{
try
{
const
result
=
await
uploadDocuments
(
state
.
token
,
kbId
,
files
);
const
result
=
await
uploadDocuments
(
state
.
token
,
kbId
,
files
);
// Check for per-file errors
const
errors
=
(
result
.
files
||
[]).
filter
((
f
)
=>
f
.
error
);
const
errors
=
(
result
.
files
||
[]).
filter
((
f
)
=>
f
.
error
);
if
(
errors
.
length
>
0
)
{
if
(
errors
.
length
>
0
)
{
alert
(
alert
(
`Uploaded
${
result
.
files
.
length
-
errors
.
length
}
of
${
result
.
files
.
length
}
files.\n\nErrors:\n`
+
`Uploaded
${
result
.
files
.
length
-
errors
.
length
}
of
${
result
.
files
.
length
}
files.\n\nErrors:\n`
+
errors
.
map
((
e
)
=>
`•
${
e
.
filename
}
:
${
e
.
error
}
`
).
join
(
"
\n
"
)
errors
.
map
((
e
)
=>
`•
${
e
.
filename
}
:
${
e
.
error
}
`
).
join
(
"
\n
"
)
);
);
}
}
loadKbs
();
loadKbs
();
...
@@ -103,19 +106,35 @@ export default function Sidebar({ onRefresh }) {
...
@@ -103,19 +106,35 @@ export default function Sidebar({ onRefresh }) {
if
(
t
===
"knowledge"
&&
!
kbLoaded
)
loadKbs
();
if
(
t
===
"knowledge"
&&
!
kbLoaded
)
loadKbs
();
}
}
// ── Collapsed sidebar ──
if
(
!
open
)
{
if
(
!
open
)
{
return
(
return
(
<
div
className=
"w-12 bg-anton-surface border-r border-anton-border flex flex-col items-center py-3 gap-3 shrink-0"
>
<
div
className=
"w-12 bg-anton-surface border-r border-anton-border flex flex-col items-center py-3 gap-3 shrink-0"
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"TOGGLE_SIDEBAR"
})
}
className=
"p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"TOGGLE_SIDEBAR"
})
}
className=
"p-2 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
>
<
PanelLeftOpen
size=
{
18
}
/>
<
PanelLeftOpen
size=
{
18
}
/>
</
button
>
</
button
>
<
button
onClick=
{
handleNewChat
}
className=
"p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition"
>
<
button
onClick=
{
handleNewChat
}
className=
"p-2 rounded-lg bg-anton-accent/20 text-anton-accent hover:bg-anton-accent/30 transition"
>
<
Plus
size=
{
18
}
/>
<
Plus
size=
{
18
}
/>
</
button
>
</
button
>
{
/* Streaming count badge when sidebar is collapsed */
}
{
streamingCount
>
0
&&
(
<
div
className=
"w-6 h-6 rounded-full bg-anton-accent/20 flex items-center justify-center"
>
<
span
className=
"text-[10px] text-anton-accent font-bold animate-pulse"
>
{
streamingCount
}
</
span
>
</
div
>
)
}
</
div
>
</
div
>
);
);
}
}
// ── Full sidebar ──
return
(
return
(
<
div
className=
"w-72 bg-anton-surface border-r border-anton-border flex flex-col shrink-0"
>
<
div
className=
"w-72 bg-anton-surface border-r border-anton-border flex flex-col shrink-0"
>
{
/* Header */
}
{
/* Header */
}
...
@@ -123,8 +142,16 @@ export default function Sidebar({ onRefresh }) {
...
@@ -123,8 +142,16 @@ export default function Sidebar({ onRefresh }) {
<
div
className=
"flex items-center gap-2"
>
<
div
className=
"flex items-center gap-2"
>
<
Flame
size=
{
20
}
className=
"text-anton-accent"
/>
<
Flame
size=
{
20
}
className=
"text-anton-accent"
/>
<
span
className=
"font-bold text-white text-sm"
>
Son of Anton
</
span
>
<
span
className=
"font-bold text-white text-sm"
>
Son of Anton
</
span
>
{
streamingCount
>
0
&&
(
<
span
className=
"text-[10px] bg-anton-accent/20 text-anton-accent px-1.5 py-0.5 rounded-full font-medium animate-pulse"
>
{
streamingCount
}
streaming
</
span
>
)
}
</
div
>
</
div
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"TOGGLE_SIDEBAR"
})
}
className=
"p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"TOGGLE_SIDEBAR"
})
}
className=
"p-1.5 rounded-lg hover:bg-anton-card text-anton-muted hover:text-white transition"
>
<
PanelLeftClose
size=
{
16
}
/>
<
PanelLeftClose
size=
{
16
}
/>
</
button
>
</
button
>
</
div
>
</
div
>
...
@@ -139,7 +166,9 @@ export default function Sidebar({ onRefresh }) {
...
@@ -139,7 +166,9 @@ export default function Sidebar({ onRefresh }) {
key=
{
t
.
key
}
key=
{
t
.
key
}
onClick=
{
()
=>
switchTab
(
t
.
key
)
}
onClick=
{
()
=>
switchTab
(
t
.
key
)
}
className=
{
`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition ${
className=
{
`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition ${
tab === t.key ? "text-anton-accent border-b-2 border-anton-accent" : "text-anton-muted hover:text-white"
tab === t.key
? "text-anton-accent border-b-2 border-anton-accent"
: "text-anton-muted hover:text-white"
}`
}
}`
}
>
>
<
t
.
icon
size=
{
13
}
/>
<
t
.
icon
size=
{
13
}
/>
...
@@ -152,52 +181,81 @@ export default function Sidebar({ onRefresh }) {
...
@@ -152,52 +181,81 @@ export default function Sidebar({ onRefresh }) {
<
div
className=
"flex-1 overflow-y-auto p-2 space-y-1"
>
<
div
className=
"flex-1 overflow-y-auto p-2 space-y-1"
>
{
tab
===
"chats"
&&
(
{
tab
===
"chats"
&&
(
<>
<>
<
button
onClick=
{
handleNewChat
}
<
button
onClick=
{
handleNewChat
}
className=
"w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
className=
"w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
>
>
<
Plus
size=
{
15
}
/>
New Chat
<
Plus
size=
{
15
}
/>
New Chat
</
button
>
</
button
>
{
state
.
chats
.
map
((
c
)
=>
(
{
state
.
chats
.
map
((
c
)
=>
{
<
div
const
chatStreaming
=
!!
state
.
activeStreams
[
c
.
id
];
key=
{
c
.
id
}
return
(
className=
{
`group flex items-center rounded-lg cursor-pointer transition ${
<
div
state.activeChatId === c.id ? "bg-anton-accent/10 text-anton-accent" : "text-anton-text hover:bg-anton-card"
key=
{
c
.
id
}
}`
}
className=
{
`group flex items-center rounded-lg cursor-pointer transition ${
>
state.activeChatId === c.id
{
renamingId
===
c
.
id
?
(
? "bg-anton-accent/10 text-anton-accent"
<
div
className=
"flex items-center gap-1 flex-1 p-1"
>
: "text-anton-text hover:bg-anton-card"
<
input
value=
{
renameVal
}
onChange=
{
(
e
)
=>
setRenameVal
(
e
.
target
.
value
)
}
}`
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleRename
(
c
.
id
)
}
>
autoFocus
{
renamingId
===
c
.
id
?
(
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
<
div
className=
"flex items-center gap-1 flex-1 p-1"
>
/>
<
input
<
button
onClick=
{
()
=>
handleRename
(
c
.
id
)
}
className=
"p-1 text-anton-success"
><
Check
size=
{
12
}
/></
button
>
value=
{
renameVal
}
<
button
onClick=
{
()
=>
setRenamingId
(
null
)
}
className=
"p-1 text-anton-muted"
><
X
size=
{
12
}
/></
button
>
onChange=
{
(
e
)
=>
setRenameVal
(
e
.
target
.
value
)
}
</
div
>
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleRename
(
c
.
id
)
}
)
:
(
autoFocus
<>
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
<
button
onClick=
{
()
=>
dispatch
({
type
:
"SET_ACTIVE_CHAT"
,
chatId
:
c
.
id
})
}
/>
className=
"flex-1 text-left px-3 py-2 text-sm truncate"
<
button
onClick=
{
()
=>
handleRename
(
c
.
id
)
}
className=
"p-1 text-anton-success"
>
>
<
Check
size=
{
12
}
/>
{
c
.
title
}
</
button
>
</
button
>
<
button
onClick=
{
()
=>
setRenamingId
(
null
)
}
className=
"p-1 text-anton-muted"
>
<
div
className=
"hidden group-hover:flex items-center pr-1 gap-0.5 shrink-0"
>
<
X
size=
{
12
}
/>
<
button
onClick=
{
()
=>
{
setRenamingId
(
c
.
id
);
setRenameVal
(
c
.
title
);
}
}
</
button
>
className=
"p-1 rounded hover:bg-anton-border text-anton-muted"
><
Edit2
size=
{
11
}
/></
button
>
<
button
onClick=
{
()
=>
handleDelete
(
c
.
id
)
}
className=
"p-1 rounded hover:bg-red-500/20 text-anton-danger"
><
Trash2
size=
{
11
}
/></
button
>
</
div
>
</
div
>
</>
)
:
(
)
}
<>
</
div
>
<
button
))
}
onClick=
{
()
=>
dispatch
({
type
:
"SET_ACTIVE_CHAT"
,
chatId
:
c
.
id
})
}
className=
"flex-1 flex items-center gap-2 text-left px-3 py-2 text-sm truncate min-w-0"
>
{
/* Streaming indicator dot */
}
{
chatStreaming
&&
(
<
span
className=
"w-2 h-2 bg-anton-accent rounded-full animate-pulse shrink-0"
/>
)
}
<
span
className=
"truncate"
>
{
c
.
title
}
</
span
>
</
button
>
<
div
className=
"hidden group-hover:flex items-center pr-1 gap-0.5 shrink-0"
>
<
button
onClick=
{
()
=>
{
setRenamingId
(
c
.
id
);
setRenameVal
(
c
.
title
);
}
}
className=
"p-1 rounded hover:bg-anton-border text-anton-muted"
>
<
Edit2
size=
{
11
}
/>
</
button
>
<
button
onClick=
{
()
=>
handleDelete
(
c
.
id
)
}
className=
"p-1 rounded hover:bg-red-500/20 text-anton-danger"
>
<
Trash2
size=
{
11
}
/>
</
button
>
</
div
>
</>
)
}
</
div
>
);
})
}
</>
</>
)
}
)
}
{
tab
===
"knowledge"
&&
(
{
tab
===
"knowledge"
&&
(
<>
<>
<
button
onClick=
{
()
=>
setShowNewKb
(
!
showNewKb
)
}
<
button
onClick=
{
()
=>
setShowNewKb
(
!
showNewKb
)
}
className=
"w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
className=
"w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-dashed border-anton-border text-anton-muted hover:text-anton-accent hover:border-anton-accent transition text-sm"
>
>
<
Plus
size=
{
15
}
/>
New Knowledge Base
<
Plus
size=
{
15
}
/>
New Knowledge Base
...
@@ -205,18 +263,24 @@ export default function Sidebar({ onRefresh }) {
...
@@ -205,18 +263,24 @@ export default function Sidebar({ onRefresh }) {
{
showNewKb
&&
(
{
showNewKb
&&
(
<
div
className=
"flex gap-1 p-1"
>
<
div
className=
"flex gap-1 p-1"
>
<
input
value=
{
newKbName
}
onChange=
{
(
e
)
=>
setNewKbName
(
e
.
target
.
value
)
}
<
input
value=
{
newKbName
}
onChange=
{
(
e
)
=>
setNewKbName
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleCreateKb
()
}
onKeyDown=
{
(
e
)
=>
e
.
key
===
"Enter"
&&
handleCreateKb
()
}
placeholder=
"Name…"
autoFocus
placeholder=
"Name…"
autoFocus
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
className=
"flex-1 bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-anton-accent"
/>
/>
<
button
onClick=
{
handleCreateKb
}
className=
"px-2 py-1 bg-anton-accent rounded text-white text-xs"
>
Add
</
button
>
<
button
onClick=
{
handleCreateKb
}
className=
"px-2 py-1 bg-anton-accent rounded text-white text-xs"
>
Add
</
button
>
</
div
>
</
div
>
)
}
)
}
{
kbs
.
map
((
kb
)
=>
(
{
kbs
.
map
((
kb
)
=>
(
<
div
key=
{
kb
.
id
}
className=
"rounded-lg border border-anton-border/50 overflow-hidden"
>
<
div
key=
{
kb
.
id
}
className=
"rounded-lg border border-anton-border/50 overflow-hidden"
>
<
button
onClick=
{
()
=>
setExpandedKb
(
expandedKb
===
kb
.
id
?
null
:
kb
.
id
)
}
<
button
onClick=
{
()
=>
setExpandedKb
(
expandedKb
===
kb
.
id
?
null
:
kb
.
id
)
}
className=
"w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-anton-card transition"
className=
"w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-anton-card transition"
>
>
{
expandedKb
===
kb
.
id
?
<
ChevronDown
size=
{
13
}
/>
:
<
ChevronRight
size=
{
13
}
/>
}
{
expandedKb
===
kb
.
id
?
<
ChevronDown
size=
{
13
}
/>
:
<
ChevronRight
size=
{
13
}
/>
}
...
@@ -228,14 +292,24 @@ export default function Sidebar({ onRefresh }) {
...
@@ -228,14 +292,24 @@ export default function Sidebar({ onRefresh }) {
{
expandedKb
===
kb
.
id
&&
(
{
expandedKb
===
kb
.
id
&&
(
<
div
className=
"px-3 pb-3 space-y-2 bg-anton-card/50"
>
<
div
className=
"px-3 pb-3 space-y-2 bg-anton-card/50"
>
<
div
className=
"text-xs text-anton-muted space-y-0.5"
>
<
div
className=
"text-xs text-anton-muted space-y-0.5"
>
<
div
>
Chunks:
{
kb
.
chunk_count
}
·
~
{
(
kb
.
estimated_tokens
/
1000
).
toFixed
(
0
)
}
K tokens
</
div
>
<
div
>
Chunks:
{
kb
.
chunk_count
}
·
~
{
(
kb
.
estimated_tokens
/
1000
).
toFixed
(
0
)
}
K
tokens
</
div
>
</
div
>
</
div
>
<
label
className=
{
`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${uploading ? "opacity-50 pointer-events-none" : ""}`
}
>
<
label
className=
{
`flex items-center gap-1.5 px-2 py-1.5 rounded border border-dashed border-anton-border text-xs text-anton-muted hover:text-anton-accent hover:border-anton-accent transition cursor-pointer ${
uploading ? "opacity-50 pointer-events-none" : ""
}`
}
>
<
Upload
size=
{
12
}
/>
<
Upload
size=
{
12
}
/>
{
uploading
{
uploading
?
`Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…`
?
`Uploading ${uploadCount} file${uploadCount !== 1 ? "s" : ""}…`
:
"Upload files (.txt, .pdf, .md, .json, .csv …)"
}
:
"Upload files (.txt, .pdf, .md, .json, .csv …)"
}
<
input
type=
"file"
className=
"hidden"
multiple
<
input
type=
"file"
className=
"hidden"
multiple
accept=
".txt,.md,.pdf,.json,.csv,.py,.js,.ts,.cs,.html,.css,.xml,.yaml,.yml,.toml"
accept=
".txt,.md,.pdf,.json,.csv,.py,.js,.ts,.cs,.html,.css,.xml,.yaml,.yml,.toml"
onChange=
{
(
e
)
=>
{
onChange=
{
(
e
)
=>
{
if
(
e
.
target
.
files
&&
e
.
target
.
files
.
length
>
0
)
{
if
(
e
.
target
.
files
&&
e
.
target
.
files
.
length
>
0
)
{
...
@@ -245,8 +319,10 @@ export default function Sidebar({ onRefresh }) {
...
@@ -245,8 +319,10 @@ export default function Sidebar({ onRefresh }) {
}
}
}
}
/>
/>
</
label
>
</
label
>
<
button
onClick=
{
()
=>
handleDeleteKb
(
kb
.
id
)
}
<
button
className=
"flex items-center gap-1 text-xs text-anton-danger hover:underline"
>
onClick=
{
()
=>
handleDeleteKb
(
kb
.
id
)
}
className=
"flex items-center gap-1 text-xs text-anton-danger hover:underline"
>
<
Trash2
size=
{
11
}
/>
Delete KB
<
Trash2
size=
{
11
}
/>
Delete KB
</
button
>
</
button
>
</
div
>
</
div
>
...
@@ -260,7 +336,8 @@ export default function Sidebar({ onRefresh }) {
...
@@ -260,7 +336,8 @@ export default function Sidebar({ onRefresh }) {
{
/* Footer */
}
{
/* Footer */
}
<
div
className=
"p-3 border-t border-anton-border space-y-2"
>
<
div
className=
"p-3 border-t border-anton-border space-y-2"
>
{
state
.
user
?.
role
===
"superadmin"
&&
(
{
state
.
user
?.
role
===
"superadmin"
&&
(
<
button
onClick=
{
()
=>
navigate
(
"/admin"
)
}
<
button
onClick=
{
()
=>
navigate
(
"/admin"
)
}
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:text-anton-accent hover:bg-anton-card transition"
className=
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:text-anton-accent hover:bg-anton-card transition"
>
>
<
Shield
size=
{
15
}
/>
Admin Panel
<
Shield
size=
{
15
}
/>
Admin Panel
...
@@ -270,10 +347,12 @@ export default function Sidebar({ onRefresh }) {
...
@@ -270,10 +347,12 @@ export default function Sidebar({ onRefresh }) {
<
div
>
<
div
>
<
div
className=
"text-sm font-medium text-white"
>
{
state
.
user
?.
username
}
</
div
>
<
div
className=
"text-sm font-medium text-white"
>
{
state
.
user
?.
username
}
</
div
>
<
div
className=
"text-xs text-anton-muted"
>
<
div
className=
"text-xs text-anton-muted"
>
{
((
state
.
user
?.
tokens_used_this_month
||
0
)
/
1000
).
toFixed
(
0
)
}
K /
{
((
state
.
user
?.
quota_tokens_monthly
||
0
)
/
1000
).
toFixed
(
0
)
}
K tokens
{
((
state
.
user
?.
tokens_used_this_month
||
0
)
/
1000
).
toFixed
(
0
)
}
K /
{
" "
}
{
((
state
.
user
?.
quota_tokens_monthly
||
0
)
/
1000
).
toFixed
(
0
)
}
K tokens
</
div
>
</
div
>
</
div
>
</
div
>
<
button
onClick=
{
()
=>
dispatch
({
type
:
"LOGOUT"
})
}
<
button
onClick=
{
()
=>
dispatch
({
type
:
"LOGOUT"
})
}
className=
"p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition"
className=
"p-2 rounded-lg text-anton-muted hover:text-anton-danger hover:bg-red-500/10 transition"
>
>
<
LogOut
size=
{
16
}
/>
<
LogOut
size=
{
16
}
/>
...
...
frontend/src/store.jsx
View file @
bde969b1
/**
/**
* Global state via React Context + useReducer
* Global state via React Context + useReducer.
*
* Holds chat messages and streaming flags so they persist
* across chat switches (background streams keep running).
*/
*/
import
React
,
{
createContext
,
useContext
,
useReducer
,
useEffect
}
from
"react"
;
import
React
,
{
createContext
,
useContext
,
useReducer
,
useEffect
}
from
"react"
;
import
{
setDispatch
}
from
"./streamManager"
;
const
AppContext
=
createContext
();
const
AppContext
=
createContext
();
...
@@ -12,6 +16,8 @@ const initialState = {
...
@@ -12,6 +16,8 @@ const initialState = {
chats
:
[],
chats
:
[],
activeChatId
:
null
,
activeChatId
:
null
,
sidebarOpen
:
true
,
sidebarOpen
:
true
,
chatMessages
:
{},
// { [chatId]: Message[] }
activeStreams
:
{},
// { [chatId]: true } — which chats are currently streaming
};
};
function
reducer
(
state
,
action
)
{
function
reducer
(
state
,
action
)
{
...
@@ -24,7 +30,13 @@ function reducer(state, action) {
...
@@ -24,7 +30,13 @@ function reducer(state, action) {
case
"LOGOUT"
:
case
"LOGOUT"
:
localStorage
.
removeItem
(
"soa_token"
);
localStorage
.
removeItem
(
"soa_token"
);
localStorage
.
removeItem
(
"soa_user"
);
localStorage
.
removeItem
(
"soa_user"
);
return
{
...
initialState
,
token
:
null
,
user
:
null
};
return
{
...
initialState
,
token
:
null
,
user
:
null
,
chatMessages
:
{},
activeStreams
:
{},
};
case
"SET_CHATS"
:
case
"SET_CHATS"
:
return
{
...
state
,
chats
:
action
.
chats
};
return
{
...
state
,
chats
:
action
.
chats
};
...
@@ -45,9 +57,15 @@ function reducer(state, action) {
...
@@ -45,9 +57,15 @@ function reducer(state, action) {
case
"REMOVE_CHAT"
:
{
case
"REMOVE_CHAT"
:
{
const
filtered
=
state
.
chats
.
filter
((
c
)
=>
c
.
id
!==
action
.
chatId
);
const
filtered
=
state
.
chats
.
filter
((
c
)
=>
c
.
id
!==
action
.
chatId
);
const
newMessages
=
{
...
state
.
chatMessages
};
delete
newMessages
[
action
.
chatId
];
const
newStreams
=
{
...
state
.
activeStreams
};
delete
newStreams
[
action
.
chatId
];
return
{
return
{
...
state
,
...
state
,
chats
:
filtered
,
chats
:
filtered
,
chatMessages
:
newMessages
,
activeStreams
:
newStreams
,
activeChatId
:
activeChatId
:
state
.
activeChatId
===
action
.
chatId
state
.
activeChatId
===
action
.
chatId
?
filtered
[
0
]?.
id
||
null
?
filtered
[
0
]?.
id
||
null
...
@@ -61,6 +79,41 @@ function reducer(state, action) {
...
@@ -61,6 +79,41 @@ function reducer(state, action) {
case
"TOGGLE_SIDEBAR"
:
case
"TOGGLE_SIDEBAR"
:
return
{
...
state
,
sidebarOpen
:
!
state
.
sidebarOpen
};
return
{
...
state
,
sidebarOpen
:
!
state
.
sidebarOpen
};
// ── Per-chat message management ──────────────
case
"SET_MESSAGES"
:
return
{
...
state
,
chatMessages
:
{
...
state
.
chatMessages
,
[
action
.
chatId
]:
action
.
messages
,
},
};
case
"ADD_MESSAGE"
:
return
{
...
state
,
chatMessages
:
{
...
state
.
chatMessages
,
[
action
.
chatId
]:
[
...(
state
.
chatMessages
[
action
.
chatId
]
||
[]),
action
.
message
,
],
},
};
// ── Background streaming flags ───────────────
case
"SET_STREAMING"
:
{
if
(
action
.
streaming
)
{
return
{
...
state
,
activeStreams
:
{
...
state
.
activeStreams
,
[
action
.
chatId
]:
true
},
};
}
const
next
=
{
...
state
.
activeStreams
};
delete
next
[
action
.
chatId
];
return
{
...
state
,
activeStreams
:
next
};
}
default
:
default
:
return
state
;
return
state
;
}
}
...
@@ -68,6 +121,12 @@ function reducer(state, action) {
...
@@ -68,6 +121,12 @@ function reducer(state, action) {
export
function
AppProvider
({
children
})
{
export
function
AppProvider
({
children
})
{
const
[
state
,
dispatch
]
=
useReducer
(
reducer
,
initialState
);
const
[
state
,
dispatch
]
=
useReducer
(
reducer
,
initialState
);
// Give the background stream manager access to dispatch
useEffect
(()
=>
{
setDispatch
(
dispatch
);
},
[
dispatch
]);
return
(
return
(
<
AppContext
.
Provider
value=
{
{
state
,
dispatch
}
}
>
<
AppContext
.
Provider
value=
{
{
state
,
dispatch
}
}
>
{
children
}
{
children
}
...
...
frontend/src/streamManager.js
0 → 100644
View file @
bde969b1
/**
* Son of Anton — Background Stream Manager
*
* Runs AI streams outside React component lifecycle.
* Switching chats does NOT abort streams. Multiple chats
* can stream simultaneously. Components subscribe to
* per-chat updates via subscribe().
*/
import
{
streamMessage
}
from
"./api"
;
// chatId -> { text, thinking, isThinking, abortController }
const
_streams
=
new
Map
();
// chatId -> Set<() => void>
const
_listeners
=
new
Map
();
// Store dispatch reference, set once from AppProvider
let
_dispatch
=
null
;
export
function
setDispatch
(
dispatch
)
{
_dispatch
=
dispatch
;
}
/** Read current stream data for a chat (non-reactive, call from inside subscriber) */
export
function
getStreamData
(
chatId
)
{
const
s
=
_streams
.
get
(
chatId
);
if
(
!
s
)
return
{
streaming
:
false
,
text
:
""
,
thinking
:
""
,
isThinking
:
false
};
return
{
streaming
:
true
,
text
:
s
.
text
,
thinking
:
s
.
thinking
,
isThinking
:
s
.
isThinking
,
};
}
/** Is this chat currently streaming? */
export
function
isStreaming
(
chatId
)
{
return
_streams
.
has
(
chatId
);
}
/** Subscribe to stream data changes for a specific chat. Returns unsubscribe fn. */
export
function
subscribe
(
chatId
,
callback
)
{
if
(
!
_listeners
.
has
(
chatId
))
_listeners
.
set
(
chatId
,
new
Set
());
_listeners
.
get
(
chatId
).
add
(
callback
);
return
()
=>
{
const
set
=
_listeners
.
get
(
chatId
);
if
(
set
)
{
set
.
delete
(
callback
);
if
(
set
.
size
===
0
)
_listeners
.
delete
(
chatId
);
}
};
}
function
_notify
(
chatId
)
{
const
set
=
_listeners
.
get
(
chatId
);
if
(
set
)
set
.
forEach
((
cb
)
=>
cb
());
}
/** Abort a running stream for a chat */
export
function
abortStream
(
chatId
)
{
const
s
=
_streams
.
get
(
chatId
);
if
(
s
)
{
s
.
abortController
.
abort
();
_streams
.
delete
(
chatId
);
_notify
(
chatId
);
if
(
_dispatch
)
_dispatch
({
type
:
"SET_STREAMING"
,
chatId
,
streaming
:
false
});
}
}
/**
* Start a background stream for a chat.
* Does nothing if that chat is already streaming.
*
* @param {object} opts
* @param {string} opts.token - JWT
* @param {string} opts.chatId - chat UUID
* @param {object} opts.body - SendMessageBody
*/
export
function
startStream
({
token
,
chatId
,
body
})
{
if
(
_streams
.
has
(
chatId
))
return
;
const
ac
=
new
AbortController
();
_streams
.
set
(
chatId
,
{
text
:
""
,
thinking
:
""
,
isThinking
:
false
,
abortController
:
ac
,
});
if
(
_dispatch
)
_dispatch
({
type
:
"SET_STREAMING"
,
chatId
,
streaming
:
true
});
_notify
(
chatId
);
// Fire-and-forget async IIFE — runs entirely in the background
(
async
()
=>
{
const
s
=
_streams
.
get
(
chatId
);
if
(
!
s
)
return
;
let
usage
=
{};
let
msgId
=
""
;
try
{
for
await
(
const
evt
of
streamMessage
(
token
,
chatId
,
body
,
ac
.
signal
))
{
if
(
ac
.
signal
.
aborted
)
break
;
if
(
!
_streams
.
has
(
chatId
))
break
;
switch
(
evt
.
type
)
{
case
"thinking_start"
:
s
.
isThinking
=
true
;
_notify
(
chatId
);
break
;
case
"thinking_delta"
:
s
.
thinking
+=
evt
.
content
;
_notify
(
chatId
);
break
;
case
"thinking_end"
:
s
.
isThinking
=
false
;
_notify
(
chatId
);
break
;
case
"text_delta"
:
s
.
text
+=
evt
.
content
;
_notify
(
chatId
);
break
;
case
"usage"
:
usage
=
{
input_tokens
:
evt
.
input_tokens
,
output_tokens
:
evt
.
output_tokens
,
};
break
;
case
"title_update"
:
if
(
_dispatch
)
_dispatch
({
type
:
"UPDATE_CHAT"
,
chat
:
{
id
:
chatId
,
title
:
evt
.
title
},
});
break
;
case
"done"
:
msgId
=
evt
.
message_id
;
break
;
case
"error"
:
s
.
text
+=
`\n\n**Error:**
${
evt
.
message
}
`
;
_notify
(
chatId
);
break
;
}
}
// Stream finished normally — persist the final assistant message
if
(
!
ac
.
signal
.
aborted
&&
_dispatch
)
{
const
assistantMsg
=
{
id
:
msgId
||
`gen-
${
Date
.
now
()}
`
,
role
:
"assistant"
,
content
:
s
.
text
,
thinking_content
:
s
.
thinking
||
null
,
input_tokens
:
usage
.
input_tokens
||
0
,
output_tokens
:
usage
.
output_tokens
||
0
,
created_at
:
new
Date
().
toISOString
(),
};
_dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
assistantMsg
});
}
}
catch
(
err
)
{
// Only surface errors that aren't deliberate aborts
if
(
!
ac
.
signal
.
aborted
&&
_dispatch
)
{
const
errMsg
=
{
id
:
`err-
${
Date
.
now
()}
`
,
role
:
"assistant"
,
content
:
`**Error:**
${
err
.
message
}
`
,
created_at
:
new
Date
().
toISOString
(),
};
_dispatch
({
type
:
"ADD_MESSAGE"
,
chatId
,
message
:
errMsg
});
}
}
finally
{
_streams
.
delete
(
chatId
);
_notify
(
chatId
);
if
(
_dispatch
)
_dispatch
({
type
:
"SET_STREAMING"
,
chatId
,
streaming
:
false
});
}
})();
}
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a 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