diff --git a/server/conversations.go b/server/conversations.go index ba692fc..7b0c77e 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -6,6 +6,7 @@ import ( "log" "math" "net/http" + "path/filepath" "strconv" "strings" "time" @@ -254,13 +255,16 @@ func (s *Server) pgSendConversationMessage(w http.ResponseWriter, r *http.Reques if !ok { return } - var req struct{ Message string } + var req struct { + Message string `json:"message"` + Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对会话工作目录) + } if err := decode(r, &req); err != nil { writeErr(w, 400, err.Error()) return } msg := strings.TrimSpace(req.Message) - if msg == "" { + if msg == "" && len(req.Attachments) == 0 { writeErr(w, 400, "消息不能为空") return } @@ -283,15 +287,32 @@ func (s *Server) pgSendConversationMessage(w http.ResponseWriter, r *http.Reques // persist + float the human turn; auto-title the thread from the first message. // Worker is the agent key (not "user") so the transcript stays a single lane // (no worker chips) — the kind='user' already right-aligns it as a human bubble. - if _, err := pg.AppendConvActivity(c.ID, db.Activity{Worker: c.AgentKey, Kind: "user", Summary: firstLine(msg, 200), Detail: msg}); err != nil { + // With attachments, Detail carries {text, attachments} JSON so the transcript + // renders attachment cards; without, Detail is the full text (Summary is truncated, + // so the transcript lazy-loads Detail to show the message untruncated). + ua := userActivityWithAttachments(c.AgentKey, msg, req.Attachments) + ua.Summary = firstLine(msg, 200) + if ua.Detail == "" { + ua.Detail = msg + } + if _, err := pg.AppendConvActivity(c.ID, ua); err != nil { log.Printf("[conv %d] append user msg failed: %v", c.ID, err) } if c.Title == "" || c.Title == "新对话" { - _ = pg.RenameConversation(c.ID, firstLine(msg, 40)) + title := firstLine(msg, 40) + if title == "" { + title = "附件消息" + } + _ = pg.RenameConversation(c.ID, title) } _ = pg.TouchConversation(c.ID) - s.runConversation(c, msg, busyKey) + // Append the uploaded files' ABSOLUTE paths so the ChatAgent opens them with its + // Read/Bash tools. baseDir = the agent's per-session CWD (/sessions/ + // conv-/), matching chatUpload's landing dir and agent/chat.go's sessionWorkDir + // — busyKey == convBusyKey(c.ID) == "conv-" == that session id. + baseDir := filepath.Join(s.m.dir, "sessions", busyKey) + s.runConversation(c, composeAgentMessage(msg, req.Attachments, baseDir), busyKey) writeJSON(w, 202, map[string]any{"status": "accepted"}) } diff --git a/web/src/app/(main)/chat/page.tsx b/web/src/app/(main)/chat/page.tsx index 7f2826a..aa7fea4 100644 --- a/web/src/app/(main)/chat/page.tsx +++ b/web/src/app/(main)/chat/page.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { toast } from "sonner"; -import { Bot, ChevronDownIcon, PlusIcon, SendIcon, Square, Trash2Icon, ZapIcon } from "lucide-react"; +import { Bot, ChevronDownIcon, Loader2Icon, PaperclipIcon, PlusIcon, SendIcon, Square, Trash2Icon, XIcon, ZapIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; @@ -34,9 +34,16 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { api } from "@/lib/api"; -import type { Activity, Agent, Conversation, LLMProfile } from "@/lib/types"; +import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types"; import { cn } from "@/lib/utils"; +// fmtBytes renders a human file size for attachment chips (mirrors transcript.tsx). +function fmtBytes(n: number): string { + if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`; + if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`; + return `${n} B`; +} + // fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M). function fmtTokens(n: number): string { if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M"; @@ -82,6 +89,10 @@ function Composer({ running, onStop, stopDisabled, + attachments, + onPickFiles, + onRemoveAttachment, + uploading, }: { value: string; onChange: (v: string) => void; @@ -92,7 +103,14 @@ function Composer({ running?: boolean; onStop?: () => void; stopDisabled?: boolean; + // 方式1 文件上传:传了 onPickFiles 才显示回形针按钮 + 附件 chip 预览。 + attachments?: ChatAttachment[]; + onPickFiles?: (files: FileList | null) => void; + onRemoveAttachment?: (path: string) => void; + uploading?: boolean; }) { + const fileInputRef = React.useRef(null); + const atts = attachments ?? []; function onKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -101,8 +119,56 @@ function Composer({ } return (
+ {atts.length > 0 && ( +
+ {atts.map((a) => ( +
+ + {a.name} + {fmtBytes(a.size)} + {onRemoveAttachment && ( + + )} +
+ ))} +
+ )}
{leftSlot} + {onPickFiles && ( + <> + { + onPickFiles(e.target.files); + e.target.value = ""; // allow re-picking the same file + }} + /> + + + )}