mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-27 12:57:29 +08:00
feat: preview node media artifacts in dashboard
This commit is contained in:
@@ -81,7 +81,9 @@ var (
|
||||
nodeAgentLoopFactory = agent.NewAgentLoop
|
||||
nodeLocalExecutorFactory = newNodeLocalExecutor
|
||||
nodeCameraSnapFunc = captureNodeCameraSnapshot
|
||||
nodeCameraClipFunc = captureNodeCameraClip
|
||||
nodeScreenSnapFunc = captureNodeScreenSnapshot
|
||||
nodeScreenRecordFunc = captureNodeScreenRecord
|
||||
)
|
||||
|
||||
const nodeArtifactInlineLimit = 512 * 1024
|
||||
@@ -742,6 +744,26 @@ func executeNodeRequest(ctx context.Context, client *http.Client, info nodes.Nod
|
||||
resp.Code = "local_runtime_error"
|
||||
return resp
|
||||
}
|
||||
case "camera_clip":
|
||||
execResp, err := executeNodeCameraClip(ctx, info, next)
|
||||
if err == nil {
|
||||
return execResp
|
||||
}
|
||||
if strings.TrimSpace(opts.Endpoint) == "" {
|
||||
resp.Error = err.Error()
|
||||
resp.Code = "local_runtime_error"
|
||||
return resp
|
||||
}
|
||||
case "screen_record":
|
||||
execResp, err := executeNodeScreenRecord(ctx, info, next)
|
||||
if err == nil {
|
||||
return execResp
|
||||
}
|
||||
if strings.TrimSpace(opts.Endpoint) == "" {
|
||||
resp.Error = err.Error()
|
||||
resp.Code = "local_runtime_error"
|
||||
return resp
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(opts.Endpoint) == "" {
|
||||
resp.Error = "node endpoint not configured"
|
||||
@@ -894,6 +916,67 @@ func executeNodeScreenSnapshot(ctx context.Context, info nodes.NodeInfo, req nod
|
||||
}, nil
|
||||
}
|
||||
|
||||
func executeNodeCameraClip(ctx context.Context, info nodes.NodeInfo, req nodes.Request) (nodes.Response, error) {
|
||||
executor, err := getNodeLocalExecutor()
|
||||
if err != nil {
|
||||
return nodes.Response{}, err
|
||||
}
|
||||
durationMs := durationArg(req.Args, "duration_ms", 3000)
|
||||
outputPath, err := nodeCameraClipFunc(ctx, executor.workspace, req.Args)
|
||||
if err != nil {
|
||||
return nodes.Response{}, err
|
||||
}
|
||||
artifact, err := buildNodeArtifact(executor.workspace, outputPath)
|
||||
if err != nil {
|
||||
return nodes.Response{}, err
|
||||
}
|
||||
return nodes.Response{
|
||||
OK: true,
|
||||
Code: "ok",
|
||||
Node: info.ID,
|
||||
Action: req.Action,
|
||||
Payload: map[string]interface{}{
|
||||
"transport": "clawgo-local",
|
||||
"media_type": "video",
|
||||
"storage": artifact["storage"],
|
||||
"duration_ms": durationMs,
|
||||
"artifacts": []map[string]interface{}{artifact},
|
||||
"meta": map[string]interface{}{
|
||||
"facing": stringArg(req.Args, "facing"),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func executeNodeScreenRecord(ctx context.Context, info nodes.NodeInfo, req nodes.Request) (nodes.Response, error) {
|
||||
executor, err := getNodeLocalExecutor()
|
||||
if err != nil {
|
||||
return nodes.Response{}, err
|
||||
}
|
||||
durationMs := durationArg(req.Args, "duration_ms", 3000)
|
||||
outputPath, err := nodeScreenRecordFunc(ctx, executor.workspace, req.Args)
|
||||
if err != nil {
|
||||
return nodes.Response{}, err
|
||||
}
|
||||
artifact, err := buildNodeArtifact(executor.workspace, outputPath)
|
||||
if err != nil {
|
||||
return nodes.Response{}, err
|
||||
}
|
||||
return nodes.Response{
|
||||
OK: true,
|
||||
Code: "ok",
|
||||
Node: info.ID,
|
||||
Action: req.Action,
|
||||
Payload: map[string]interface{}{
|
||||
"transport": "clawgo-local",
|
||||
"media_type": "video",
|
||||
"storage": artifact["storage"],
|
||||
"duration_ms": durationMs,
|
||||
"artifacts": []map[string]interface{}{artifact},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getNodeLocalExecutor() (*nodeLocalExecutor, error) {
|
||||
key := strings.TrimSpace(getConfigPath())
|
||||
if key == "" {
|
||||
@@ -1176,6 +1259,83 @@ func captureNodeScreenSnapshot(ctx context.Context, workspace string, args map[s
|
||||
}
|
||||
}
|
||||
|
||||
func captureNodeCameraClip(ctx context.Context, workspace string, args map[string]interface{}) (string, error) {
|
||||
outputPath, err := nodeMediaOutputPath(workspace, "camera", ".mp4", stringArg(args, "filename"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
durationSec := fmt.Sprintf("%.3f", float64(durationArg(args, "duration_ms", 3000))/1000.0)
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if _, err := os.Stat("/dev/video0"); err != nil {
|
||||
return "", fmt.Errorf("camera device /dev/video0 not found")
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return "", fmt.Errorf("ffmpeg not installed")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "video4linux2", "-t", durationSec, "-i", "/dev/video0", "-pix_fmt", "yuv420p", outputPath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("camera clip failed: %v, output=%s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return outputPath, nil
|
||||
case "darwin":
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return "", fmt.Errorf("ffmpeg not installed")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "avfoundation", "-t", durationSec, "-i", "0:none", "-pix_fmt", "yuv420p", outputPath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("camera clip failed: %v, output=%s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return outputPath, nil
|
||||
default:
|
||||
return "", fmt.Errorf("camera_clip not supported on %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func captureNodeScreenRecord(ctx context.Context, workspace string, args map[string]interface{}) (string, error) {
|
||||
outputPath, err := nodeMediaOutputPath(workspace, "screen", ".mp4", stringArg(args, "filename"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
durationMs := durationArg(args, "duration_ms", 3000)
|
||||
durationSec := fmt.Sprintf("%.3f", float64(durationMs)/1000.0)
|
||||
durationWholeSec := strconv.Itoa((durationMs + 999) / 1000)
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
if _, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "avfoundation", "-t", durationSec, "-i", "1:none", "-pix_fmt", "yuv420p", outputPath)
|
||||
if out, err := cmd.CombinedOutput(); err == nil {
|
||||
return outputPath, nil
|
||||
} else if strings.TrimSpace(string(out)) != "" {
|
||||
return "", fmt.Errorf("screen record failed: %v, output=%s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("ffmpeg not installed")
|
||||
case "linux":
|
||||
candidates := [][]string{
|
||||
{"ffmpeg", "-y", "-f", "x11grab", "-t", durationSec, "-i", os.Getenv("DISPLAY"), "-pix_fmt", "yuv420p", outputPath},
|
||||
{"wf-recorder", "-f", outputPath, "-d", durationWholeSec},
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate[0] == "ffmpeg" && strings.TrimSpace(os.Getenv("DISPLAY")) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := exec.LookPath(candidate[0]); err != nil {
|
||||
continue
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, candidate[0], candidate[1:]...)
|
||||
if out, err := cmd.CombinedOutput(); err == nil {
|
||||
return outputPath, nil
|
||||
} else if strings.TrimSpace(string(out)) != "" && candidate[0] == "ffmpeg" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no supported screen recorder found (ffmpeg x11grab or wf-recorder)")
|
||||
default:
|
||||
return "", fmt.Errorf("screen_record not supported on %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func nodeMediaOutputPath(workspace, kind, ext, requested string) (string, error) {
|
||||
root := strings.TrimSpace(workspace)
|
||||
if root == "" {
|
||||
@@ -1205,6 +1365,31 @@ func nodeMediaOutputPath(workspace, kind, ext, requested string) (string, error)
|
||||
return fullPath, nil
|
||||
}
|
||||
|
||||
func durationArg(args map[string]interface{}, key string, fallback int) int {
|
||||
if len(args) == 0 {
|
||||
return fallback
|
||||
}
|
||||
switch v := args[key].(type) {
|
||||
case int:
|
||||
if v > 0 {
|
||||
return v
|
||||
}
|
||||
case int64:
|
||||
if v > 0 {
|
||||
return int(v)
|
||||
}
|
||||
case float64:
|
||||
if v > 0 {
|
||||
return int(v)
|
||||
}
|
||||
case json.Number:
|
||||
if n, err := v.Int64(); err == nil && n > 0 {
|
||||
return int(n)
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func structToWirePayload(v interface{}) map[string]interface{} {
|
||||
b, _ := json.Marshal(v)
|
||||
var out map[string]interface{}
|
||||
|
||||
@@ -386,3 +386,99 @@ func TestExecuteNodeRequestRunsLocalScreenSnapshot(t *testing.T) {
|
||||
t.Fatalf("unexpected artifact: %+v", artifacts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteNodeRequestRunsLocalCameraClip(t *testing.T) {
|
||||
prevCfg := globalConfigPathOverride
|
||||
prevExecutors := nodeLocalExecutors
|
||||
prevClip := nodeCameraClipFunc
|
||||
globalConfigPathOverride = filepath.Join(t.TempDir(), "config.json")
|
||||
nodeLocalExecutors = map[string]*nodeLocalExecutor{}
|
||||
defer func() {
|
||||
globalConfigPathOverride = prevCfg
|
||||
nodeLocalExecutors = prevExecutors
|
||||
nodeCameraClipFunc = prevClip
|
||||
}()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace")
|
||||
if err := config.SaveConfig(globalConfigPathOverride, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
nodeCameraClipFunc = func(ctx context.Context, workspace string, args map[string]interface{}) (string, error) {
|
||||
out := filepath.Join(workspace, "artifacts", "node", "camera-test.mp4")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(out, []byte("video-bytes"), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
info := nodes.NodeInfo{ID: "edge-clip", Name: "Edge Clip"}
|
||||
resp := executeNodeRequest(context.Background(), &http.Client{Timeout: time.Second}, info, nodeRegisterOptions{}, &nodes.Request{
|
||||
Action: "camera_clip",
|
||||
Args: map[string]interface{}{"duration_ms": 2500},
|
||||
})
|
||||
if !resp.OK {
|
||||
t.Fatalf("expected ok response, got %+v", resp)
|
||||
}
|
||||
if got, _ := resp.Payload["duration_ms"].(int); got != 2500 {
|
||||
t.Fatalf("unexpected duration payload: %+v", resp.Payload)
|
||||
}
|
||||
artifacts, ok := resp.Payload["artifacts"].([]map[string]interface{})
|
||||
if !ok || len(artifacts) != 1 {
|
||||
t.Fatalf("expected one artifact, got %+v", resp.Payload["artifacts"])
|
||||
}
|
||||
if artifacts[0]["name"] != "camera-test.mp4" {
|
||||
t.Fatalf("unexpected artifact: %+v", artifacts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteNodeRequestRunsLocalScreenRecord(t *testing.T) {
|
||||
prevCfg := globalConfigPathOverride
|
||||
prevExecutors := nodeLocalExecutors
|
||||
prevRecord := nodeScreenRecordFunc
|
||||
globalConfigPathOverride = filepath.Join(t.TempDir(), "config.json")
|
||||
nodeLocalExecutors = map[string]*nodeLocalExecutor{}
|
||||
defer func() {
|
||||
globalConfigPathOverride = prevCfg
|
||||
nodeLocalExecutors = prevExecutors
|
||||
nodeScreenRecordFunc = prevRecord
|
||||
}()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace")
|
||||
if err := config.SaveConfig(globalConfigPathOverride, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
nodeScreenRecordFunc = func(ctx context.Context, workspace string, args map[string]interface{}) (string, error) {
|
||||
out := filepath.Join(workspace, "artifacts", "node", "screen-test.mp4")
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(out, []byte("screen-video"), 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
info := nodes.NodeInfo{ID: "edge-record", Name: "Edge Record"}
|
||||
resp := executeNodeRequest(context.Background(), &http.Client{Timeout: time.Second}, info, nodeRegisterOptions{}, &nodes.Request{
|
||||
Action: "screen_record",
|
||||
Args: map[string]interface{}{"duration_ms": 1800},
|
||||
})
|
||||
if !resp.OK {
|
||||
t.Fatalf("expected ok response, got %+v", resp)
|
||||
}
|
||||
if got, _ := resp.Payload["duration_ms"].(int); got != 1800 {
|
||||
t.Fatalf("unexpected duration payload: %+v", resp.Payload)
|
||||
}
|
||||
artifacts, ok := resp.Payload["artifacts"].([]map[string]interface{})
|
||||
if !ok || len(artifacts) != 1 {
|
||||
t.Fatalf("expected one artifact, got %+v", resp.Payload["artifacts"])
|
||||
}
|
||||
if artifacts[0]["name"] != "screen-test.mp4" {
|
||||
t.Fatalf("unexpected artifact: %+v", artifacts[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +438,7 @@ func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "memory"), 0755); err != nil {
|
||||
t.Fatalf("mkdir memory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte("{\"node\":\"edge-b\",\"used_transport\":\"webrtc\",\"fallback_from\":\"\",\"duration_ms\":12}\n"), 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"), []byte("{\"node\":\"edge-b\",\"used_transport\":\"webrtc\",\"fallback_from\":\"\",\"duration_ms\":12,\"artifacts\":[{\"name\":\"snap.png\",\"kind\":\"image\",\"mime_type\":\"image/png\",\"storage\":\"inline\",\"content_base64\":\"iVBORw0KGgo=\"}]}\n"), 0644); err != nil {
|
||||
t.Fatalf("write audit: %v", err)
|
||||
}
|
||||
srv.SetNodeP2PStatusHandler(func() map[string]interface{} {
|
||||
@@ -467,4 +467,9 @@ func TestHandleWebUINodesIncludesP2PSummary(t *testing.T) {
|
||||
if len(dispatches) != 1 {
|
||||
t.Fatalf("expected dispatch audit rows, got %+v", body["dispatches"])
|
||||
}
|
||||
first, _ := dispatches[0].(map[string]interface{})
|
||||
artifacts, _ := first["artifacts"].([]interface{})
|
||||
if len(artifacts) != 1 {
|
||||
t.Fatalf("expected artifact previews in dispatch row, got %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ type NodesTool struct {
|
||||
auditPath string
|
||||
}
|
||||
|
||||
const nodeAuditArtifactPreviewLimit = 32768
|
||||
|
||||
func NewNodesTool(m *nodes.Manager, r *nodes.Router, auditPath string) *NodesTool {
|
||||
return &NodesTool{manager: m, router: r, auditPath: strings.TrimSpace(auditPath)}
|
||||
}
|
||||
@@ -159,6 +161,9 @@ func (t *NodesTool) writeAudit(req nodes.Request, resp nodes.Response, mode stri
|
||||
if len(kinds) > 0 {
|
||||
row["artifact_kinds"] = kinds
|
||||
}
|
||||
if previews := artifactAuditPreviews(resp.Payload["artifacts"]); len(previews) > 0 {
|
||||
row["artifacts"] = previews
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(row)
|
||||
f, err := os.OpenFile(t.auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
@@ -194,3 +199,56 @@ func artifactAuditSummary(raw interface{}) (int, []string) {
|
||||
}
|
||||
return len(items), kinds
|
||||
}
|
||||
|
||||
func artifactAuditPreviews(raw interface{}) []map[string]interface{} {
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
if typed, ok := raw.([]map[string]interface{}); ok {
|
||||
items = make([]interface{}, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
row, ok := item.(map[string]interface{})
|
||||
if !ok || len(row) == 0 {
|
||||
continue
|
||||
}
|
||||
entry := map[string]interface{}{}
|
||||
for _, key := range []string{"name", "kind", "mime_type", "storage", "path", "url", "source_path"} {
|
||||
if value, ok := row[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
|
||||
entry[key] = value
|
||||
}
|
||||
}
|
||||
if size, ok := row["size_bytes"]; ok {
|
||||
entry["size_bytes"] = size
|
||||
}
|
||||
if text, _ := row["content_text"].(string); strings.TrimSpace(text) != "" {
|
||||
entry["content_text"] = trimAuditContent(text)
|
||||
}
|
||||
if b64, _ := row["content_base64"].(string); strings.TrimSpace(b64) != "" {
|
||||
entry["content_base64"] = trimAuditContent(b64)
|
||||
entry["content_base64_truncated"] = len(b64) > nodeAuditArtifactPreviewLimit
|
||||
}
|
||||
if truncated, ok := row["truncated"].(bool); ok && truncated {
|
||||
entry["truncated"] = true
|
||||
}
|
||||
if len(entry) > 0 {
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimAuditContent(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if len(raw) <= nodeAuditArtifactPreviewLimit {
|
||||
return raw
|
||||
}
|
||||
return raw[:nodeAuditArtifactPreviewLimit]
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ const resources = {
|
||||
dashboardNodeDispatchFallback: 'Fallback From',
|
||||
dashboardNodeDispatchDuration: 'Duration',
|
||||
dashboardNodeDispatchArtifacts: 'Artifacts',
|
||||
dashboardNodeDispatchArtifactPreview: 'Artifact Preview',
|
||||
dashboardNodeDispatchError: 'Error',
|
||||
configNodeP2P: 'Node P2P',
|
||||
configNodeP2PHint: 'Configure websocket tunnel or WebRTC transport for remote nodes.',
|
||||
@@ -689,6 +690,7 @@ const resources = {
|
||||
dashboardNodeDispatchFallback: '回退来源',
|
||||
dashboardNodeDispatchDuration: '耗时',
|
||||
dashboardNodeDispatchArtifacts: '工件',
|
||||
dashboardNodeDispatchArtifactPreview: '工件预览',
|
||||
dashboardNodeDispatchError: '错误',
|
||||
configNodeP2P: '节点 P2P',
|
||||
configNodeP2PHint: '为远端节点配置 websocket tunnel 或 WebRTC 传输。',
|
||||
|
||||
@@ -12,6 +12,21 @@ function formatRuntimeTime(value: unknown) {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function dataUrlForArtifact(artifact: any) {
|
||||
const mime = String(artifact?.mime_type || '').trim() || 'application/octet-stream';
|
||||
const content = String(artifact?.content_base64 || '').trim();
|
||||
if (!content) return '';
|
||||
return `data:${mime};base64,${content}`;
|
||||
}
|
||||
|
||||
function formatBytes(value: unknown) {
|
||||
const size = Number(value || 0);
|
||||
if (!Number.isFinite(size) || size <= 0) return '-';
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
@@ -81,6 +96,7 @@ const Dashboard: React.FC = () => {
|
||||
durationMs: Number(item?.duration_ms || 0),
|
||||
artifactCount: Number(item?.artifact_count || 0),
|
||||
artifactKinds: Array.isArray(item?.artifact_kinds) ? item.artifact_kinds.map((kind: any) => String(kind || '').trim()).filter(Boolean) : [],
|
||||
artifacts: Array.isArray(item?.artifacts) ? item.artifacts : [],
|
||||
ok: Boolean(item?.ok),
|
||||
error: String(item?.error || '').trim(),
|
||||
}));
|
||||
@@ -302,6 +318,45 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.artifacts.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="text-zinc-400 text-xs">{t('dashboardNodeDispatchArtifactPreview')}</div>
|
||||
{item.artifacts.slice(0, 2).map((artifact: any, artifactIndex: number) => {
|
||||
const kind = String(artifact?.kind || '').trim().toLowerCase();
|
||||
const mime = String(artifact?.mime_type || '').trim().toLowerCase();
|
||||
const isImage = kind === 'image' || mime.startsWith('image/');
|
||||
const isVideo = kind === 'video' || mime.startsWith('video/');
|
||||
const dataUrl = dataUrlForArtifact(artifact);
|
||||
return (
|
||||
<div key={`${item.id}-artifact-${artifactIndex}`} className="rounded-2xl border border-zinc-800 bg-zinc-950/40 p-3">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-zinc-200 truncate">{String(artifact?.name || artifact?.source_path || `artifact-${artifactIndex + 1}`)}</div>
|
||||
<div className="text-[11px] text-zinc-500 truncate">
|
||||
{[artifact?.kind, artifact?.mime_type, formatBytes(artifact?.size_bytes)].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500">{String(artifact?.storage || '-')}</div>
|
||||
</div>
|
||||
{isImage && dataUrl && (
|
||||
<img src={dataUrl} alt={String(artifact?.name || 'artifact')} className="max-h-48 rounded-xl border border-zinc-800 object-contain bg-black/30" />
|
||||
)}
|
||||
{isVideo && dataUrl && (
|
||||
<video src={dataUrl} controls className="max-h-48 w-full rounded-xl border border-zinc-800 bg-black/30" />
|
||||
)}
|
||||
{!isImage && !isVideo && String(artifact?.content_text || '').trim() !== '' && (
|
||||
<pre className="rounded-xl border border-zinc-800 bg-black/20 p-3 text-[11px] text-zinc-300 whitespace-pre-wrap overflow-auto max-h-48">{String(artifact?.content_text || '')}</pre>
|
||||
)}
|
||||
{!isImage && !isVideo && String(artifact?.content_text || '').trim() === '' && (
|
||||
<div className="text-[11px] text-zinc-500 break-all">
|
||||
{String(artifact?.source_path || artifact?.path || artifact?.url || '-')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user