Changeset 0.37.18 (#230)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -919,6 +919,7 @@ func main() {
|
||||
wsH := handlers.NewWorkspaceHandler(stores, wfs)
|
||||
protected.POST("/workspaces", wsH.Create)
|
||||
protected.GET("/workspaces", wsH.List)
|
||||
protected.GET("/workspaces/default", wsH.GetDefault) // v0.37.18: before :id param
|
||||
protected.GET("/workspaces/:id", wsH.Get)
|
||||
protected.PATCH("/workspaces/:id", wsH.Update)
|
||||
protected.DELETE("/workspaces/:id", wsH.Delete)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/admin-surfaces.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/extension-surface.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-shell.css?v={{.Version}}">
|
||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-debug.css?v={{.Version}}">
|
||||
{{if eq .Surface "chat"}}{{template "css-chat" .}}{{end}}
|
||||
{{if eq .Surface "notes"}}{{template "css-notes" .}}{{end}}
|
||||
{{if eq .Surface "projects"}}{{template "css-projects" .}}{{end}}
|
||||
@@ -142,55 +143,9 @@
|
||||
|
||||
{{/* v0.37.13: Legacy UserMenu hydration removed — all surfaces use Preact UserMenu. */}}
|
||||
|
||||
{{/* ── Debug Log Modal (all surfaces) ───── */}}
|
||||
<div id="debugModal" class="modal-overlay">
|
||||
<div class="modal modal-wide">
|
||||
<div class="modal-header">
|
||||
<h2>Debug Log</h2>
|
||||
<button class="modal-close" onclick="closeModal('debugModal')">✕</button>
|
||||
</div>
|
||||
<div class="modal-tabs">
|
||||
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console <span id="debugConsoleCount" class="badge">0</span></button>
|
||||
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network <span id="debugNetworkCount" class="badge">0</span></button>
|
||||
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
|
||||
<button class="debug-tab" data-tab="repl" onclick="switchDebugTab('repl')">REPL</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:0;overflow:hidden;display:flex;flex-direction:column;min-height:0;">
|
||||
<div id="debugConsoleTab" class="debug-tab-content" style="display:flex;flex-direction:column;flex:1;min-height:0;">
|
||||
<div style="padding:8px;display:flex;gap:8px;align-items:center;flex-shrink:0;">
|
||||
<label><input type="checkbox" id="debugFilterErrors"> Errors only</label>
|
||||
<label><input type="checkbox" id="debugAutoScroll" checked> Auto-scroll</label>
|
||||
</div>
|
||||
<div id="debugConsoleContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;flex:1;overflow-y:auto;min-height:0;"></div>
|
||||
</div>
|
||||
<div id="debugNetworkTab" class="debug-tab-content" style="display:none;flex:1;min-height:0;overflow-y:auto;">
|
||||
<div id="debugNetworkContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;"></div>
|
||||
</div>
|
||||
<div id="debugStateTab" class="debug-tab-content" style="display:none;flex:1;min-height:0;overflow-y:auto;">
|
||||
<div id="debugStateContent" class="debug-content" style="font-family:monospace;font-size:12px;padding:8px;white-space:pre-wrap;"></div>
|
||||
</div>
|
||||
<div id="debugReplTab" class="debug-tab-content" style="display:none;flex:1;min-height:0;flex-direction:column;">
|
||||
<div id="replOutput" style="flex:1;overflow-y:auto;font-family:monospace;font-size:12px;padding:8px;min-height:0;"></div>
|
||||
<div style="border-top:1px solid var(--border);padding:8px;flex-shrink:0;">
|
||||
<input type="text" id="replInput" placeholder="Type expression…" style="width:100%;font-family:monospace;font-size:12px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn-danger btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
|
||||
<button class="btn-danger btn-small" onclick="purgeCache()">Purge Cache</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn-secondary btn-small" onclick="clearDebugLog()">Clear</button>
|
||||
<button class="btn-secondary btn-small" onclick="copyDebugLog()">Copy</button>
|
||||
<button class="btn-secondary btn-small" onclick="exportDebugLog()">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/* ── Debug Modal (Preact, v0.37.18) ───── */}}
|
||||
<div id="debugMount"></div>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/debug.js?v={{.Version}}"></script>
|
||||
<script type="module" nonce="{{.CSPNonce}}" src="{{.BasePath}}/js/repl.js?v={{.Version}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
@@ -4441,6 +4441,23 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MessageResponse'
|
||||
/api/v1/workspaces/default:
|
||||
get:
|
||||
tags:
|
||||
- Workspaces
|
||||
summary: Get or create user's default workspace
|
||||
description: Returns the current user's personal "My Files" workspace, auto-creating it on first access. Idempotent.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Default workspace
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Workspace'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
/api/v1/workspaces/{id}:
|
||||
get:
|
||||
tags:
|
||||
|
||||
Reference in New Issue
Block a user