Skip to content

Commit 39c6b3e

Browse files
authored
feat: track files being staged (#9275)
This changeset makes visible when files are being staged, so users are aware that the model "isn't ready yet" for requests. Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 0e9d1a6 commit 39c6b3e

9 files changed

Lines changed: 425 additions & 43 deletions

File tree

core/http/react-ui/src/App.css

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,6 +1936,56 @@
19361936
40% { transform: scale(1); opacity: 1; }
19371937
}
19381938

1939+
/* Staging progress indicator (replaces thinking dots during model transfer) */
1940+
.chat-staging-progress {
1941+
display: flex;
1942+
flex-direction: column;
1943+
gap: 6px;
1944+
min-width: 200px;
1945+
max-width: 320px;
1946+
}
1947+
.chat-staging-label {
1948+
font-size: 0.8rem;
1949+
color: var(--color-text-secondary);
1950+
display: flex;
1951+
align-items: center;
1952+
gap: 6px;
1953+
}
1954+
.chat-staging-label i {
1955+
color: var(--color-primary);
1956+
}
1957+
.chat-staging-detail {
1958+
display: flex;
1959+
align-items: center;
1960+
gap: 8px;
1961+
}
1962+
.chat-staging-bar-container {
1963+
flex: 1;
1964+
height: 4px;
1965+
background: var(--color-bg-tertiary);
1966+
border-radius: 2px;
1967+
overflow: hidden;
1968+
}
1969+
.chat-staging-bar {
1970+
height: 100%;
1971+
background: var(--color-primary);
1972+
border-radius: 2px;
1973+
transition: width 300ms ease;
1974+
}
1975+
.chat-staging-pct {
1976+
font-size: 0.75rem;
1977+
color: var(--color-text-muted);
1978+
min-width: 32px;
1979+
text-align: right;
1980+
}
1981+
.chat-staging-file {
1982+
font-size: 0.7rem;
1983+
color: var(--color-text-muted);
1984+
overflow: hidden;
1985+
text-overflow: ellipsis;
1986+
white-space: nowrap;
1987+
}
1988+
19391989
/* Message completion flash */
19401990
.chat-message-bubble {
19411991
transition: border-color 300ms ease;

core/http/react-ui/src/components/OperationsBar.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export default function OperationsBar() {
2727
({op.error})
2828
</span>
2929
</>
30+
) : op.taskType === 'staging' ? (
31+
<>
32+
<i className="fas fa-cloud-arrow-up" style={{ marginRight: 'var(--spacing-xs)' }} />
33+
Staging model: {op.name}{op.nodeName ? ` → ${op.nodeName}` : ''}
34+
</>
3035
) : (
3136
<>
3237
{op.isDeletion ? 'Removing' : 'Installing'}{' '}

core/http/react-ui/src/pages/Chat.jsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
1313
import { loadClientMCPServers } from '../utils/mcpClientStorage'
1414
import ConfirmDialog from '../components/ConfirmDialog'
1515
import { useAuth } from '../context/AuthContext'
16+
import { useOperations } from '../hooks/useOperations'
1617
import { relativeTime } from '../utils/format'
1718

1819
function getLastMessagePreview(chat) {
@@ -277,13 +278,20 @@ export default function Chat() {
277278
const { addToast } = useOutletContext()
278279
const navigate = useNavigate()
279280
const { isAdmin } = useAuth()
281+
const { operations } = useOperations()
280282
const {
281283
chats, activeChat, activeChatId, isStreaming, streamingChatId, streamingContent,
282284
streamingReasoning, streamingToolCalls, tokensPerSecond, maxTokensPerSecond,
283285
addChat, switchChat, deleteChat, deleteAllChats, renameChat, updateChatSettings,
284286
sendMessage, stopGeneration, clearHistory, getContextUsagePercent, addMessage,
285287
} = useChat(urlModel || '')
286288

289+
// Detect active staging operation for the current chat's model
290+
const stagingOp = useMemo(() => {
291+
if (!isStreaming || !activeChat?.model) return null
292+
return operations.find(op => op.taskType === 'staging' && op.name === activeChat.model) || null
293+
}, [operations, isStreaming, activeChat?.model])
294+
287295
const [input, setInput] = useState('')
288296
const [files, setFiles] = useState([])
289297
const [showSettings, setShowSettings] = useState(false)
@@ -1187,9 +1195,28 @@ export default function Chat() {
11871195
</div>
11881196
<div className="chat-message-bubble">
11891197
<div className="chat-message-content chat-thinking-indicator">
1190-
<span className="chat-thinking-dots">
1191-
<span /><span /><span />
1192-
</span>
1198+
{stagingOp ? (
1199+
<div className="chat-staging-progress">
1200+
<div className="chat-staging-label">
1201+
<i className="fas fa-cloud-arrow-up" /> Transferring model{stagingOp.nodeName ? ` to ${stagingOp.nodeName}` : ''}...
1202+
</div>
1203+
{stagingOp.progress > 0 && (
1204+
<div className="chat-staging-detail">
1205+
<div className="chat-staging-bar-container">
1206+
<div className="chat-staging-bar" style={{ width: `${stagingOp.progress}%` }} />
1207+
</div>
1208+
<span className="chat-staging-pct">{Math.round(stagingOp.progress)}%</span>
1209+
</div>
1210+
)}
1211+
{stagingOp.message && (
1212+
<div className="chat-staging-file">{stagingOp.message}</div>
1213+
)}
1214+
</div>
1215+
) : (
1216+
<span className="chat-thinking-dots">
1217+
<span /><span /><span />
1218+
</span>
1219+
)}
11931220
</div>
11941221
</div>
11951222
</div>

core/http/routes/ui_api.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,27 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
153153
operations = append(operations, opData)
154154
}
155155

156+
// Append active file staging operations (distributed mode only)
157+
if d := applicationInstance.Distributed(); d != nil && d.Router != nil {
158+
for modelID, status := range d.Router.StagingTracker().GetAll() {
159+
operations = append(operations, map[string]any{
160+
"id": "staging:" + modelID,
161+
"name": modelID,
162+
"fullName": modelID,
163+
"jobID": "staging:" + modelID,
164+
"progress": int(status.Progress),
165+
"taskType": "staging",
166+
"isDeletion": false,
167+
"isBackend": false,
168+
"isQueued": false,
169+
"isCancelled": false,
170+
"cancellable": false,
171+
"message": status.Message,
172+
"nodeName": status.NodeName,
173+
})
174+
}
175+
}
176+
156177
// Sort operations by progress (ascending), then by ID for stable display order
157178
slices.SortFunc(operations, func(a, b map[string]any) int {
158179
progressA := a["progress"].(int)

core/services/nodes/file_stager_http.go

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,11 @@ func (h *HTTPFileStager) doUpload(ctx context.Context, addr, nodeID, localPath,
146146
defer f.Close()
147147

148148
var body io.Reader = f
149-
// For files > 100MB, wrap with progress logging
149+
cb := StagingProgressFromContext(ctx)
150+
// For files > 100MB or when a progress callback is set, wrap with progress reporting
150151
const progressThreshold = 100 << 20
151-
if fileSize > progressThreshold {
152-
body = newProgressReader(f, fileSize, filepath.Base(localPath), nodeID)
152+
if fileSize > progressThreshold || cb != nil {
153+
body = newProgressReader(f, fileSize, filepath.Base(localPath), nodeID, cb)
153154
}
154155

155156
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body)
@@ -268,26 +269,30 @@ func (h *HTTPFileStager) probeExisting(ctx context.Context, addr, localPath, key
268269
}
269270

270271
// progressReader wraps an io.Reader and logs upload progress periodically.
272+
// If a StagingProgressCallback is present in the context, it also calls it
273+
// for UI-visible progress updates.
271274
type progressReader struct {
272-
reader io.Reader
273-
total int64
274-
read int64
275-
file string
276-
node string
277-
lastLog time.Time
278-
lastPct int
279-
start time.Time
280-
mu sync.Mutex
275+
reader io.Reader
276+
total int64
277+
read int64
278+
file string
279+
node string
280+
lastLog time.Time
281+
lastPct int
282+
start time.Time
283+
mu sync.Mutex
284+
progressCb StagingProgressCallback
281285
}
282286

283-
func newProgressReader(r io.Reader, total int64, file, node string) *progressReader {
287+
func newProgressReader(r io.Reader, total int64, file, node string, cb StagingProgressCallback) *progressReader {
284288
return &progressReader{
285-
reader: r,
286-
total: total,
287-
file: file,
288-
node: node,
289-
start: time.Now(),
290-
lastLog: time.Now(),
289+
reader: r,
290+
total: total,
291+
file: file,
292+
node: node,
293+
start: time.Now(),
294+
lastLog: time.Now(),
295+
progressCb: cb,
291296
}
292297
}
293298

@@ -313,6 +318,10 @@ func (pr *progressReader) Read(p []byte) (int, error) {
313318
pr.lastLog = now
314319
pr.lastPct = pct
315320
}
321+
// Call external progress callback for UI visibility
322+
if pr.progressCb != nil {
323+
pr.progressCb(pr.file, pr.read, pr.total)
324+
}
316325
pr.mu.Unlock()
317326
}
318327
return n, err
@@ -385,7 +394,19 @@ func (h *HTTPFileStager) FetchRemoteByKey(ctx context.Context, nodeID, key, loca
385394
}
386395
defer f.Close()
387396

388-
written, err := io.Copy(f, resp.Body)
397+
// Wrap response body with progress reporting if callback is set or file is large
398+
var src io.Reader = resp.Body
399+
cb := StagingProgressFromContext(ctx)
400+
totalSize := resp.ContentLength
401+
const progressThreshold = 100 << 20
402+
if totalSize > progressThreshold || cb != nil {
403+
if totalSize <= 0 {
404+
totalSize = 0 // unknown size — progress reader will still report bytes
405+
}
406+
src = newProgressReader(resp.Body, totalSize, filepath.Base(key), nodeID, cb)
407+
}
408+
409+
written, err := io.Copy(f, src)
389410
if err != nil {
390411
os.Remove(localDst)
391412
return fmt.Errorf("writing to %s: %w", localDst, err)

core/services/nodes/file_stager_s3.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,14 @@ func (s *S3NATSFileStager) EnsureRemote(ctx context.Context, nodeID, localPath,
7070
// Upload to S3 if not already present
7171
exists, _ := s.fm.Exists(ctx, key)
7272
if !exists {
73-
if err := s.fm.Upload(ctx, key, localPath); err != nil {
73+
// Wrap with progress reporting if a staging callback is available
74+
var progressFn storage.UploadProgressFunc
75+
if cb := StagingProgressFromContext(ctx); cb != nil {
76+
progressFn = func(fileName string, bytesWritten, totalBytes int64) {
77+
cb(fileName, bytesWritten, totalBytes)
78+
}
79+
}
80+
if err := s.fm.UploadWithProgress(ctx, key, localPath, progressFn); err != nil {
7481
return "", fmt.Errorf("uploading %s to S3: %w", localPath, err)
7582
}
7683
}

0 commit comments

Comments
 (0)