mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
feat(ws-chat): add HTTP file serving, WS media delivery, and streaming UX improvements
- Add GET /v1/files/{path} endpoint with Bearer token + query param auth
- Pre-convert media to markdown HTTP URLs in announce messages for WS channel
- Add HideInput flag to skip persisting system messages in session history
- Add fallback to append image URLs if LLM strips them from announce response
- Fix stream-to-history DOM flash by promoting streamed text locally
- Fix tool call card word-wrap and expandable arguments panel
- Fix snake_case mismatch in Message types (toolCalls → tool_calls)
- Add clickable image rendering in markdown renderer
This commit is contained in:
1 parent
b62d46e50e
commit
892ee8ea70
21 files changed
+426
-79
No files matched your search
@@ -242,6 +242,9 @@ func runGateway() {
|
||||
if meta.OriginLocalKey != "" {
|
||||
batchMeta["origin_local_key"] = meta.OriginLocalKey
|
||||
}
|
||||
if meta.OriginSessionKey != "" {
|
||||
batchMeta["origin_session_key"] = meta.OriginSessionKey
|
||||
}
|
||||
msgBus.PublishInbound(bus.InboundMessage{
|
||||
Channel: "system",
|
||||
SenderID: senderID,
|
||||
@@ -617,6 +620,9 @@ func runGateway() {
|
||||
server.SetBuiltinToolsHandler(builtinToolsH)
|
||||
}
|
||||
|
||||
// Workspace file serving endpoint
|
||||
server.SetFilesHandler(httpapi.NewFilesHandler(workspace, cfg.Gateway.Token))
|
||||
|
||||
// Seed + apply builtin tool disables
|
||||
if pgStores.BuiltinTools != nil {
|
||||
seedBuiltinTools(context.Background(), pgStores.BuiltinTools)
|
||||
|
||||
+129
-18
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -390,11 +392,13 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
continue
|
||||
}
|
||||
|
||||
// Use SAME session as user's original chat so agent has context.
|
||||
sessionKey := sessions.BuildScopedSessionKey(parentAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
|
||||
|
||||
// Override session key for forum topics / DM threads (same logic as main lane).
|
||||
sessionKey = overrideSessionKeyFromLocalKey(sessionKey, origLocalKey, parentAgent, origChannel, msg.ChatID, origPeerKind)
|
||||
// Use exact origin session key if available (WS uses non-standard format).
|
||||
sessionKey := msg.Metadata["origin_session_key"]
|
||||
if sessionKey == "" {
|
||||
// Fallback: rebuild session key from origin metadata (works for Telegram, Discord, etc.)
|
||||
sessionKey = sessions.BuildScopedSessionKey(parentAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
|
||||
sessionKey = overrideSessionKeyFromLocalKey(sessionKey, origLocalKey, parentAgent, origChannel, msg.ChatID, origPeerKind)
|
||||
}
|
||||
|
||||
slog.Info("subagent announce → scheduler (subagent lane)",
|
||||
"subagent", msg.SenderID,
|
||||
@@ -421,10 +425,25 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
outMeta := buildAnnounceOutMeta(origLocalKey)
|
||||
|
||||
// Build request before goroutine to capture msg fields.
|
||||
// WS: convert media to markdown URLs inline (no outbound channel handler).
|
||||
// Must happen BEFORE the run so the agent loop saves images in the session
|
||||
// before run.completed fires — avoids race with frontend loadHistory().
|
||||
fwdMedia := msg.Media
|
||||
announceMessage := msg.Content
|
||||
wsMediaSuffix := ""
|
||||
if origChannel == "ws" && len(msg.Media) > 0 {
|
||||
mdMedia := mediaToMarkdownFromPaths(msg.Media, cfg.WorkspacePath(), cfg)
|
||||
if mdMedia != "" {
|
||||
announceMessage += "\n\n[Image URLs for web display — include these EXACTLY as-is at the end of your response]" + mdMedia
|
||||
wsMediaSuffix = mdMedia
|
||||
}
|
||||
fwdMedia = nil // already embedded as markdown
|
||||
}
|
||||
|
||||
announceReq := agent.RunRequest{
|
||||
SessionKey: sessionKey,
|
||||
Message: msg.Content,
|
||||
ForwardMedia: msg.Media,
|
||||
Message: announceMessage,
|
||||
ForwardMedia: fwdMedia,
|
||||
Channel: origChannel,
|
||||
ChatID: msg.ChatID,
|
||||
PeerKind: origPeerKind,
|
||||
@@ -432,15 +451,15 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
UserID: announceUserID,
|
||||
RunID: fmt.Sprintf("announce-%s", msg.SenderID),
|
||||
RunKind: "announce",
|
||||
HideInput: true, // don't persist raw system message in chat history
|
||||
Stream: false,
|
||||
ParentTraceID: parentTraceID,
|
||||
ParentRootSpanID: parentRootSpanID,
|
||||
}
|
||||
|
||||
// Handle announce asynchronously with per-session serialization.
|
||||
// The mutex ensures concurrent announces for the same session wait for
|
||||
// each other, so each reads up-to-date session history.
|
||||
go func(sessionKey, origCh, chatID, senderID, label string, meta map[string]string, req agent.RunRequest) {
|
||||
go func(sessionKey, origCh, chatID, senderID, label string, meta map[string]string, req agent.RunRequest, mediaSuffix string) {
|
||||
mu := getAnnounceMu(sessionKey)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
@@ -477,6 +496,12 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
if isSilent {
|
||||
announceContent = "" // suppress NO_REPLY text but still send media
|
||||
}
|
||||
|
||||
// Fallback: if LLM stripped the image markdown URLs, append them.
|
||||
if mediaSuffix != "" && !strings.Contains(announceContent, mediaSuffix) {
|
||||
announceContent += mediaSuffix
|
||||
}
|
||||
|
||||
outMsg := bus.OutboundMessage{
|
||||
Channel: origCh,
|
||||
ChatID: chatID,
|
||||
@@ -490,7 +515,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
})
|
||||
}
|
||||
msgBus.PublishOutbound(outMsg)
|
||||
}(sessionKey, origChannel, msg.ChatID, msg.SenderID, msg.Metadata["subagent_label"], outMeta, announceReq)
|
||||
}(sessionKey, origChannel, msg.ChatID, msg.SenderID, msg.Metadata["subagent_label"], outMeta, announceReq, wsMediaSuffix)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -513,10 +538,13 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
continue
|
||||
}
|
||||
|
||||
sessionKey := sessions.BuildScopedSessionKey(parentAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
|
||||
|
||||
// Override session key for forum topics / DM threads.
|
||||
sessionKey = overrideSessionKeyFromLocalKey(sessionKey, origLocalKey, parentAgent, origChannel, msg.ChatID, origPeerKind)
|
||||
// Use exact origin session key if available (WS uses non-standard format).
|
||||
sessionKey := msg.Metadata["origin_session_key"]
|
||||
if sessionKey == "" {
|
||||
// Fallback: rebuild session key from origin metadata (works for Telegram, Discord, etc.)
|
||||
sessionKey = sessions.BuildScopedSessionKey(parentAgent, origChannel, sessions.PeerKind(origPeerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)
|
||||
sessionKey = overrideSessionKeyFromLocalKey(sessionKey, origLocalKey, parentAgent, origChannel, msg.ChatID, origPeerKind)
|
||||
}
|
||||
|
||||
slog.Info("delegate announce → scheduler (delegate lane)",
|
||||
"delegation", msg.SenderID,
|
||||
@@ -541,10 +569,23 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
// Build outbound metadata for topic/thread routing.
|
||||
outMeta := buildAnnounceOutMeta(origLocalKey)
|
||||
|
||||
// WS: convert media to markdown URLs inline (same as subagent announce).
|
||||
fwdMedia := msg.Media
|
||||
announceMessage := msg.Content
|
||||
wsMediaSuffix := ""
|
||||
if origChannel == "ws" && len(msg.Media) > 0 {
|
||||
mdMedia := mediaToMarkdownFromPaths(msg.Media, cfg.WorkspacePath(), cfg)
|
||||
if mdMedia != "" {
|
||||
announceMessage += "\n\n[Image URLs for web display — include these EXACTLY as-is at the end of your response]" + mdMedia
|
||||
wsMediaSuffix = mdMedia
|
||||
}
|
||||
fwdMedia = nil
|
||||
}
|
||||
|
||||
announceReq := agent.RunRequest{
|
||||
SessionKey: sessionKey,
|
||||
Message: msg.Content,
|
||||
ForwardMedia: msg.Media,
|
||||
Message: announceMessage,
|
||||
ForwardMedia: fwdMedia,
|
||||
Channel: origChannel,
|
||||
ChatID: msg.ChatID,
|
||||
PeerKind: origPeerKind,
|
||||
@@ -552,13 +593,14 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
UserID: announceUserID,
|
||||
RunID: fmt.Sprintf("delegate-announce-%s", msg.Metadata["delegation_id"]),
|
||||
RunKind: "announce",
|
||||
HideInput: true, // don't persist raw system message in chat history
|
||||
Stream: false,
|
||||
ParentTraceID: parentTraceID,
|
||||
ParentRootSpanID: parentRootSpanID,
|
||||
}
|
||||
|
||||
// Same per-session serialization as subagent announce above.
|
||||
go func(sessionKey, origCh, chatID, senderID string, meta map[string]string, req agent.RunRequest) {
|
||||
go func(sessionKey, origCh, chatID, senderID string, meta map[string]string, req agent.RunRequest, mediaSuffix string) {
|
||||
mu := getAnnounceMu(sessionKey)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
@@ -588,6 +630,12 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
if isSilent {
|
||||
announceContent = "" // suppress NO_REPLY text but still send media
|
||||
}
|
||||
|
||||
// Fallback: if LLM stripped the image markdown URLs, append them.
|
||||
if mediaSuffix != "" && !strings.Contains(announceContent, mediaSuffix) {
|
||||
announceContent += mediaSuffix
|
||||
}
|
||||
|
||||
outMsg := bus.OutboundMessage{
|
||||
Channel: origCh,
|
||||
ChatID: chatID,
|
||||
@@ -601,7 +649,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
})
|
||||
}
|
||||
msgBus.PublishOutbound(outMsg)
|
||||
}(sessionKey, origChannel, msg.ChatID, msg.SenderID, outMeta, announceReq)
|
||||
}(sessionKey, origChannel, msg.ChatID, msg.SenderID, outMeta, announceReq, wsMediaSuffix)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -862,6 +910,69 @@ func overrideSessionKeyFromLocalKey(sessionKey, localKey, agentID, channel, chat
|
||||
|
||||
// buildAnnounceOutMeta builds outbound metadata for announce messages so that
|
||||
// Send() can route replies to the correct forum topic or DM thread.
|
||||
// mediaToMarkdown converts media results to markdown image/link syntax using the
|
||||
// /v1/files/ HTTP endpoint. Used for WS channel where outbound media attachments
|
||||
// are not supported (no channel handler). Returns empty string if no media.
|
||||
func mediaToMarkdown(media []agent.MediaResult, workspace string, cfg *config.Config) string {
|
||||
if len(media) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if cfg.Tailscale.EnableTLS {
|
||||
scheme = "https"
|
||||
}
|
||||
host := cfg.Gateway.Host
|
||||
if host == "" || host == "0.0.0.0" {
|
||||
host = "localhost"
|
||||
}
|
||||
baseURL := fmt.Sprintf("%s://%s:%d/v1/files", scheme, host, cfg.Gateway.Port)
|
||||
tokenQuery := ""
|
||||
if cfg.Gateway.Token != "" {
|
||||
tokenQuery = "?token=" + cfg.Gateway.Token
|
||||
}
|
||||
|
||||
cleanRoot := filepath.Clean(workspace)
|
||||
var parts []string
|
||||
for _, mr := range media {
|
||||
cleanPath := filepath.Clean(mr.Path)
|
||||
relPath, err := filepath.Rel(cleanRoot, cleanPath)
|
||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||
continue
|
||||
}
|
||||
fileURL := baseURL + "/" + relPath + tokenQuery
|
||||
if strings.HasPrefix(mr.ContentType, "image/") {
|
||||
parts = append(parts, fmt.Sprintf("", fileURL))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("[%s](%s)", filepath.Base(mr.Path), fileURL))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "\n\n" + strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// mediaToMarkdownFromPaths is like mediaToMarkdown but accepts raw file paths
|
||||
// ([]string from bus.InboundMessage.Media) instead of []agent.MediaResult.
|
||||
func mediaToMarkdownFromPaths(paths []string, workspace string, cfg *config.Config) string {
|
||||
if len(paths) == 0 {
|
||||
return ""
|
||||
}
|
||||
media := make([]agent.MediaResult, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
ct := mime.TypeByExtension(filepath.Ext(p))
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
media = append(media, agent.MediaResult{
|
||||
Path: p,
|
||||
ContentType: ct,
|
||||
})
|
||||
}
|
||||
return mediaToMarkdown(media, workspace, cfg)
|
||||
}
|
||||
|
||||
func buildAnnounceOutMeta(localKey string) map[string]string {
|
||||
if localKey == "" {
|
||||
return nil
|
||||
|
||||
+24
-14
@@ -355,10 +355,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
// This prevents concurrent runs from seeing each other's in-progress messages.
|
||||
// NOTE: pendingMsgs stores TEXT ONLY (no images) to avoid bloating session storage.
|
||||
var pendingMsgs []providers.Message
|
||||
pendingMsgs = append(pendingMsgs, providers.Message{
|
||||
Role: "user",
|
||||
Content: req.Message,
|
||||
})
|
||||
if !req.HideInput {
|
||||
pendingMsgs = append(pendingMsgs, providers.Message{
|
||||
Role: "user",
|
||||
Content: req.Message,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Run LLM iteration loop
|
||||
var loopDetector toolLoopState // detects repeated no-progress tool calls
|
||||
@@ -668,15 +670,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}
|
||||
}
|
||||
|
||||
toolResultPayload := map[string]interface{}{
|
||||
"name": tc.Name,
|
||||
"id": tc.ID,
|
||||
"is_error": result.IsError,
|
||||
}
|
||||
if result.IsError && result.ForLLM != "" {
|
||||
toolResultPayload["content"] = result.ForLLM
|
||||
}
|
||||
emitRun(AgentEvent{
|
||||
Type: protocol.AgentEventToolResult,
|
||||
AgentID: l.id,
|
||||
RunID: req.RunID,
|
||||
Payload: map[string]interface{}{
|
||||
"name": tc.Name,
|
||||
"id": tc.ID,
|
||||
"is_error": result.IsError,
|
||||
},
|
||||
Payload: toolResultPayload,
|
||||
})
|
||||
|
||||
l.scanWebToolResult(tc.Name, result)
|
||||
@@ -797,15 +803,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
}
|
||||
}
|
||||
|
||||
parToolResultPayload := map[string]interface{}{
|
||||
"name": r.tc.Name,
|
||||
"id": r.tc.ID,
|
||||
"is_error": r.result.IsError,
|
||||
}
|
||||
if r.result.IsError && r.result.ForLLM != "" {
|
||||
parToolResultPayload["content"] = r.result.ForLLM
|
||||
}
|
||||
emitRun(AgentEvent{
|
||||
Type: protocol.AgentEventToolResult,
|
||||
AgentID: l.id,
|
||||
RunID: req.RunID,
|
||||
Payload: map[string]interface{}{
|
||||
"name": r.tc.Name,
|
||||
"id": r.tc.ID,
|
||||
"is_error": r.result.IsError,
|
||||
},
|
||||
Payload: parToolResultPayload,
|
||||
})
|
||||
|
||||
l.scanWebToolResult(r.tc.Name, r.result)
|
||||
|
||||
@@ -276,7 +276,8 @@ type RunRequest struct {
|
||||
MaxIterations int // per-request override (0 = use agent default, must be lower)
|
||||
|
||||
// Run classification
|
||||
RunKind string // "delegation", "announce" — empty for user-initiated runs
|
||||
RunKind string // "delegation", "announce" — empty for user-initiated runs
|
||||
HideInput bool // don't persist input message in session history (announce runs)
|
||||
|
||||
// Delegation context (set when running as a delegate agent)
|
||||
DelegationID string // delegation ID for event correlation
|
||||
|
||||
@@ -45,6 +45,7 @@ type Server struct {
|
||||
delegationsHandler *httpapi.DelegationsHandler // delegation history API
|
||||
builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API
|
||||
oauthHandler *httpapi.OAuthHandler // OAuth endpoints
|
||||
filesHandler *httpapi.FilesHandler // workspace file serving
|
||||
agentStore store.AgentStore // for context injection in tools_invoke
|
||||
|
||||
upgrader websocket.Upgrader
|
||||
@@ -195,6 +196,11 @@ func (s *Server) BuildMux() *http.ServeMux {
|
||||
s.builtinToolsHandler.RegisterRoutes(mux)
|
||||
}
|
||||
|
||||
// Workspace file serving (available in all modes)
|
||||
if s.filesHandler != nil {
|
||||
s.filesHandler.RegisterRoutes(mux)
|
||||
}
|
||||
|
||||
// OAuth endpoints (available in all modes)
|
||||
if s.oauthHandler != nil {
|
||||
s.oauthHandler.RegisterRoutes(mux)
|
||||
@@ -338,6 +344,9 @@ func (s *Server) SetBuiltinToolsHandler(h *httpapi.BuiltinToolsHandler) {
|
||||
// SetOAuthHandler sets the OAuth handler (available in all modes).
|
||||
func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.oauthHandler = h }
|
||||
|
||||
// SetFilesHandler sets the workspace file serving handler.
|
||||
func (s *Server) SetFilesHandler(h *httpapi.FilesHandler) { s.filesHandler = h }
|
||||
|
||||
// SetAgentStore sets the agent store for context injection in tools_invoke.
|
||||
func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as }
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FilesHandler serves workspace files over HTTP with Bearer token auth.
|
||||
type FilesHandler struct {
|
||||
workspaceRoot string
|
||||
token string
|
||||
}
|
||||
|
||||
// NewFilesHandler creates a handler that serves files from the workspace root.
|
||||
func NewFilesHandler(workspaceRoot, token string) *FilesHandler {
|
||||
return &FilesHandler{workspaceRoot: workspaceRoot, token: token}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers the file serving route.
|
||||
func (h *FilesHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /v1/files/{path...}", h.auth(h.handleServe))
|
||||
}
|
||||
|
||||
func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if h.token != "" {
|
||||
// Accept token via Bearer header or ?token= query param (for <img src>).
|
||||
provided := extractBearerToken(r)
|
||||
if provided == "" {
|
||||
provided = r.URL.Query().Get("token")
|
||||
}
|
||||
if provided != h.token {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) {
|
||||
relPath := r.PathValue("path")
|
||||
if relPath == "" {
|
||||
http.Error(w, "path is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent path traversal
|
||||
if strings.Contains(relPath, "..") {
|
||||
slog.Warn("security.files_traversal", "path", relPath)
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
absPath := filepath.Join(h.workspaceRoot, relPath)
|
||||
|
||||
// Double-check the resolved path is within the workspace
|
||||
cleanAbs := filepath.Clean(absPath)
|
||||
cleanRoot := filepath.Clean(h.workspaceRoot)
|
||||
if !strings.HasPrefix(cleanAbs, cleanRoot+string(filepath.Separator)) && cleanAbs != cleanRoot {
|
||||
slog.Warn("security.files_escape", "resolved", cleanAbs, "root", cleanRoot)
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := os.Stat(cleanAbs)
|
||||
if err != nil || info.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Set Content-Type from extension
|
||||
ext := filepath.Ext(cleanAbs)
|
||||
ct := mime.TypeByExtension(ext)
|
||||
if ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, cleanAbs)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type AnnounceMetadata struct {
|
||||
OriginPeerKind string
|
||||
OriginLocalKey string // composite key with topic/thread suffix for routing
|
||||
OriginUserID string
|
||||
OriginSessionKey string // exact parent session key (WS uses non-standard format)
|
||||
ParentAgent string
|
||||
OriginTraceID string // parent trace UUID for announce linking
|
||||
OriginRootSpanID string // parent agent's root span UUID
|
||||
|
||||
@@ -21,7 +21,8 @@ const (
|
||||
ctxSandboxKey toolContextKey = "tool_sandbox_key"
|
||||
ctxAsyncCB toolContextKey = "tool_async_cb"
|
||||
ctxWorkspace toolContextKey = "tool_workspace"
|
||||
ctxAgentKey toolContextKey = "tool_agent_key"
|
||||
ctxAgentKey toolContextKey = "tool_agent_key"
|
||||
ctxSessionKey toolContextKey = "tool_session_key" // origin session key for announce routing
|
||||
)
|
||||
|
||||
func WithToolChannel(ctx context.Context, channel string) context.Context {
|
||||
@@ -101,6 +102,18 @@ func ToolAgentKeyFromCtx(ctx context.Context) string {
|
||||
return v
|
||||
}
|
||||
|
||||
// WithToolSessionKey injects the parent's session key so subagent announce
|
||||
// can route results back to the exact same session (required for WS where
|
||||
// session keys don't follow BuildScopedSessionKey format).
|
||||
func WithToolSessionKey(ctx context.Context, key string) context.Context {
|
||||
return context.WithValue(ctx, ctxSessionKey, key)
|
||||
}
|
||||
|
||||
func ToolSessionKeyFromCtx(ctx context.Context) string {
|
||||
v, _ := ctx.Value(ctxSessionKey).(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// --- Vision / ImageGen config (per-agent overrides) ---
|
||||
|
||||
const (
|
||||
|
||||
@@ -33,10 +33,11 @@ type DelegationTask struct {
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
|
||||
// Origin metadata for async announce routing
|
||||
OriginChannel string `json:"-"`
|
||||
OriginChatID string `json:"-"`
|
||||
OriginPeerKind string `json:"-"`
|
||||
OriginLocalKey string `json:"-"` // composite key with topic/thread suffix for routing
|
||||
OriginChannel string `json:"-"`
|
||||
OriginChatID string `json:"-"`
|
||||
OriginPeerKind string `json:"-"`
|
||||
OriginLocalKey string `json:"-"` // composite key with topic/thread suffix for routing
|
||||
OriginSessionKey string `json:"-"` // exact parent session key for announce routing (WS uses non-standard format)
|
||||
|
||||
// Trace context for announce linking (same pattern as SubagentTask)
|
||||
OriginTraceID uuid.UUID `json:"-"`
|
||||
|
||||
@@ -161,6 +161,9 @@ func (dm *DelegateManager) DelegateAsync(ctx context.Context, opts DelegateOpts)
|
||||
if task.OriginLocalKey != "" {
|
||||
announceMeta["origin_local_key"] = task.OriginLocalKey
|
||||
}
|
||||
if task.OriginSessionKey != "" {
|
||||
announceMeta["origin_session_key"] = task.OriginSessionKey
|
||||
}
|
||||
// Emit announce event so WS clients know all results are being sent to lead.
|
||||
hasMedia := len(artifacts.Media) > 0
|
||||
var announceSummaries []protocol.DelegationAnnounceResultSummary
|
||||
|
||||
@@ -174,6 +174,7 @@ func (dm *DelegateManager) prepareDelegation(ctx context.Context, opts DelegateO
|
||||
OriginChatID: chatID,
|
||||
OriginPeerKind: peerKind,
|
||||
OriginLocalKey: localKey,
|
||||
OriginSessionKey: ToolSessionKeyFromCtx(ctx),
|
||||
OriginTraceID: tracing.TraceIDFromContext(ctx),
|
||||
OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx),
|
||||
TeamTaskID: opts.TeamTaskID,
|
||||
|
||||
@@ -88,6 +88,7 @@ func (r *Registry) ExecuteWithContext(ctx context.Context, name string, args map
|
||||
}
|
||||
if sessionKey != "" {
|
||||
ctx = WithToolSandboxKey(ctx, sessionKey)
|
||||
ctx = WithToolSessionKey(ctx, sessionKey)
|
||||
}
|
||||
if asyncCB != nil {
|
||||
ctx = WithToolAsyncCB(ctx, asyncCB)
|
||||
|
||||
+12
-10
@@ -51,11 +51,12 @@ type SubagentTask struct {
|
||||
Result string `json:"result,omitempty"`
|
||||
Depth int `json:"depth"`
|
||||
Model string `json:"model,omitempty"` // model override for this subagent
|
||||
OriginChannel string `json:"originChannel,omitempty"`
|
||||
OriginChatID string `json:"originChatId,omitempty"`
|
||||
OriginPeerKind string `json:"originPeerKind,omitempty"` // "direct" or "group" (for session key building)
|
||||
OriginLocalKey string `json:"originLocalKey,omitempty"` // composite key with topic/thread suffix for routing
|
||||
OriginUserID string `json:"originUserId,omitempty"` // parent's userID for per-user scoping propagation
|
||||
OriginChannel string `json:"originChannel,omitempty"`
|
||||
OriginChatID string `json:"originChatId,omitempty"`
|
||||
OriginPeerKind string `json:"originPeerKind,omitempty"` // "direct" or "group" (for session key building)
|
||||
OriginLocalKey string `json:"originLocalKey,omitempty"` // composite key with topic/thread suffix for routing
|
||||
OriginUserID string `json:"originUserId,omitempty"` // parent's userID for per-user scoping propagation
|
||||
OriginSessionKey string `json:"originSessionKey,omitempty"` // exact parent session key for announce routing (WS uses non-standard format)
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
CompletedAt int64 `json:"completedAt,omitempty"`
|
||||
OriginTraceID uuid.UUID `json:"-"` // parent trace for announce linking
|
||||
@@ -193,11 +194,12 @@ func (sm *SubagentManager) Spawn(
|
||||
OriginChannel: channel,
|
||||
OriginChatID: chatID,
|
||||
OriginPeerKind: peerKind,
|
||||
OriginLocalKey: ToolLocalKeyFromCtx(ctx),
|
||||
OriginUserID: store.UserIDFromContext(ctx),
|
||||
OriginTraceID: tracing.TraceIDFromContext(ctx),
|
||||
OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx),
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
OriginLocalKey: ToolLocalKeyFromCtx(ctx),
|
||||
OriginUserID: store.UserIDFromContext(ctx),
|
||||
OriginSessionKey: ToolSessionKeyFromCtx(ctx),
|
||||
OriginTraceID: tracing.TraceIDFromContext(ctx),
|
||||
OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx),
|
||||
CreatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
// Create per-task context for real goroutine cancellation
|
||||
taskCtx, taskCancel := context.WithCancel(ctx)
|
||||
|
||||
@@ -37,6 +37,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
|
||||
OriginPeerKind: task.OriginPeerKind,
|
||||
OriginLocalKey: task.OriginLocalKey,
|
||||
OriginUserID: task.OriginUserID,
|
||||
OriginSessionKey: task.OriginSessionKey,
|
||||
ParentAgent: task.ParentID,
|
||||
OriginTraceID: task.OriginTraceID.String(),
|
||||
OriginRootSpanID: task.OriginRootSpanID.String(),
|
||||
@@ -63,6 +64,9 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
|
||||
if task.OriginLocalKey != "" {
|
||||
announceMeta["origin_local_key"] = task.OriginLocalKey
|
||||
}
|
||||
if task.OriginSessionKey != "" {
|
||||
announceMeta["origin_session_key"] = task.OriginSessionKey
|
||||
}
|
||||
sm.msgBus.PublishInbound(bus.InboundMessage{
|
||||
Channel: "system",
|
||||
SenderID: fmt.Sprintf("subagent:%s", task.ID),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { Bot, User, Wrench, ChevronRight } from "lucide-react";
|
||||
import { Bot, User, ChevronRight, Check, AlertTriangle } from "lucide-react";
|
||||
import { MessageContent } from "./message-content";
|
||||
import type { ChatMessage } from "@/types/chat";
|
||||
import type { ToolCall } from "@/types/session";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
toolCallErrors?: Map<string, string>;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
export function MessageBubble({ message, toolCallErrors }: MessageBubbleProps) {
|
||||
const isUser = message.role === "user";
|
||||
const isTool = message.role === "tool";
|
||||
|
||||
@@ -17,7 +18,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
}
|
||||
|
||||
const hasContent = !!message.content?.trim();
|
||||
const hasToolCalls = message.toolCalls && message.toolCalls.length > 0;
|
||||
const hasToolCalls = message.tool_calls && message.tool_calls.length > 0;
|
||||
|
||||
// Skip assistant messages with neither text nor tool calls
|
||||
if (!isUser && !hasContent && !hasToolCalls) {
|
||||
@@ -28,8 +29,8 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
if (!isUser && !hasContent && hasToolCalls) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{message.toolCalls!.map((tc) => (
|
||||
<ToolCallItem key={tc.id} toolCall={tc} />
|
||||
{message.tool_calls!.map((tc) => (
|
||||
<ToolCallItem key={tc.id} toolCall={tc} isError={toolCallErrors?.has(tc.id)} errorContent={toolCallErrors?.get(tc.id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -54,8 +55,8 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
>
|
||||
{hasToolCalls && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{message.toolCalls!.map((tc) => (
|
||||
<ToolCallItem key={tc.id} toolCall={tc} compact />
|
||||
{message.tool_calls!.map((tc) => (
|
||||
<ToolCallItem key={tc.id} toolCall={tc} compact isError={toolCallErrors?.has(tc.id)} errorContent={toolCallErrors?.get(tc.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -73,37 +74,44 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallItem({ toolCall, compact }: { toolCall: ToolCall; compact?: boolean }) {
|
||||
function ToolCallItem({ toolCall, compact, isError, errorContent }: { toolCall: ToolCall; compact?: boolean; isError?: boolean; errorContent?: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasArgs = toolCall.arguments && Object.keys(toolCall.arguments).length > 0;
|
||||
const canExpand = hasArgs || (isError && !!errorContent);
|
||||
const iconSize = compact ? "h-3 w-3" : "h-3.5 w-3.5";
|
||||
|
||||
const StatusIcon = isError
|
||||
? <AlertTriangle className={`${iconSize} shrink-0 text-red-500`} />
|
||||
: <Check className={`${iconSize} shrink-0 text-green-500`} />;
|
||||
|
||||
return (
|
||||
<div className={compact ? "" : "rounded-md border bg-muted/50 overflow-hidden"}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasArgs && setExpanded(!expanded)}
|
||||
onClick={() => canExpand && setExpanded(!expanded)}
|
||||
className={`flex items-center gap-1.5 w-full text-left ${
|
||||
compact
|
||||
? "text-xs text-muted-foreground py-0.5"
|
||||
: "px-3 py-1.5 text-sm hover:bg-muted/80 transition-colors"
|
||||
} ${hasArgs ? "cursor-pointer" : "cursor-default"}`}
|
||||
} ${canExpand ? "cursor-pointer" : "cursor-default"}`}
|
||||
>
|
||||
{compact ? (
|
||||
<Wrench className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<Wrench className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{StatusIcon}
|
||||
<span className={`font-mono truncate ${compact ? "" : "text-xs"}`}>{toolCall.name}</span>
|
||||
{hasArgs && (
|
||||
{canExpand && (
|
||||
<ChevronRight className={`h-3 w-3 shrink-0 ml-auto text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`} />
|
||||
)}
|
||||
</button>
|
||||
{expanded && hasArgs && (
|
||||
<pre className={`text-[11px] text-muted-foreground overflow-x-auto ${
|
||||
{expanded && canExpand && (
|
||||
<div className={`text-[11px] overflow-auto max-h-48 ${
|
||||
compact ? "pl-4.5 pb-1" : "px-3 pb-2 border-t bg-muted/30"
|
||||
}`}>
|
||||
{JSON.stringify(toolCall.arguments, null, 2)}
|
||||
</pre>
|
||||
{isError && errorContent && (
|
||||
<pre className="text-red-500 whitespace-pre-wrap">{errorContent}</pre>
|
||||
)}
|
||||
{hasArgs && (
|
||||
<pre className="text-muted-foreground">{JSON.stringify(toolCall.arguments, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Wrench, Check, AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Wrench, Check, AlertTriangle, Loader2, ChevronRight } from "lucide-react";
|
||||
import type { ToolStreamEntry } from "@/types/chat";
|
||||
|
||||
interface ToolCallCardProps {
|
||||
@@ -6,11 +7,41 @@ interface ToolCallCardProps {
|
||||
}
|
||||
|
||||
export function ToolCallCard({ entry }: ToolCallCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasArgs = entry.arguments && Object.keys(entry.arguments).length > 0;
|
||||
const hasError = entry.phase === "error" && !!entry.errorContent;
|
||||
const canExpand = hasArgs || hasError;
|
||||
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
||||
<ToolIcon phase={entry.phase} />
|
||||
<span className="font-medium">{entry.name}</span>
|
||||
<PhaseLabel phase={entry.phase} />
|
||||
<div className="my-1 rounded-md border bg-muted/50 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canExpand && setExpanded(!expanded)}
|
||||
className={`flex items-start gap-2 w-full text-left px-3 py-2 text-sm ${
|
||||
canExpand ? "cursor-pointer hover:bg-muted/80 transition-colors" : "cursor-default"
|
||||
}`}
|
||||
>
|
||||
<ToolIcon phase={entry.phase} />
|
||||
<span className="font-mono text-xs break-all">{entry.name}</span>
|
||||
<PhaseLabel phase={entry.phase} />
|
||||
{canExpand && (
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 shrink-0 text-muted-foreground transition-transform ${
|
||||
expanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{expanded && canExpand && (
|
||||
<div className="px-3 pb-2 border-t bg-muted/30 text-[11px] overflow-auto max-h-48">
|
||||
{hasError && (
|
||||
<pre className="text-red-500 whitespace-pre-wrap">{entry.errorContent}</pre>
|
||||
)}
|
||||
{hasArgs && (
|
||||
<pre className="text-muted-foreground whitespace-pre-wrap break-words">{JSON.stringify(entry.arguments, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,19 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps)
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img({ src, alt, ...props }) {
|
||||
return (
|
||||
<a href={src} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? "image"}
|
||||
className="max-w-sm rounded-lg border shadow-sm"
|
||||
loading="lazy"
|
||||
{...props}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
table({ children, ...props }) {
|
||||
return (
|
||||
<div className="my-2 overflow-x-auto">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { Circle } from "lucide-react";
|
||||
import { MessageBubble } from "@/components/chat/message-bubble";
|
||||
import { StreamingText } from "@/components/chat/streaming-text";
|
||||
@@ -28,6 +29,19 @@ export function ChatThread({
|
||||
[messages.length, streamText, thinkingText, toolStream.length],
|
||||
);
|
||||
|
||||
// Build map of tool_call_id → error content for tool results that indicate errors
|
||||
const toolCallErrors = useMemo(() => {
|
||||
const errors = new Map<string, string>();
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "tool" || !msg.tool_call_id || !msg.content) continue;
|
||||
const c = msg.content.trimStart();
|
||||
if (c.startsWith("Error") || c.startsWith("error:") || c.includes("failed:") || c.includes("Failed:")) {
|
||||
errors.set(msg.tool_call_id, msg.content);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}, [messages]);
|
||||
|
||||
// Show spinner while loading history for a different session
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -54,7 +68,7 @@ export function ChatThread({
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={`${msg.role}-${i}`} message={msg} />
|
||||
<MessageBubble key={`${msg.role}-${i}`} message={msg} toolCallErrors={toolCallErrors} />
|
||||
))}
|
||||
|
||||
{/* Tool stream during active run */}
|
||||
|
||||
@@ -26,6 +26,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
const expectingRunRef = useRef(false);
|
||||
const streamRef = useRef("");
|
||||
const thinkingRef = useRef("");
|
||||
const toolStreamRef = useRef<ToolStreamEntry[]>([]);
|
||||
const agentIdRef = useRef(agentId);
|
||||
agentIdRef.current = agentId;
|
||||
|
||||
@@ -44,6 +45,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
expectingRunRef.current = false;
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
toolStreamRef.current = [];
|
||||
}
|
||||
|
||||
// Load history (no loading spinner — the empty state placeholder is shown instead)
|
||||
@@ -87,6 +89,13 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
const event = payload as AgentEventPayload;
|
||||
if (!event) return;
|
||||
|
||||
// Announce run.completed: reload history when a subagent/delegate announce finishes
|
||||
// for the current agent (these runs aren't tracked by runIdRef).
|
||||
if (event.type === "run.completed" && event.runKind === "announce" && event.agentId === agentIdRef.current) {
|
||||
loadHistory();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture run.started when we are expecting a run for this agent
|
||||
if (event.type === "run.started") {
|
||||
if (expectingRunRef.current && event.agentId === agentIdRef.current) {
|
||||
@@ -98,6 +107,7 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
setToolStream([]);
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
toolStreamRef.current = [];
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -123,20 +133,24 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
toolCallId: event.payload?.id ?? "",
|
||||
runId: event.runId,
|
||||
name: event.payload?.name ?? "tool",
|
||||
arguments: event.payload?.arguments,
|
||||
phase: "calling",
|
||||
startedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
setToolStream((prev) => [...prev, entry]);
|
||||
toolStreamRef.current = [...toolStreamRef.current, entry];
|
||||
setToolStream(toolStreamRef.current);
|
||||
break;
|
||||
}
|
||||
case "tool.result": {
|
||||
const isError = event.payload?.is_error;
|
||||
setToolStream((prev) =>
|
||||
prev.map((t) =>
|
||||
t.toolCallId === event.payload?.id
|
||||
? {
|
||||
...t,
|
||||
phase: event.payload?.is_error ? "error" : "completed",
|
||||
phase: isError ? "error" : "completed",
|
||||
errorContent: isError ? event.payload?.content : undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: t,
|
||||
@@ -147,12 +161,28 @@ export function useChatMessages(sessionKey: string, agentId: string) {
|
||||
case "run.completed": {
|
||||
setIsRunning(false);
|
||||
runIdRef.current = null;
|
||||
loadHistory();
|
||||
|
||||
// If we have streamed text and no tool calls, promote it directly
|
||||
// to avoid a flash caused by loadHistory() replacing the DOM.
|
||||
// When tools were used, reload to get structured tool_calls data.
|
||||
const hadTools = toolStreamRef.current.length > 0;
|
||||
const streamed = streamRef.current;
|
||||
|
||||
setStreamText(null);
|
||||
setThinkingText(null);
|
||||
setToolStream([]);
|
||||
streamRef.current = "";
|
||||
thinkingRef.current = "";
|
||||
toolStreamRef.current = [];
|
||||
|
||||
if (streamed && !hadTools) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: streamed, timestamp: Date.now() },
|
||||
]);
|
||||
} else {
|
||||
loadHistory();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "run.failed": {
|
||||
|
||||
@@ -13,12 +13,14 @@ export interface AgentEventPayload {
|
||||
type: string; // "run.started" | "run.completed" | "run.failed" | "chunk" | "tool.call" | "tool.result"
|
||||
agentId: string;
|
||||
runId: string;
|
||||
runKind?: string; // "delegation" | "announce" — omitted for user-initiated runs
|
||||
payload?: {
|
||||
content?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
is_error?: boolean;
|
||||
error?: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +30,8 @@ export interface ToolStreamEntry {
|
||||
runId: string;
|
||||
name: string;
|
||||
phase: "calling" | "completed" | "error";
|
||||
arguments?: Record<string, unknown>;
|
||||
errorContent?: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ export interface SessionPreview {
|
||||
export interface Message {
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
toolCallId?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
|
||||
Reference in new issue
Block a user