Changeset 0.37.18 (#230)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-24 19:55:14 +00:00
committed by xcaliber
parent 96a4f16bc5
commit 3a4afea7f2
34 changed files with 1534 additions and 1290 deletions

View File

@@ -963,6 +963,9 @@ func (h *CompletionHandler) streamCompletion(
log.Printf("Failed to persist assistant message: %v", err)
}
// Record tool_output file refs (v0.37.18)
h.recordToolFileRefs(c.Request.Context(), result.FileRefs, channelID, userID, asstMsgID)
// Broadcast assistant message to other channel participants
if h.hub != nil && asstMsgID != "" {
broadcastAssistantMessage(c.Request.Context(), h.hub, channelID, asstMsgID, result.Content, model, userID, personaID)
@@ -1049,10 +1052,14 @@ func (h *CompletionHandler) syncCompletion(
finalContent := result.Content
// Persist assistant response
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil {
syncAsstMsgID, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID)
if err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
// Record tool_output file refs (v0.37.18)
h.recordToolFileRefs(c.Request.Context(), result.FileRefs, channelID, userID, syncAsstMsgID)
// AI-to-AI chaining
go func() {
defer func() {
@@ -1770,6 +1777,31 @@ func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPro
// ── Message Persistence ─────────────────────
// recordToolFileRefs creates file records with origin=tool_output for workspace
// files produced by tools during a completion. Links each file to the assistant
// message so the UI can show "files generated by this response".
func (h *CompletionHandler) recordToolFileRefs(ctx context.Context, refs []FileRef, channelID, userID, asstMsgID string) {
if len(refs) == 0 || asstMsgID == "" {
return
}
for _, ref := range refs {
f := &models.File{
ChannelID: channelID,
UserID: userID,
Origin: models.FileOriginToolOutput,
Filename: ref.Path,
ContentType: "application/octet-stream",
DisplayHint: "download",
}
if asstMsgID != "" {
f.MessageID = &asstMsgID
}
if err := h.stores.Files.Create(ctx, f); err != nil {
log.Printf("[file_ref] Failed to record tool_output file ref %s: %v", ref.Path, err)
}
}
}
// persistMessage inserts a message into the tree, updates the cursor, and
// returns the new message's ID.
//

View File

@@ -271,7 +271,8 @@ func (h *FileHandler) ListByChannel(c *gin.Context) {
return
}
files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, "")
origin := c.Query("origin") // v0.37.18: filter by origin (user_upload, tool_output, system)
files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, origin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"})
return

View File

@@ -42,9 +42,16 @@ const defaultMaxRounds = 10
// LoopResult holds the accumulated output from a completion with tool
// execution. Callers are responsible for persistence.
// FileRef tracks a file created by a tool for auto-linking to the assistant message.
type FileRef struct {
WorkspaceID string `json:"workspace_id"`
Path string `json:"path"`
}
type LoopResult struct {
Content string
ToolActivity []map[string]interface{}
FileRefs []FileRef // v0.37.18: workspace files created by tools
InputTokens int
OutputTokens int
CacheCreationTokens int
@@ -210,6 +217,11 @@ func CoreToolLoop(ctx context.Context, lcfg LoopConfig, sink LoopSink) LoopResul
// Emit workspace.file.changed for live editor updates (v0.21.5)
emitWorkspaceEvent(lcfg, call, toolResult)
// Collect file refs for tool_output auto-save (v0.37.18)
if ref := collectFileRef(lcfg, call, toolResult); ref != nil {
result.FileRefs = append(result.FileRefs, *ref)
}
// Collect for persistence
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
"id": tc.ID,
@@ -574,6 +586,26 @@ func emitWorkspaceEvent(lcfg LoopConfig, call tools.ToolCall, result tools.ToolR
}
}
// collectFileRef returns a FileRef when workspace_write or workspace_patch succeeds.
func collectFileRef(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) *FileRef {
if result.IsError || lcfg.ExecCtx.WorkspaceID == "" {
return nil
}
if call.Name != "workspace_write" && call.Name != "workspace_patch" {
return nil
}
var toolArgs struct {
Path string `json:"path"`
}
if json.Unmarshal([]byte(call.Arguments), &toolArgs) == nil && toolArgs.Path != "" {
return &FileRef{
WorkspaceID: lcfg.ExecCtx.WorkspaceID,
Path: toolArgs.Path,
}
}
return nil
}
// ── Sink Implementations ───────────────────────
// sseSink writes SSE events to a gin.Context writer for interactive streaming.

View File

@@ -85,6 +85,55 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
c.JSON(http.StatusCreated, w)
}
// GetDefault returns the current user's personal workspace, creating it on first access.
// The personal workspace is the first user-owned workspace (by created_at) or a new
// "My Files" workspace if none exists. Idempotent — safe to call on every page load.
func (h *WorkspaceHandler) GetDefault(c *gin.Context) {
userID := getUserID(c)
ctx := c.Request.Context()
w, err := h.stores.Workspaces.GetByOwner(ctx, models.WorkspaceOwnerUser, userID)
if err == nil {
// Enrich with stats
stats, _ := h.stores.Workspaces.GetStats(ctx, w.ID)
if stats != nil {
w.FileCount = stats.FileCount
w.TotalBytes = stats.TotalBytes
}
c.JSON(http.StatusOK, w)
return
}
if err != sql.ErrNoRows {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch workspace"})
log.Printf("workspace getDefault: %v", err)
return
}
// Auto-create personal workspace
w = &models.Workspace{
OwnerType: models.WorkspaceOwnerUser,
OwnerID: userID,
Name: "My Files",
Status: models.WorkspaceStatusActive,
}
w.ID = store.NewID()
w.RootPath = "workspaces/" + w.ID
if err := h.stores.Workspaces.Create(ctx, w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create default workspace"})
log.Printf("workspace getDefault create: %v", err)
return
}
if err := h.wfs.CreateDir(w); err != nil {
log.Printf("workspace getDefault mkdir: %v", err)
// Workspace record exists but directory failed — still return the workspace.
// Directory will be created on first file upload.
}
c.JSON(http.StatusOK, w)
}
func (h *WorkspaceHandler) Get(c *gin.Context) {
w, ok := h.loadAndAuthorize(c)
if !ok {