feat: 主 Agent 对话支持文件上传(方式1:落盘 + Read/Bash 读取)
- 前置:task 工作目录 <workDir>/<taskID> → <workDir>/tasks/<taskID>(改中心
ensureRunDir,与 sessions/<id> 对称;三 agent 全走它)。
- 上传接口(方案B,一个/两 scope):POST /api/chat/upload?scope=task|session&id=X,
落到 tasks|sessions/<id>/uploads/,路径安全化 + 同名加序号,返回 {name,path,size}。
- 后端注入(task main agent):chat handler 收 attachments;user 活动 Detail 存
{text,attachments} JSON 供消息渲染;发给 agent 的清单用【绝对路径】(baseDir+相对,
与落盘/CWD 完全对齐)保证一定 Read 得到。
- 前端(会话与任务共用 transcript 渲染):
· api.chatUpload + chat(attachments) + ChatAttachment 类型;
· transcript UserRow 解析 Detail JSON,气泡上方渲染附件卡片(乐观回显内联 detail);
· sessions-tab 输入区加回形针上传按钮 + 发送前 chip 预览(可移除)+ 带附件发送。
- 说明:文本/代码/二进制经 Read/Bash 处理;图片视觉不支持(norma 无 image block)。
会话(ChatAgent)面暂只共用了渲染,后端注入 + Composer 上传待后续补。
This commit is contained in:
1 parent
6348ec9d21
commit
071414063e
9 files changed
+337
-39
No files matched your search
+2
-2
@@ -77,7 +77,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
|
||||
base := append(tsx.MainAgentTools(), actool.DefaultTools()...)
|
||||
tools, def, cleanup := AugmentTools(ctx, "mainagent", base)
|
||||
defer cleanup()
|
||||
// 本任务的工作目录 <workDir>/<taskID>,先建好。
|
||||
// 本任务的工作目录 <workDir>/tasks/<taskID>,先建好。
|
||||
mainDir := ensureRunDir(m.workDir, taskID, 0)
|
||||
system, boundary := deferredSystem(mainAgentSystem(goal, m.workDir, mainDir), def)
|
||||
opts := agentcore.Options{
|
||||
@@ -99,7 +99,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
|
||||
TavilySearchAPIKey: m.webSearch.TavilyKey,
|
||||
WebSearchProxy: m.webSearch.Proxy,
|
||||
BashEnv: proxyEnv(m.proxyAddr, m.proxyCACert), // Bash 子命令默认走代理+信任 CA
|
||||
WorkingDir: mainDir, // 本任务工作目录 <workDir>/<taskID>
|
||||
WorkingDir: mainDir, // 本任务工作目录 <workDir>/tasks/<taskID>
|
||||
ToolOutputDir: cmdOutDir(mainDir),
|
||||
MaxTurns: m.maxTurns, // 0 = unlimited (configurable in agent management)
|
||||
Compaction: compactionConfig(m.window), // long chats stay within the window
|
||||
|
||||
+2
-2
@@ -275,7 +275,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
if tc.Final {
|
||||
situational += "\n\n【任务终局收尾(本轮特殊指令,覆盖上面的常规规划流程)】:" + resolveTaskTimeoutWrapup("planner")
|
||||
}
|
||||
// 本任务的工作目录 <workDir>/<taskID>,先建好。
|
||||
// 本任务的工作目录 <workDir>/tasks/<taskID>,先建好。
|
||||
taskDir := ensureRunDir(p.workDir, taskID, 0)
|
||||
system, boundary := deferredSystem(plannerSystem(goal, p.workDir, taskDir), def)
|
||||
// planner 无自身墙钟预算;有 deadline 时把 MaxDuration 夹逼到剩余,让在跑的规划轮在
|
||||
@@ -304,7 +304,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
||||
TavilySearchAPIKey: p.webSearch.TavilyKey,
|
||||
WebSearchProxy: p.webSearch.Proxy,
|
||||
BashEnv: proxyEnv(p.proxyAddr, p.proxyCACert), // Bash 子命令默认走代理+信任 CA
|
||||
WorkingDir: taskDir, // 本任务工作目录 <workDir>/<taskID>
|
||||
WorkingDir: taskDir, // 本任务工作目录 <workDir>/tasks/<taskID>
|
||||
ToolOutputDir: cmdOutDir(taskDir),
|
||||
MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management)
|
||||
MaxDuration: maxDur, // 0=不限;有 deadline 时=距 deadline 剩余
|
||||
|
||||
+6
-5
@@ -167,11 +167,12 @@ func workerArtifactSpec(runDir string) string {
|
||||
}
|
||||
|
||||
// ensureRunDir builds and creates an agent's working directory under base:
|
||||
// <base>/<taskID> for planner/main; <base>/<taskID>/i<intentID> for a worker
|
||||
// (intentID<=0 → task dir only). Best-effort mkdir — on failure, writes fail the
|
||||
// same way an unwritable CWD would.
|
||||
// <base>/tasks/<taskID> for planner/main; <base>/tasks/<taskID>/i<intentID> for a
|
||||
// worker (intentID<=0 → task dir only). The "tasks/" segment groups per-task dirs
|
||||
// symmetrically with the chat agent's "sessions/<sessionID>". Best-effort mkdir — on
|
||||
// failure, writes fail the same way an unwritable CWD would.
|
||||
func ensureRunDir(base string, taskID, intentID int64) string {
|
||||
dir := filepath.Join(base, strconv.FormatInt(taskID, 10))
|
||||
dir := filepath.Join(base, "tasks", strconv.FormatInt(taskID, 10))
|
||||
if intentID > 0 {
|
||||
dir = filepath.Join(dir, "i"+strconv.FormatInt(intentID, 10))
|
||||
}
|
||||
@@ -260,7 +261,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
|
||||
// 意图 + 全局态势改放【启动 user 消息】(见下方 input),system 只留静态角色正文
|
||||
// (段[A]/[B]/[C] + deferred 块)。与 planner 一致:把易变的运行期数据移出 system,
|
||||
// system 每 session 稳定、更利于缓存;代价是长 run 里这条 user 消息可能被 compaction
|
||||
// 压缩。本次意图的专属工作目录 <workDir>/<taskID>/i<intentID>,引擎侧先建好。
|
||||
// 压缩。本次意图的专属工作目录 <workDir>/tasks/<taskID>/i<intentID>,引擎侧先建好。
|
||||
runDir := ensureRunDir(w.workDir, taskID, intent.ID)
|
||||
overview := renderWorkerGraphOverview(tsx.graphOverviewData())
|
||||
system, boundary := deferredSystem(workerSystem(w.proxyAddr, w.workDir, runDir), def)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// maxChatUpload caps a single chat-attachment upload request (memory + spill).
|
||||
const maxChatUpload = 128 << 20 // 128 MiB
|
||||
|
||||
// safeChatID guards the {id} path segment against traversal — task ids are numeric,
|
||||
// session ids are alnum/_/- ; anything with "/" or ".." is rejected.
|
||||
var safeChatID = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// chatAttachment is one uploaded file as the frontend + agent see it. Path is relative
|
||||
// to the chat's working dir (e.g. "uploads/report.txt"), which is the agent's CWD, so
|
||||
// it can Read/Bash the file directly; Name/Size drive the UI card.
|
||||
type chatAttachment struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// chatUpload implements method-1 file support: it saves one or more files into a chat's
|
||||
// working dir under uploads/, so the agent opens them with its existing Read/Bash tools
|
||||
// and the sent message carries their paths. No LLM-layer change, no multimodal.
|
||||
//
|
||||
// POST /api/chat/upload?scope=task|session&id=<id>, multipart field "file" (repeatable).
|
||||
// Returns {attachments:[{name,path,size}]}. Target dir mirrors the agent CWD layout:
|
||||
//
|
||||
// scope=task → <workDir>/tasks/<id>/uploads/
|
||||
// scope=session → <workDir>/sessions/<id>/uploads/
|
||||
func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) {
|
||||
var sub string
|
||||
switch r.URL.Query().Get("scope") {
|
||||
case "task":
|
||||
sub = "tasks"
|
||||
case "session":
|
||||
sub = "sessions"
|
||||
default:
|
||||
writeErr(w, 400, "scope 必须是 task 或 session")
|
||||
return
|
||||
}
|
||||
id := r.URL.Query().Get("id")
|
||||
if !safeChatID.MatchString(id) {
|
||||
writeErr(w, 400, "非法 id")
|
||||
return
|
||||
}
|
||||
dir := filepath.Join(s.m.dir, sub, id, "uploads")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
writeErr(w, 500, "建目录失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxChatUpload)
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
writeErr(w, 400, "解析上传失败或超出大小限制: "+err.Error())
|
||||
return
|
||||
}
|
||||
files := r.MultipartForm.File["file"]
|
||||
if len(files) == 0 {
|
||||
writeErr(w, 400, "缺少上传文件(表单字段 file)")
|
||||
return
|
||||
}
|
||||
out := make([]chatAttachment, 0, len(files))
|
||||
for _, hdr := range files {
|
||||
name := filepath.Base(hdr.Filename) // strip any path component
|
||||
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
|
||||
continue
|
||||
}
|
||||
dest := uniqueUploadPath(dir, name)
|
||||
if err := saveUpload(hdr, dest); err != nil {
|
||||
writeErr(w, 500, "保存失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
base := filepath.Base(dest)
|
||||
out = append(out, chatAttachment{Name: base, Path: "uploads/" + base, Size: hdr.Size})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"attachments": out})
|
||||
}
|
||||
|
||||
// uniqueUploadPath returns dir/name, or dir/name-1, dir/name-2… when it already exists,
|
||||
// so re-uploading the same filename never clobbers a prior attachment.
|
||||
func uniqueUploadPath(dir, name string) string {
|
||||
dest := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(dest); os.IsNotExist(err) {
|
||||
return dest
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
for i := 1; ; i++ {
|
||||
cand := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext))
|
||||
if _, err := os.Stat(cand); os.IsNotExist(err) {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// composeAgentMessage appends an attachment manifest to the user's message so the agent
|
||||
// knows which files were uploaded and where to Read them. baseDir is the agent's working
|
||||
// dir (its CWD); we emit ABSOLUTE paths (baseDir + relative) so the agent can Read/Bash
|
||||
// them unambiguously regardless of how it interprets relative paths.
|
||||
func composeAgentMessage(msg string, atts []chatAttachment, baseDir string) string {
|
||||
if len(atts) == 0 {
|
||||
return msg
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(msg)
|
||||
b.WriteString("\n\n【用户上传的附件】(绝对路径,需要时用 Read/Bash 查看):")
|
||||
for _, a := range atts {
|
||||
fmt.Fprintf(&b, "\n- %s(%s)", filepath.Join(baseDir, a.Path), humanBytes(a.Size))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// userActivityWithAttachments builds the persisted 'user' activity. With attachments,
|
||||
// Detail holds JSON {text, attachments} so the transcript renders text + attachment
|
||||
// cards; Summary stays the plain text (the list payload omits Detail, lazy-loaded).
|
||||
func userActivityWithAttachments(worker, text string, atts []chatAttachment) db.Activity {
|
||||
a := db.Activity{Worker: worker, Kind: "user", Summary: text}
|
||||
if len(atts) > 0 {
|
||||
blob, _ := json.Marshal(map[string]any{"text": text, "attachments": atts})
|
||||
a.Detail = string(blob)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
switch {
|
||||
case n >= 1<<20:
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
|
||||
case n >= 1<<10:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/(1<<10))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -628,6 +628,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/settings/web-search/test", s.testWebSearch)
|
||||
mux.HandleFunc("GET /api/report", s.getReport)
|
||||
mux.HandleFunc("POST /api/chat", s.chat)
|
||||
mux.HandleFunc("POST /api/chat/upload", s.chatUpload) // 方式1 文件上传:落到会话/任务工作目录 uploads/
|
||||
mux.HandleFunc("POST /api/tasks/{id}/chat/stop", s.stopChat)
|
||||
|
||||
// --- 管理后台 API (PostgreSQL 数据源; 新版数据库与管理后台方案) ---
|
||||
@@ -2057,7 +2058,8 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
Message string `json:"message"`
|
||||
Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
@@ -2065,8 +2067,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// Persist + broadcast the human turn so the 主 Agent 编排会话 survives page
|
||||
// reloads and updates live: the conversation lives in the activity stream as
|
||||
// worker="mainagent" (the per-task activity table, replayed via SSE).
|
||||
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "user", Summary: req.Message})
|
||||
// worker="mainagent" (the per-task activity table, replayed via SSE). With
|
||||
// attachments, the activity's Detail carries {text, attachments} so the transcript
|
||||
// renders attachment cards.
|
||||
s.engine.emitActivity(t, userActivityWithAttachments("mainagent", req.Message, req.Attachments))
|
||||
if ma := s.mainAgentRef(); ma != nil {
|
||||
// Serialize per task: one main-agent run at a time so concurrent messages
|
||||
// don't corrupt the shared exp<id>-main transcript. If a prior turn is still
|
||||
@@ -2103,7 +2107,11 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
emit := func(rec db.Activity) { s.engine.emitActivity(t, rec) }
|
||||
maTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
resume := func() { s.reviveTask(t) } // set_goals 新增目标 → 把任务拉回 running
|
||||
if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, req.Message, emit, t.Notify, resume, t.NotifyGoal); err != nil && ctx.Err() == nil {
|
||||
// 把上传附件的【绝对路径】清单拼进发给 agent 的消息,它据此用 Read/Bash 打开文件。
|
||||
// taskDir = agent 的工作目录(CWD),与 chatUpload 落盘、ensureRunDir 一致。
|
||||
taskDir := filepath.Join(s.m.dir, "tasks", t.ID)
|
||||
agentMsg := composeAgentMessage(req.Message, req.Attachments, taskDir)
|
||||
if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, agentMsg, emit, t.Notify, resume, t.NotifyGoal); err != nil && ctx.Err() == nil {
|
||||
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: "(主 Agent 出错:" + err.Error() + ")"})
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
ShieldAlertIcon,
|
||||
WifiOffIcon,
|
||||
RotateCwIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import { TodoPopover } from "@/components/todo-popover";
|
||||
@@ -33,6 +36,7 @@ import { api, sseUrl } from "@/lib/api";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type {
|
||||
Activity,
|
||||
ChatAttachment,
|
||||
InterceptApprovalRow,
|
||||
Session,
|
||||
SessionStatus,
|
||||
@@ -40,6 +44,13 @@ import type {
|
||||
TokenTotal,
|
||||
} from "@/lib/types";
|
||||
|
||||
// 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`;
|
||||
}
|
||||
|
||||
// ── Reliability model (see docs/task-session-history-sse-remediation.md) ──────────
|
||||
// The task's activity is NO LONGER one unbounded `allActivity` array replayed from
|
||||
// SSE since=0. Instead:
|
||||
@@ -284,6 +295,24 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
const [input, setInput] = React.useState("");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
|
||||
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
|
||||
const [uploading, setUploading] = React.useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
async function pickFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const r = await api.chatUpload("task", taskId, Array.from(files));
|
||||
setAttachments((prev) => [...prev, ...r.attachments]);
|
||||
} catch (e) {
|
||||
toast.error("上传失败:" + (e as Error).message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
// SSE connection state — surfaced so a dropped realtime link is visible, never
|
||||
// silently shown as "no messages".
|
||||
const [sseLive, setSseLive] = React.useState(false);
|
||||
@@ -795,7 +824,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
|
||||
function send() {
|
||||
const text = input.trim();
|
||||
if (!text || sending) return;
|
||||
const atts = attachments;
|
||||
if ((!text && atts.length === 0) || sending) return;
|
||||
const base = [...(store.main?.items ?? []), ...chatExtra];
|
||||
const nextSeq = Math.max(0, ...base.map((a) => a.seq)) + 1;
|
||||
const userMsg: Activity = {
|
||||
@@ -804,14 +834,17 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
ts: new Date().toISOString(),
|
||||
kind: "user",
|
||||
summary: text,
|
||||
// 有附件时把 {text, attachments} 塞进 detail,乐观气泡直接渲染卡片,无需再拉取。
|
||||
detail: atts.length > 0 ? JSON.stringify({ text, attachments: atts }) : undefined,
|
||||
};
|
||||
// optimistic echo of the human turn only; the agent's steps + final answer
|
||||
// stream back live via SSE (worker="mainagent"), so we don't append the reply.
|
||||
setChatExtra((prev) => [...prev, userMsg]);
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
setSending(true);
|
||||
api
|
||||
.chat(text, taskId)
|
||||
.chat(text, taskId, atts.length > 0 ? atts : undefined)
|
||||
.catch(() => {
|
||||
setChatExtra((prev) => [
|
||||
...prev,
|
||||
@@ -1013,25 +1046,66 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{isMain ? (
|
||||
<div className="flex items-center gap-2 border-t p-3">
|
||||
<Input
|
||||
placeholder="给主 Agent 发消息,引导探索方向…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !mainLive) send();
|
||||
}}
|
||||
disabled={mainLive}
|
||||
/>
|
||||
{mainLive ? (
|
||||
<Button size="icon" variant="destructive" onClick={stop} disabled={stopping} title="停止当前执行">
|
||||
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" onClick={send} disabled={!input.trim() || sending}>
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
|
||||
</Button>
|
||||
<div className="border-t p-3">
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
key={a.path}
|
||||
className="flex items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs"
|
||||
title={a.path}
|
||||
>
|
||||
<PaperclipIcon className="size-3 shrink-0 text-primary" />
|
||||
<span className="max-w-[160px] truncate">{a.name}</span>
|
||||
<span className="text-muted-foreground">{fmtBytes(a.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setAttachments((p) => p.filter((x) => x.path !== a.path))}
|
||||
title="移除"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => void pickFiles(e.target.files)}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={mainLive || uploading}
|
||||
title="上传文件"
|
||||
>
|
||||
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
|
||||
</Button>
|
||||
<Input
|
||||
placeholder="给主 Agent 发消息,引导探索方向…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !mainLive) send();
|
||||
}}
|
||||
disabled={mainLive}
|
||||
/>
|
||||
{mainLive ? (
|
||||
<Button size="icon" variant="destructive" onClick={stop} disabled={stopping} title="停止当前执行">
|
||||
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" onClick={send} disabled={(!input.trim() && attachments.length === 0) || sending}>
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center border-t px-4 py-2">
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CrosshairIcon,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
PaperclipIcon,
|
||||
ShieldAlertIcon,
|
||||
Terminal,
|
||||
UserIcon,
|
||||
@@ -445,11 +446,40 @@ function MessageBlock({
|
||||
// it (intent=true) — same bubble, but a target icon instead of the human avatar.
|
||||
// summary is a truncated first line, so the full message is pulled from the detail
|
||||
// and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps).
|
||||
// fmtBytes renders a human file size for attachment chips.
|
||||
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`;
|
||||
}
|
||||
|
||||
type MsgAttachment = { name: string; path: string; size: number };
|
||||
|
||||
// parseUserBody splits a user turn's body into text + attachments. The backend stores
|
||||
// Detail as JSON {text, attachments} when files were uploaded, else plain text — so we
|
||||
// parse defensively and fall back to treating the whole body as text.
|
||||
function parseUserBody(body: string): { text: string; attachments: MsgAttachment[] } {
|
||||
if (body.startsWith("{")) {
|
||||
try {
|
||||
const p = JSON.parse(body);
|
||||
if (p && Array.isArray(p.attachments)) {
|
||||
return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments };
|
||||
}
|
||||
} catch {
|
||||
/* plain text that merely starts with "{" */
|
||||
}
|
||||
}
|
||||
return { text: body, attachments: [] };
|
||||
}
|
||||
|
||||
function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean; getDetail: (seq: number) => Promise<string> }) {
|
||||
const Icon = intent ? CrosshairIcon : UserIcon;
|
||||
const [ref, inView] = useInView();
|
||||
const [full, setFull] = React.useState<string | null>(null);
|
||||
// Optimistic echoes carry their detail inline; persisted rows lazy-load it on scroll.
|
||||
const inline = step.detail && step.detail.length > 0 ? step.detail : null;
|
||||
const [full, setFull] = React.useState<string | null>(inline);
|
||||
React.useEffect(() => {
|
||||
if (inline) return; // already have the body (optimistic echo)
|
||||
if (!inView) return; // fetch the full message only when the bubble nears view
|
||||
let live = true;
|
||||
getDetail(step.seq)
|
||||
@@ -462,11 +492,31 @@ function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [inView, step.seq, getDetail, step.summary]);
|
||||
}, [inView, step.seq, getDetail, step.summary, inline]);
|
||||
const { text, attachments } = parseUserBody(full ?? step.summary);
|
||||
return (
|
||||
<div ref={ref} className="mt-3 mb-2 flex justify-end gap-2">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
|
||||
{full ?? step.summary}
|
||||
<div className="flex max-w-[85%] flex-col items-end gap-1.5">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
key={a.path}
|
||||
className="flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-xs shadow-sm"
|
||||
title={a.path}
|
||||
>
|
||||
<PaperclipIcon className="size-3 shrink-0 text-primary" />
|
||||
<span className="max-w-[180px] truncate font-medium">{a.name}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{fmtBytes(a.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{text && (
|
||||
<div className="whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
|
||||
+17
-2
@@ -9,7 +9,7 @@ import type {
|
||||
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Activity,
|
||||
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
|
||||
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
|
||||
Tool, Conversation, AgentTrigger,
|
||||
Tool, Conversation, AgentTrigger, ChatAttachment,
|
||||
InterceptRule, InterceptPending, InterceptApprovalRow,
|
||||
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
|
||||
WorkspaceListing, WorkspaceFile,
|
||||
@@ -315,7 +315,22 @@ export const api = {
|
||||
if (!r.ok) throw new Error(`report: ${r.status}`);
|
||||
return r.text();
|
||||
},
|
||||
chat: (message: string, task?: string) => post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message }),
|
||||
chat: (message: string, task?: string, attachments?: ChatAttachment[]) =>
|
||||
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
|
||||
// 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。
|
||||
chatUpload: async (scope: "task" | "session", id: string, files: File[]) => {
|
||||
if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size })) };
|
||||
const fd = new FormData();
|
||||
for (const f of files) fd.append("file", f);
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/chat/upload?scope=${scope}&id=${encodeURIComponent(id)}`, {
|
||||
method: "POST",
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: fd,
|
||||
});
|
||||
if (!r.ok) throw new Error(`上传失败: ${r.status} ${await r.text()}`);
|
||||
return r.json() as Promise<{ attachments: ChatAttachment[] }>;
|
||||
},
|
||||
stopChat: (taskId: string) => post<{ status: string }>(`/tasks/${taskId}/chat/stop`, {}),
|
||||
gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}),
|
||||
|
||||
|
||||
@@ -265,6 +265,13 @@ export type ActivityKind =
|
||||
| "usage" // live cumulative token usage (per model turn); not rendered
|
||||
| "intercept_request"; // user-approval request from the intercept layer
|
||||
|
||||
// ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。
|
||||
export interface ChatAttachment {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
seq: number;
|
||||
intent_id?: string;
|
||||
|
||||
Reference in new issue
Block a user