Changeset 0.22.8 (#150)

This commit is contained in:
2026-03-04 16:06:12 +00:00
parent 389e47b0f9
commit 7e26a2a261
114 changed files with 3700 additions and 7572 deletions

184
server/tools/file_recall.go Normal file
View File

@@ -0,0 +1,184 @@
package tools
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Late Registration ────────────────────────
// file_recall needs stores + objStore, which aren't available at
// init time. Called from main.go after storage init.
// If objStore is nil (storage not configured), the tool is not registered.
func RegisterFileRecall(stores store.Stores, objStore storage.ObjectStore) {
if objStore == nil {
log.Printf(" file_recall: storage not configured, tool not registered")
return
}
Register(&fileRecallTool{
stores: stores,
objStore: objStore,
})
}
// ═══════════════════════════════════════════
// file_recall
// ═══════════════════════════════════════════
const maxImageBytes = 10 * 1024 * 1024 // 10 MB cap for base64 images
type fileRecallTool struct {
stores store.Stores
objStore storage.ObjectStore
}
func (t *fileRecallTool) Definition() ToolDef {
return ToolDef{
Name: "file_recall",
DisplayName: "Files",
Category: "context",
Description: "Re-read files from this conversation. " +
"Use action='list' to see available files, then action='read' " +
"with a file_id to retrieve the file content. " +
"Documents return extracted text; images return base64 data.",
Parameters: JSONSchema(map[string]interface{}{
"action": PropEnum("Action: 'list' to see files, 'read' to get content", "list", "read"),
"file_id": Prop("string", "File ID to read (required for action='read')"),
}, []string{"action"}),
}
}
func (t *fileRecallTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
Action string `json:"action"`
FileID string `json:"file_id"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
switch args.Action {
case "list":
return t.listFiles(ctx, execCtx)
case "read":
if args.FileID == "" {
return "", fmt.Errorf("file_id is required for action='read'")
}
return t.readFile(ctx, execCtx, args.FileID)
default:
return "", fmt.Errorf("invalid action: %q (use 'list' or 'read')", args.Action)
}
}
// listFiles returns metadata for all files in the channel.
func (t *fileRecallTool) listFiles(ctx context.Context, execCtx ExecutionContext) (string, error) {
atts, err := t.stores.Files.GetByChannel(ctx, execCtx.ChannelID, "")
if err != nil {
return "", fmt.Errorf("failed to list files: %w", err)
}
type attInfo struct {
ID string `json:"id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
HasText bool `json:"has_text"`
CreatedAt string `json:"created_at"`
}
items := make([]attInfo, 0, len(atts))
for _, a := range atts {
items = append(items, attInfo{
ID: a.ID,
Filename: a.Filename,
ContentType: a.ContentType,
SizeBytes: a.SizeBytes,
HasText: a.ExtractedText != nil && *a.ExtractedText != "",
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
})
}
log.Printf("📎 file_recall: list → %d files in channel %s", len(items), execCtx.ChannelID)
out, _ := json.Marshal(map[string]interface{}{
"files": items,
"count": len(items),
"channel_id": execCtx.ChannelID,
})
return string(out), nil
}
// readFile returns the content of a specific file.
func (t *fileRecallTool) readFile(ctx context.Context, execCtx ExecutionContext, fileID string) (string, error) {
att, err := t.stores.Files.GetByID(ctx, fileID)
if err != nil {
return "", fmt.Errorf("file not found: %s", fileID)
}
// Security: verify file belongs to this channel
if att.ChannelID != execCtx.ChannelID {
return "", fmt.Errorf("file %s does not belong to this conversation", fileID)
}
// Documents: return extracted text
if !isImageType(att.ContentType) {
if att.ExtractedText != nil && *att.ExtractedText != "" {
log.Printf("📎 file_recall: read text → %s (%s, %d chars)",
att.Filename, att.ID, len(*att.ExtractedText))
out, _ := json.Marshal(map[string]interface{}{
"id": att.ID,
"filename": att.Filename,
"content_type": att.ContentType,
"content": *att.ExtractedText,
"content_type_returned": "text",
})
return string(out), nil
}
return "", fmt.Errorf("no extracted text available for %s (content type: %s)", att.Filename, att.ContentType)
}
// Images: read from storage and return base64
if att.SizeBytes > maxImageBytes {
return "", fmt.Errorf("image %s is too large (%d bytes, max %d). Re-upload a smaller version",
att.Filename, att.SizeBytes, maxImageBytes)
}
reader, size, _, err := t.objStore.Get(ctx, att.StorageKey)
if err != nil {
return "", fmt.Errorf("failed to read file from storage: %w", err)
}
defer reader.Close()
// Read and base64-encode
data := make([]byte, size)
if _, err := io.ReadFull(reader, data); err != nil {
return "", fmt.Errorf("failed to read file data: %w", err)
}
b64 := base64.StdEncoding.EncodeToString(data)
dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64)
log.Printf("📎 file_recall: read image → %s (%s, %d bytes)",
att.Filename, att.ID, size)
out, _ := json.Marshal(map[string]interface{}{
"id": att.ID,
"filename": att.Filename,
"content_type": att.ContentType,
"content": dataURI,
"content_type_returned": "base64_image",
})
return string(out), nil
}
func isImageType(ct string) bool {
return strings.HasPrefix(ct, "image/")
}