Skip to content

Commit 9f9d207

Browse files
committed
feat(realtime): LocalAI Assistant ("Manage Mode") for the Talk page
Mirrors the chat-page metadata.localai_assistant flow so users can ask the realtime model what's loaded / installed / configured. Tools are run server-side via the same in-process MCP holder that powers the chat modality — no transport switch, no proxy, no new wire protocol. Wire: - core/http/endpoints/openai/realtime.go: - RealtimeSessionOptions{LocalAIAssistant,IsAdmin}; isCurrentUserAdmin helper mirrors chat.go's requireAssistantAccess (no-op when auth disabled, else requires auth.RoleAdmin). - Session grows AssistantExecutor mcpTools.ToolExecutor. - runRealtimeSession, when opts.LocalAIAssistant is set: gate on admin, fail closed if DisableLocalAIAssistant or the holder has no tools, DiscoverTools and inject into session.Tools, prepend holder.SystemPrompt() to instructions. - Tool-call dispatch loop: when AssistantExecutor.IsTool(name), run ExecuteTool inproc, append a FunctionCallOutput to conv.Items, skip the function_call_arguments client emit (the client can't execute these — it doesn't know about them). After the loop, if any assistant tool ran, trigger another response so the model speaks the result. Mirrors chat's agentic loop, driven server-side rather than via client round-trip. - core/http/endpoints/openai/realtime_webrtc.go: RealtimeCallRequest gains `localai_assistant` (JSON omitempty). Handshake calls isCurrentUserAdmin and builds RealtimeSessionOptions. - core/http/react-ui/src/pages/Talk.jsx: admin-only "Manage Mode" checkbox under the Tools dropdown; passes localai_assistant: true to realtimeApi.call's body, captured in the connect callback's deps. Mirroring chat's pattern means the in-process MCP tools surface "just works" for the Talk page without exposing a Streamable-HTTP MCP endpoint (which was the alternative). Clients with their own MCP servers can still use the existing ClientMCPDropdown path in parallel; the realtime handler distinguishes them by AssistantExecutor.IsTool() at dispatch time. Assisted-by: claude-code:claude-opus-4-7-1m [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 560c5b2 commit 9f9d207

3 files changed

Lines changed: 181 additions & 8 deletions

File tree

core/http/endpoints/openai/realtime.go

Lines changed: 143 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import (
2020
"github.com/mudler/LocalAI/core/backend"
2121

2222
"github.com/mudler/LocalAI/core/config"
23+
"github.com/mudler/LocalAI/core/http/auth"
24+
mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
2325
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
2426
"github.com/mudler/LocalAI/core/schema"
2527
"github.com/mudler/LocalAI/core/templates"
@@ -86,6 +88,14 @@ type Session struct {
8688
// pairs are kept together so we never feed an orphaned tool result.
8789
MaxHistoryItems int
8890

91+
// AssistantExecutor is non-nil when the session opted into the in-process
92+
// LocalAI Assistant tool surface. Tool calls whose name matches this
93+
// executor's catalog are run inproc and their output is fed back to the
94+
// model server-side; the client never sees a function_call_arguments
95+
// event for those. Mirrors the chat handler's metadata.localai_assistant
96+
// path.
97+
AssistantExecutor mcpTools.ToolExecutor
98+
8999
// Response cancellation: protects activeResponseCancel/activeResponseDone
90100
responseMu sync.Mutex
91101
activeResponseCancel context.CancelFunc
@@ -211,6 +221,19 @@ func RealtimeTranscriptionSession(application *application.Application) echo.Han
211221
}
212222
}
213223

224+
// RealtimeSessionOptions bundles per-session knobs decoded from the WS query
225+
// string (or the WebRTC handshake body). Mirrors what chat.go pulls off
226+
// `metadata.localai_assistant` — admin-only opt-in to the in-process
227+
// management tool surface.
228+
type RealtimeSessionOptions struct {
229+
LocalAIAssistant bool
230+
// AuthEnabled mirrors chat.go's requireAssistantAccess gate. We resolve
231+
// admin role at handshake time (where the echo.Context has the auth
232+
// cookie/Bearer) and drop the result here so runRealtimeSession can
233+
// decide without holding onto the request.
234+
IsAdmin bool
235+
}
236+
214237
func Realtime(application *application.Application) echo.HandlerFunc {
215238
return func(c echo.Context) error {
216239
ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
@@ -224,18 +247,33 @@ func Realtime(application *application.Application) echo.HandlerFunc {
224247

225248
// Extract query parameters from Echo context before passing to websocket handler
226249
model := c.QueryParam("model")
250+
opts := RealtimeSessionOptions{
251+
LocalAIAssistant: c.QueryParam("localai_assistant") == "1" || c.QueryParam("localai_assistant") == "true",
252+
IsAdmin: isCurrentUserAdmin(c, application),
253+
}
227254

228-
registerRealtime(application, model)(ws)
255+
registerRealtime(application, model, opts)(ws)
229256
return nil
230257
}
231258
}
232259

233-
func registerRealtime(application *application.Application, model string) func(c *websocket.Conn) {
260+
// isCurrentUserAdmin replicates the chat-side admin check at the realtime
261+
// handshake. When auth is disabled, every caller is treated as admin (same
262+
// as chat's requireAssistantAccess).
263+
func isCurrentUserAdmin(c echo.Context, application *application.Application) bool {
264+
if application == nil || application.ApplicationConfig() == nil || !application.ApplicationConfig().Auth.Enabled {
265+
return true
266+
}
267+
user := auth.GetUser(c)
268+
return user != nil && user.Role == auth.RoleAdmin
269+
}
270+
271+
func registerRealtime(application *application.Application, model string, opts RealtimeSessionOptions) func(c *websocket.Conn) {
234272
return func(conn *websocket.Conn) {
235273
t := NewWebSocketTransport(conn)
236274
evaluator := application.TemplatesEvaluator()
237275
xlog.Debug("Realtime WebSocket connection established", "address", conn.RemoteAddr().String(), "model", model)
238-
runRealtimeSession(application, t, model, evaluator)
276+
runRealtimeSession(application, t, model, evaluator, opts)
239277
}
240278
}
241279

@@ -306,7 +344,7 @@ func prepareRealtimeConfig(cfg *config.ModelConfig) (errCode, errMsg string, ok
306344

307345
// runRealtimeSession runs the main event loop for a realtime session.
308346
// It is transport-agnostic and works with both WebSocket and WebRTC.
309-
func runRealtimeSession(application *application.Application, t Transport, model string, evaluator *templates.Evaluator) {
347+
func runRealtimeSession(application *application.Application, t Transport, model string, evaluator *templates.Evaluator, opts RealtimeSessionOptions) {
310348
cl := application.ModelConfigLoader()
311349
cfg, err := cl.LoadModelConfigFileByNameDefaultOptions(model, application.ApplicationConfig())
312350
if err != nil {
@@ -321,16 +359,72 @@ func runRealtimeSession(application *application.Application, t Transport, model
321359
return
322360
}
323361

362+
// LocalAI Assistant opt-in: gate on admin (same rule as chat.go's
363+
// requireAssistantAccess) and grab the process-wide holder's executor.
364+
// We collect tools + system prompt here and merge them into the session
365+
// below so they're live from the first response.create.
366+
var assistantTools []types.ToolUnion
367+
var assistantSystemPrompt string
368+
var assistantExecutor mcpTools.ToolExecutor
369+
if opts.LocalAIAssistant {
370+
if !opts.IsAdmin {
371+
sendError(t, "forbidden", "localai_assistant requires admin", "", "")
372+
return
373+
}
374+
appCfg := application.ApplicationConfig()
375+
if appCfg != nil && appCfg.DisableLocalAIAssistant {
376+
sendError(t, "unavailable", "LocalAI Assistant is disabled on this server", "", "")
377+
return
378+
}
379+
holder := application.LocalAIAssistant()
380+
if holder == nil || !holder.HasTools() {
381+
sendError(t, "unavailable", "LocalAI Assistant is not available on this server", "", "")
382+
return
383+
}
384+
exec := holder.Executor()
385+
fns, discErr := exec.DiscoverTools(context.Background())
386+
if discErr != nil {
387+
xlog.Error("realtime: failed to discover LocalAI Assistant tools", "error", discErr)
388+
sendError(t, "tool_discovery_failed", "failed to discover assistant tools: "+discErr.Error(), "", "")
389+
return
390+
}
391+
assistantExecutor = exec
392+
assistantSystemPrompt = holder.SystemPrompt()
393+
assistantTools = make([]types.ToolUnion, 0, len(fns))
394+
for _, fn := range fns {
395+
fnCopy := fn
396+
assistantTools = append(assistantTools, types.ToolUnion{
397+
Function: &types.ToolFunction{
398+
Name: fnCopy.Name,
399+
Description: fnCopy.Description,
400+
Parameters: fnCopy.Parameters,
401+
},
402+
})
403+
}
404+
xlog.Debug("realtime: LocalAI Assistant tools injected", "count", len(fns))
405+
}
406+
324407
sttModel := cfg.Pipeline.Transcription
325408

409+
// Compose the system prompt: prepend the assistant prompt when we have
410+
// one (it teaches the model the safety rules and tool recipes), then the
411+
// session's default voice instructions. Order matches chat.go's
412+
// hasSystemMessage check — assistant prompt comes first.
413+
instructions := defaultInstructions
414+
if assistantSystemPrompt != "" {
415+
instructions = assistantSystemPrompt + "\n\n" + defaultInstructions
416+
}
417+
326418
sessionID := generateSessionID()
327419
session := &Session{
328420
ID: sessionID,
329421
TranscriptionOnly: false,
330422
Model: model,
331423
Voice: cfg.TTSConfig.Voice,
332-
Instructions: defaultInstructions,
424+
Instructions: instructions,
333425
ModelConfig: cfg,
426+
Tools: assistantTools,
427+
AssistantExecutor: assistantExecutor,
334428
TurnDetection: &types.TurnDetectionUnion{
335429
ServerVad: &types.ServerVad{
336430
Threshold: 0.5,
@@ -1647,8 +1741,16 @@ func triggerResponse(ctx context.Context, session *Session, conv *Conversation,
16471741
})
16481742
}
16491743

1650-
// Handle Tool Calls
1744+
// Handle Tool Calls. Two paths:
1745+
// - LocalAI Assistant tools (session.AssistantExecutor.IsTool) run
1746+
// server-side; we append both the call and its output to conv.Items
1747+
// and re-trigger a follow-up response so the model can speak the
1748+
// result. The client only sees observability events.
1749+
// - All other tools follow the standard OpenAI flow: emit
1750+
// function_call_arguments.done and wait for the client to send
1751+
// conversation.item.create back.
16511752
xlog.Debug("About to handle tool calls", "finalToolCallsCount", len(finalToolCalls))
1753+
executedAssistantTool := false
16521754
for i, tc := range finalToolCalls {
16531755
toolCallID := generateItemID()
16541756
callID := "call_" + generateUniqueID() // OpenAI uses call_xyz
@@ -1680,6 +1782,34 @@ func triggerResponse(ctx context.Context, session *Session, conv *Conversation,
16801782
Item: fcItem,
16811783
})
16821784

1785+
serverSide := session.AssistantExecutor != nil && session.AssistantExecutor.IsTool(tc.Name)
1786+
if serverSide {
1787+
output, execErr := session.AssistantExecutor.ExecuteTool(ctx, tc.Name, tc.Arguments)
1788+
if execErr != nil {
1789+
output = "Error: " + execErr.Error()
1790+
xlog.Error("realtime: assistant tool execution failed", "tool", tc.Name, "error", execErr)
1791+
}
1792+
foItem := types.MessageItemUnion{
1793+
FunctionCallOutput: &types.MessageItemFunctionCallOutput{
1794+
ID: generateItemID(),
1795+
CallID: callID,
1796+
Output: output,
1797+
Status: types.ItemStatusCompleted,
1798+
},
1799+
}
1800+
conv.Lock.Lock()
1801+
conv.Items = append(conv.Items, &foItem)
1802+
conv.Lock.Unlock()
1803+
sendEvent(t, types.ResponseOutputItemDoneEvent{
1804+
ServerEventBase: types.ServerEventBase{},
1805+
ResponseID: responseID,
1806+
OutputIndex: outputIndex,
1807+
Item: fcItem,
1808+
})
1809+
executedAssistantTool = true
1810+
continue
1811+
}
1812+
16831813
sendEvent(t, types.ResponseFunctionCallArgumentsDeltaEvent{
16841814
ServerEventBase: types.ServerEventBase{},
16851815
ResponseID: responseID,
@@ -1715,6 +1845,13 @@ func triggerResponse(ctx context.Context, session *Session, conv *Conversation,
17151845
Status: types.ResponseStatusCompleted,
17161846
},
17171847
})
1848+
1849+
// If we executed any assistant tools inproc, run another response cycle
1850+
// so the model can speak the result. Mirrors the chat-side agentic loop
1851+
// but driven server-side rather than by client round-trip.
1852+
if executedAssistantTool {
1853+
triggerResponse(ctx, session, conv, t, nil)
1854+
}
17181855
}
17191856

17201857
// Helper functions to generate unique IDs

core/http/endpoints/openai/realtime_webrtc.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import (
1515
type RealtimeCallRequest struct {
1616
SDP string `json:"sdp"`
1717
Model string `json:"model"`
18+
// LocalAIAssistant opts the session into the in-process admin tool
19+
// surface (same modality as the chat page's "Manage Mode"). Admin-only;
20+
// the realtime entry point gates it the same way the chat handler does.
21+
LocalAIAssistant bool `json:"localai_assistant,omitempty"`
1822
}
1923

2024
// RealtimeCallResponse is the JSON response for POST /v1/realtime/calls.
@@ -165,9 +169,13 @@ func RealtimeCalls(application *application.Application) echo.HandlerFunc {
165169

166170
// Start the realtime session in a goroutine
167171
evaluator := application.TemplatesEvaluator()
172+
opts := RealtimeSessionOptions{
173+
LocalAIAssistant: req.LocalAIAssistant,
174+
IsAdmin: isCurrentUserAdmin(c, application),
175+
}
168176
go func() {
169177
defer transport.Close()
170-
runRealtimeSession(application, transport, req.Model, evaluator)
178+
runRealtimeSession(application, transport, req.Model, evaluator, opts)
171179
}()
172180

173181
return c.JSON(http.StatusCreated, RealtimeCallResponse{

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ModelSelector from '../components/ModelSelector'
55
import ClientMCPDropdown from '../components/ClientMCPDropdown'
66
import { useMCPClient } from '../hooks/useMCPClient'
77
import { loadClientMCPServers } from '../utils/mcpClientStorage'
8+
import { useAuth } from '../context/AuthContext'
89

910
const STATUS_STYLES = {
1011
disconnected: { icon: 'fa-solid fa-circle', color: 'var(--color-text-secondary)', bg: 'transparent' },
@@ -58,6 +59,12 @@ export default function Talk() {
5859
getConnectedTools,
5960
} = useMCPClient()
6061

62+
// LocalAI Assistant ("Manage Mode") — mirrors the chat-page toggle.
63+
// Admin-only; the realtime endpoint enforces the gate too. When on, the
64+
// backend mounts the in-process MCP admin tool surface for this session.
65+
const { isAdmin } = useAuth()
66+
const [manageMode, setManageMode] = useState(false)
67+
6168
// Diagnostics
6269
const [diagVisible, setDiagVisible] = useState(false)
6370

@@ -336,6 +343,7 @@ export default function Talk() {
336343
const data = await realtimeApi.call({
337344
sdp: pc.localDescription.sdp,
338345
model: selectedModel,
346+
localai_assistant: manageMode,
339347
})
340348

341349
await pc.setRemoteDescription({ type: 'answer', sdp: data.sdp })
@@ -344,7 +352,7 @@ export default function Talk() {
344352
updateStatus('error', 'Connection failed: ' + err.message)
345353
disconnect()
346354
}
347-
}, [selectedModel, diagVisible, handleServerEvent, updateStatus, addToast])
355+
}, [selectedModel, manageMode, diagVisible, handleServerEvent, updateStatus, addToast])
348356

349357
// ── Disconnect ──
350358
const disconnect = useCallback(() => {
@@ -620,6 +628,26 @@ export default function Talk() {
620628
connectionStatuses={connectionStatuses}
621629
getConnectedTools={getConnectedTools}
622630
/>
631+
{isAdmin && (
632+
<label style={{
633+
display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)',
634+
marginTop: 'var(--spacing-xs)', fontSize: '0.8125rem',
635+
cursor: isConnected ? 'not-allowed' : 'pointer',
636+
color: isConnected ? 'var(--color-text-secondary)' : 'var(--color-text)',
637+
}}>
638+
<input
639+
type="checkbox"
640+
checked={manageMode}
641+
disabled={isConnected}
642+
onChange={(e) => setManageMode(e.target.checked)}
643+
/>
644+
<i className="fas fa-user-shield" style={{ color: 'var(--color-primary)' }} />
645+
Manage Mode
646+
<span style={{ color: 'var(--color-text-secondary)', fontSize: '0.75rem' }}>
647+
— let the model query LocalAI (models, backends, system info)
648+
</span>
649+
</label>
650+
)}
623651
</div>
624652

625653
{/* Pipeline details */}

0 commit comments

Comments
 (0)