diff --git a/core/http/endpoints/localai/agents.go b/core/http/endpoints/localai/agents.go
index 20119d4c3..2c29229aa 100644
--- a/core/http/endpoints/localai/agents.go
+++ b/core/http/endpoints/localai/agents.go
@@ -5,12 +5,15 @@ import (
"fmt"
"io"
"net/http"
+ "os"
+ "path/filepath"
"sort"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/services"
+ "github.com/mudler/LocalAI/pkg/utils"
"github.com/mudler/LocalAGI/core/state"
coreTypes "github.com/mudler/LocalAGI/core/types"
agiServices "github.com/mudler/LocalAGI/services"
@@ -225,7 +228,16 @@ func AgentSSEEndpoint(app *application.Application) echo.HandlerFunc {
func GetAgentConfigMetaEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
- return c.JSON(http.StatusOK, svc.GetConfigMeta())
+ meta := svc.GetConfigMeta()
+ return c.JSON(http.StatusOK, map[string]any{
+ "filters": meta.Filters,
+ "fields": meta.Fields,
+ "connectors": meta.Connectors,
+ "actions": meta.Actions,
+ "dynamicPrompts": meta.DynamicPrompts,
+ "mcpServers": meta.MCPServers,
+ "outputsDir": svc.OutputsDir(),
+ })
}
}
@@ -331,3 +343,34 @@ func ExecuteActionEndpoint(app *application.Application) echo.HandlerFunc {
return c.JSON(http.StatusOK, result)
}
}
+
+func AgentFileEndpoint(app *application.Application) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ svc := app.AgentPoolService()
+
+ requestedPath := c.QueryParam("path")
+ if requestedPath == "" {
+ return c.JSON(http.StatusBadRequest, map[string]string{"error": "no file path specified"})
+ }
+
+ // Resolve the real path (follows symlinks, eliminates ..)
+ resolved, err := filepath.EvalSymlinks(filepath.Clean(requestedPath))
+ if err != nil {
+ return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
+ }
+
+ // Only serve files from the outputs subdirectory
+ outputsDir, _ := filepath.EvalSymlinks(filepath.Clean(svc.OutputsDir()))
+
+ if utils.InTrustedRoot(resolved, outputsDir) != nil {
+ return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
+ }
+
+ info, err := os.Stat(resolved)
+ if err != nil || info.IsDir() {
+ return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
+ }
+
+ return c.File(resolved)
+ }
+}
diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css
index 792837ea8..0e9a4a1e2 100644
--- a/core/http/react-ui/src/App.css
+++ b/core/http/react-ui/src/App.css
@@ -32,10 +32,13 @@
height: 100vh;
height: 100dvh;
min-height: 0;
+ min-width: 0;
+ overflow: hidden;
}
.app-layout-chat .main-content-inner {
overflow: hidden;
+ min-width: 0;
}
/* Footer */
@@ -979,6 +982,7 @@
display: flex;
flex: 1;
min-height: 0;
+ min-width: 0;
overflow: hidden;
position: relative;
}
@@ -1104,11 +1108,13 @@
min-width: 0;
min-height: 0;
position: relative;
+ overflow: hidden;
}
.chat-messages {
flex: 1;
overflow-y: auto;
+ overflow-x: hidden;
padding: var(--spacing-lg) var(--spacing-xl);
display: flex;
flex-direction: column;
@@ -1130,6 +1136,7 @@
display: flex;
gap: var(--spacing-sm);
max-width: 80%;
+ min-width: 0;
animation: fadeIn 200ms ease;
}
@@ -1183,7 +1190,7 @@
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-subtle);
border-radius: 4px 16px 16px 16px;
- padding: var(--spacing-sm) var(--spacing-md);
+ padding: var(--spacing-md) var(--spacing-lg);
font-size: 0.875rem;
line-height: 1.6;
word-break: break-word;
@@ -1260,11 +1267,21 @@
max-width: 90%;
}
.chat-message-system .chat-message-bubble {
- font-style: italic;
- color: var(--color-text-secondary);
- background: var(--color-bg-secondary);
- border: 1px solid var(--color-border);
- font-size: 0.8rem;
+ color: var(--color-text-muted);
+ background: transparent;
+ border: none;
+ font-size: 0.7rem;
+ letter-spacing: 0.01em;
+ padding: 2px 0;
+}
+.chat-message-system .chat-message-content {
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ padding: 2px var(--spacing-sm);
+ font-size: 0.7rem;
+ line-height: 1.4;
+ color: var(--color-text-muted);
}
.chat-message-timestamp {
font-size: 0.6875rem;
@@ -1438,112 +1455,187 @@
font-size: 0.625rem;
}
-/* Thinking/Reasoning box */
-.chat-thinking-box {
- margin: var(--spacing-xs) var(--spacing-md);
- background: rgba(99, 102, 241, 0.1);
- border: 1px solid rgba(99, 102, 241, 0.3);
- border-radius: var(--radius-lg);
-}
-.chat-thinking-box-streaming {
- padding: var(--spacing-sm) var(--spacing-md);
-}
-.chat-thinking-header {
- width: 100%;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--spacing-sm) var(--spacing-md);
- background: none;
- border: none;
- cursor: pointer;
- color: var(--color-text-primary);
- font-family: inherit;
- font-size: 0.8125rem;
- transition: background 150ms;
-}
-.chat-thinking-header:hover {
- background: rgba(99, 102, 241, 0.15);
-}
-.chat-thinking-label {
- font-size: 0.75rem;
- font-weight: 600;
- color: var(--color-primary);
-}
-.chat-thinking-content {
- padding: var(--spacing-sm) var(--spacing-md);
- border-top: 1px solid rgba(99, 102, 241, 0.2);
- font-size: 0.875rem;
- max-height: 384px;
- overflow-y: auto;
- overflow-x: hidden;
- color: var(--color-text-primary);
-}
-.chat-thinking-content p { margin: 0 0 var(--spacing-xs); }
-.chat-thinking-content p:last-child { margin-bottom: 0; }
-.chat-thinking-content pre {
- background: var(--color-bg-tertiary);
- padding: var(--spacing-sm);
- border-radius: var(--radius-md);
- overflow-x: auto;
- font-size: 0.8125rem;
-}
-.chat-thinking-content code {
- font-family: 'JetBrains Mono', monospace;
- font-size: 0.8125rem;
+/* Activity group (thinking + tools collapsed into one line) */
+@keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
}
-/* Tool call/result boxes */
-.chat-tool-box {
- margin: var(--spacing-xs) var(--spacing-md);
- border-radius: var(--radius-lg);
+.chat-activity-group {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow: hidden;
+ border-left: 2px solid var(--color-border-subtle);
}
-.chat-tool-box-call {
- background: rgba(139, 92, 246, 0.1);
- border: 1px solid rgba(139, 92, 246, 0.3);
+.chat-activity-streaming {
+ border-left-color: var(--color-primary);
}
-.chat-tool-box-result {
- background: rgba(34, 197, 94, 0.1);
- border: 1px solid rgba(34, 197, 94, 0.3);
-}
-.chat-tool-header {
- width: 100%;
+.chat-activity-toggle {
display: flex;
align-items: center;
justify-content: space-between;
- padding: var(--spacing-sm) var(--spacing-md);
+ gap: var(--spacing-sm);
+ padding: 6px 12px;
background: none;
border: none;
cursor: pointer;
- color: var(--color-text-primary);
font-family: inherit;
- font-size: 0.8125rem;
- transition: background 150ms;
+ color: var(--color-text-muted);
+ transition: color 150ms;
+ width: 100%;
+ text-align: left;
}
-.chat-tool-header:hover {
- background: rgba(139, 92, 246, 0.1);
+.chat-activity-toggle:hover {
+ color: var(--color-text-secondary);
}
-.chat-tool-label {
- font-size: 0.75rem;
+.chat-activity-toggle i {
+ font-size: 0.5rem;
+ flex-shrink: 0;
+ opacity: 0.4;
+}
+.chat-activity-summary {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.7rem;
+ letter-spacing: 0.01em;
+}
+.chat-activity-count {
+ display: inline-block;
+ margin-left: 6px;
+ padding: 0 5px;
+ border-radius: 999px;
+ background: var(--color-bg-tertiary);
+ font-size: 0.6rem;
+ color: var(--color-text-muted);
+}
+.chat-activity-shimmer {
+ background: linear-gradient(
+ 90deg,
+ var(--color-text-muted) 0%,
+ var(--color-text-muted) 40%,
+ var(--color-primary) 50%,
+ var(--color-text-muted) 60%,
+ var(--color-text-muted) 100%
+ );
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ animation: shimmer 3s ease-in-out infinite;
+}
+.chat-activity-details {
+ display: flex;
+ flex-direction: column;
+ padding: 2px 0 6px;
+ min-width: 0;
+ overflow: hidden;
+}
+.chat-activity-item {
+ padding: 3px 12px;
+ font-size: 0.7rem;
+ color: var(--color-text-muted);
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ border-left: 2px solid transparent;
+ margin-left: -2px;
+ min-width: 0;
+ overflow: hidden;
+}
+.chat-activity-item-label {
+ font-size: 0.575rem;
font-weight: 600;
+ color: var(--color-text-muted);
+ opacity: 0.6;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
}
-.chat-tool-content {
- padding: var(--spacing-sm) var(--spacing-md);
- border-top: 1px solid rgba(139, 92, 246, 0.15);
- max-height: 300px;
+.chat-activity-item-text {
+ font-size: 0.7rem;
+ color: var(--color-text-secondary);
+ word-break: break-word;
+ white-space: pre-wrap;
+}
+.chat-activity-item-content {
+ font-size: 0.8rem;
+ color: var(--color-text-secondary);
+ max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
+ line-height: 1.5;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+ min-width: 0;
}
-.chat-tool-content pre {
- margin: 0;
+.chat-activity-item-content.chat-activity-live {
+ max-height: 300px;
+}
+.chat-activity-item-content p { margin: 0 0 4px; }
+.chat-activity-item-content p:last-child { margin-bottom: 0; }
+.chat-activity-item-content pre {
+ background: var(--color-bg-tertiary);
+ padding: var(--spacing-xs);
+ border-radius: var(--radius-sm);
+ overflow-x: auto;
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
}
-.chat-tool-content code {
+.chat-activity-item-content code {
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+.chat-activity-item-code {
+ margin: 2px 0 0;
+ font-size: 0.65rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--color-text-muted);
+ max-height: 120px;
+ overflow-y: auto;
+}
+.chat-activity-item-code code {
font-family: 'JetBrains Mono', monospace;
- font-size: 0.75rem;
+ font-size: 0.65rem;
+}
+.chat-activity-params {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ margin-top: 2px;
+}
+.chat-activity-param {
+ display: flex;
+ gap: 6px;
+ font-size: 0.675rem;
+ line-height: 1.4;
+ word-break: break-word;
+}
+.chat-activity-param-key {
+ color: var(--color-text-muted);
+ flex-shrink: 0;
+ opacity: 0.7;
+}
+.chat-activity-param-val {
color: var(--color-text-secondary);
+ white-space: pre-wrap;
+ word-break: break-word;
+ min-width: 0;
+}
+.chat-activity-param-val-long {
+ max-height: 80px;
+ overflow-y: auto;
+}
+.chat-activity-thinking {
+ border-left-color: rgba(99, 102, 241, 0.3);
+}
+.chat-activity-tool-call {
+ border-left-color: rgba(139, 92, 246, 0.3);
+}
+.chat-activity-tool-result {
+ border-left-color: rgba(20, 184, 166, 0.3);
}
/* Context window progress bar */
@@ -1993,6 +2085,285 @@
}
}
+/* Canvas panel */
+.canvas-panel {
+ width: 45%;
+ max-width: 720px;
+ flex-shrink: 1;
+ border-left: 1px solid var(--color-border-subtle);
+ display: flex;
+ flex-direction: column;
+ background: var(--color-bg-primary);
+ overflow: hidden;
+}
+.canvas-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--spacing-sm) var(--spacing-md);
+ border-bottom: 1px solid var(--color-border-subtle);
+ gap: var(--spacing-sm);
+ flex-shrink: 0;
+}
+.canvas-panel-title {
+ font-weight: 600;
+ font-size: 0.875rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.canvas-panel-tabs {
+ overflow-x: auto;
+ display: flex;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-md);
+ border-bottom: 1px solid var(--color-border-subtle);
+ flex-shrink: 0;
+ scrollbar-width: thin;
+}
+.canvas-panel-tab {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--color-border-subtle);
+ background: transparent;
+ color: var(--color-text-secondary);
+ font-size: 0.75rem;
+ cursor: pointer;
+ white-space: nowrap;
+ flex-shrink: 0;
+ transition: all 150ms;
+}
+.canvas-panel-tab:hover { border-color: var(--color-border-default); }
+.canvas-panel-tab.active {
+ background: var(--color-primary-light);
+ border-color: var(--color-primary);
+ color: var(--color-primary);
+}
+.canvas-panel-tab span {
+ max-width: 100px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.canvas-panel-toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-md);
+ border-bottom: 1px solid var(--color-border-subtle);
+ flex-shrink: 0;
+}
+.canvas-toggle-group {
+ display: flex;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+.canvas-toggle-btn {
+ padding: 2px 10px;
+ font-size: 0.75rem;
+ border: none;
+ background: transparent;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition: all 150ms;
+}
+.canvas-toggle-btn.active {
+ background: var(--color-primary);
+ color: var(--color-primary-text);
+}
+.canvas-panel-body {
+ flex: 1;
+ overflow: auto;
+ padding: var(--spacing-md);
+ min-height: 0;
+}
+.canvas-panel-body pre {
+ margin: 0;
+ font-size: 0.8125rem;
+}
+
+/* Artifact card (inline in messages) */
+.artifact-card {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm) var(--spacing-md);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ background: var(--color-bg-tertiary);
+ margin: var(--spacing-sm) 0;
+ transition: border-color 150ms;
+}
+.artifact-card:hover {
+ border-color: var(--color-primary);
+}
+.artifact-card-icon {
+ font-size: 1.1rem;
+ color: var(--color-primary);
+ flex-shrink: 0;
+}
+.artifact-card-info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.artifact-card-title {
+ font-weight: 600;
+ font-size: 0.8125rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.artifact-card-lang {
+ font-size: 0.7rem;
+ color: var(--color-text-muted);
+}
+.artifact-card-actions {
+ display: flex;
+ gap: 4px;
+ flex-shrink: 0;
+}
+.artifact-card-actions button {
+ background: none;
+ border: none;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ padding: 4px 6px;
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ transition: all 150ms;
+}
+.artifact-card-actions button:hover {
+ color: var(--color-primary);
+ background: var(--color-primary-light);
+}
+
+/* Resource cards (below agent messages) */
+.resource-cards {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--spacing-xs);
+ margin-top: var(--spacing-xs);
+}
+.resource-card {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ border: 1px solid var(--color-border-subtle);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 0.8rem;
+ background: var(--color-bg-secondary);
+ transition: border-color 150ms;
+}
+.resource-card:hover {
+ border-color: var(--color-primary);
+}
+.resource-card-thumb {
+ width: 40px;
+ height: 40px;
+ object-fit: cover;
+ border-radius: var(--radius-sm);
+}
+.resource-card-label {
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.resource-cards-more {
+ background: none;
+ border: 1px dashed var(--color-border-default);
+ border-radius: var(--radius-sm);
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-size: 0.75rem;
+ color: var(--color-text-muted);
+ cursor: pointer;
+}
+.resource-cards-more:hover {
+ color: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+/* Canvas preview types */
+.canvas-preview-iframe {
+ width: 100%;
+ min-height: 600px;
+ height: calc(100vh - 200px);
+ border: none;
+ background: white;
+ border-radius: var(--radius-md);
+}
+.canvas-preview-image {
+ max-width: 100%;
+ border-radius: var(--radius-md);
+}
+.canvas-preview-svg {
+ display: flex;
+ justify-content: center;
+ padding: var(--spacing-md);
+}
+.canvas-preview-svg svg {
+ max-width: 100%;
+ height: auto;
+}
+.canvas-preview-markdown {
+ padding: var(--spacing-sm);
+}
+.canvas-audio-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--spacing-md);
+ padding: var(--spacing-lg);
+}
+.canvas-audio-icon {
+ font-size: 2rem;
+ color: var(--color-primary);
+}
+.canvas-url-card {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-md);
+}
+.canvas-url-card a {
+ color: var(--color-primary);
+ word-break: break-all;
+}
+
+/* Canvas mode toggle */
+.canvas-mode-toggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.75rem;
+ color: var(--color-text-secondary);
+}
+.canvas-mode-toggle .canvas-mode-label {
+ font-weight: 500;
+ cursor: pointer;
+}
+.canvas-mode-toggle .toggle {
+ transform: scale(0.8);
+}
+
+@media (max-width: 768px) {
+ .canvas-panel {
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ z-index: 50;
+ }
+}
+
@media (max-width: 640px) {
.card-grid {
grid-template-columns: 1fr;
diff --git a/core/http/react-ui/src/App.jsx b/core/http/react-ui/src/App.jsx
index 89dc33808..267c031cb 100644
--- a/core/http/react-ui/src/App.jsx
+++ b/core/http/react-ui/src/App.jsx
@@ -10,7 +10,7 @@ export default function App() {
const { toasts, addToast, removeToast } = useToast()
const [version, setVersion] = useState('')
const location = useLocation()
- const isChatRoute = location.pathname.startsWith('/chat')
+ const isChatRoute = location.pathname.startsWith('/chat') || location.pathname.match(/^\/agents\/[^/]+\/chat/)
useEffect(() => {
systemApi.version()
diff --git a/core/http/react-ui/src/components/CanvasPanel.jsx b/core/http/react-ui/src/components/CanvasPanel.jsx
new file mode 100644
index 000000000..489b00fe4
--- /dev/null
+++ b/core/http/react-ui/src/components/CanvasPanel.jsx
@@ -0,0 +1,161 @@
+import { useState, useEffect, useRef } from 'react'
+import { renderMarkdown } from '../utils/markdown'
+import { getArtifactIcon } from '../utils/artifacts'
+import DOMPurify from 'dompurify'
+import hljs from 'highlight.js'
+
+export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }) {
+ const [showPreview, setShowPreview] = useState(true)
+ const [copySuccess, setCopySuccess] = useState(false)
+ const codeRef = useRef(null)
+
+ const current = artifacts.find(a => a.id === selectedId) || artifacts[0]
+ if (!current) return null
+
+ const hasPreview = current.type === 'code' && ['html', 'svg', 'md', 'markdown'].includes(current.language)
+
+ useEffect(() => {
+ if (codeRef.current && !showPreview && current.type === 'code') {
+ codeRef.current.querySelectorAll('pre code').forEach(block => {
+ hljs.highlightElement(block)
+ })
+ }
+ }, [current, showPreview])
+
+ const handleCopy = () => {
+ const text = current.code || current.url || ''
+ navigator.clipboard.writeText(text)
+ setCopySuccess(true)
+ setTimeout(() => setCopySuccess(false), 2000)
+ }
+
+ const handleDownload = () => {
+ if (current.type === 'code') {
+ const blob = new Blob([current.code], { type: 'text/plain' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = current.title || 'download.txt'
+ a.click()
+ URL.revokeObjectURL(url)
+ } else if (current.url) {
+ const a = document.createElement('a')
+ a.href = current.url
+ a.download = current.title || 'download'
+ a.target = '_blank'
+ a.click()
+ }
+ }
+
+ const renderBody = () => {
+ if (current.type === 'image') {
+ return
+ }
+ if (current.type === 'pdf') {
+ return
+ }
+ if (current.type === 'audio') {
+ return (
+
+ )
+ }
+ if (current.type === 'video') {
+ return
+ }
+ if (current.type === 'url') {
+ return (
+
+ )
+ }
+ if (current.type === 'file') {
+ return (
+
+ )
+ }
+ // Code artifacts
+ if (showPreview && hasPreview) {
+ if (current.language === 'html') {
+ return
+ }
+ if (current.language === 'svg') {
+ return
+ }
+ if (current.language === 'md' || current.language === 'markdown') {
+ return
+ }
+ }
+ return (
+
+ {current.code}
+
+ )
+ }
+
+ return (
+
+
+ {current.title || 'Artifact'}
+
+
+
+ {artifacts.length > 1 && (
+
+ {artifacts.map(a => (
+
+ ))}
+
+ )}
+
+
+
{current.type === 'code' ? current.language : current.type}
+ {hasPreview && (
+
+
+
+
+ )}
+
+
+
+
+
+
+ {renderBody()}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/ResourceCards.jsx b/core/http/react-ui/src/components/ResourceCards.jsx
new file mode 100644
index 000000000..7f111b51d
--- /dev/null
+++ b/core/http/react-ui/src/components/ResourceCards.jsx
@@ -0,0 +1,65 @@
+import { useState } from 'react'
+import { getArtifactIcon, inferMetadataType } from '../utils/artifacts'
+
+export default function ResourceCards({ metadata, onOpenArtifact, messageIndex, agentName }) {
+ const [expanded, setExpanded] = useState(false)
+
+ if (!metadata) return null
+
+ const items = []
+ const fileUrl = (absPath) => {
+ if (!agentName) return absPath
+ return `/api/agents/${encodeURIComponent(agentName)}/files?path=${encodeURIComponent(absPath)}`
+ }
+
+ Object.entries(metadata).forEach(([key, values]) => {
+ if (!Array.isArray(values)) return
+ values.forEach((v, i) => {
+ if (typeof v !== 'string') return
+ const type = inferMetadataType(key, v)
+ const isWeb = v.startsWith('http://') || v.startsWith('https://')
+ const url = isWeb ? v : fileUrl(v)
+ let title
+ if (type === 'url') {
+ try { title = new URL(v).hostname } catch (_e) { title = v }
+ } else {
+ title = v.split('/').pop() || key
+ }
+ items.push({ id: `meta-${messageIndex}-${key}-${i}`, type, url, title })
+ })
+ })
+
+ if (items.length === 0) return null
+
+ const shown = expanded ? items : items.slice(0, 3)
+ const hasMore = items.length > 3
+
+ return (
+
+ {shown.map(item => (
+
onOpenArtifact && onOpenArtifact(item.id)}
+ >
+ {item.type === 'image' ? (
+

+ ) : (
+
+ )}
+
{item.title}
+
+ ))}
+ {hasMore && !expanded && (
+
+ )}
+ {hasMore && expanded && (
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/hooks/useAgentChat.js b/core/http/react-ui/src/hooks/useAgentChat.js
new file mode 100644
index 000000000..e54bfe1b8
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useAgentChat.js
@@ -0,0 +1,173 @@
+import { useState, useCallback, useRef, useEffect } from 'react'
+
+const STORAGE_KEY_PREFIX = 'localai_agent_chats_'
+const SAVE_DEBOUNCE_MS = 500
+
+function generateId() {
+ return Date.now().toString(36) + Math.random().toString(36).slice(2)
+}
+
+function storageKey(agentName) {
+ return STORAGE_KEY_PREFIX + agentName
+}
+
+function loadConversations(agentName) {
+ try {
+ const stored = localStorage.getItem(storageKey(agentName))
+ if (stored) {
+ const data = JSON.parse(stored)
+ if (data && Array.isArray(data.conversations)) {
+ return data
+ }
+ }
+ } catch (_e) {
+ localStorage.removeItem(storageKey(agentName))
+ }
+ return null
+}
+
+function saveConversations(agentName, conversations, activeId) {
+ try {
+ const data = {
+ conversations: conversations.map(c => ({
+ id: c.id,
+ name: c.name,
+ messages: c.messages,
+ createdAt: c.createdAt,
+ updatedAt: c.updatedAt,
+ })),
+ activeId,
+ lastSaved: Date.now(),
+ }
+ localStorage.setItem(storageKey(agentName), JSON.stringify(data))
+ } catch (err) {
+ if (err.name === 'QuotaExceededError' || err.code === 22) {
+ console.warn('localStorage quota exceeded for agent chats')
+ }
+ }
+}
+
+function createConversation() {
+ return {
+ id: generateId(),
+ name: 'New Chat',
+ messages: [],
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ }
+}
+
+export function useAgentChat(agentName) {
+ const [conversations, setConversations] = useState(() => {
+ const stored = loadConversations(agentName)
+ if (stored && stored.conversations.length > 0) return stored.conversations
+ return [createConversation()]
+ })
+
+ const [activeId, setActiveId] = useState(() => {
+ const stored = loadConversations(agentName)
+ if (stored && stored.activeId) return stored.activeId
+ return conversations[0]?.id
+ })
+
+ const saveTimerRef = useRef(null)
+
+ const activeConversation = conversations.find(c => c.id === activeId) || conversations[0]
+
+ // Debounced save
+ const debouncedSave = useCallback(() => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
+ saveTimerRef.current = setTimeout(() => {
+ saveConversations(agentName, conversations, activeId)
+ }, SAVE_DEBOUNCE_MS)
+ }, [agentName, conversations, activeId])
+
+ useEffect(() => {
+ debouncedSave()
+ return () => {
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
+ }
+ }, [conversations, activeId, debouncedSave])
+
+ // Save immediately on unmount
+ useEffect(() => {
+ return () => {
+ saveConversations(agentName, conversations, activeId)
+ }
+ }, [agentName, conversations, activeId])
+
+ const addConversation = useCallback(() => {
+ const conv = createConversation()
+ setConversations(prev => [conv, ...prev])
+ setActiveId(conv.id)
+ return conv
+ }, [])
+
+ const switchConversation = useCallback((id) => {
+ setActiveId(id)
+ }, [])
+
+ const deleteConversation = useCallback((id) => {
+ setConversations(prev => {
+ if (prev.length <= 1) return prev
+ const filtered = prev.filter(c => c.id !== id)
+ if (id === activeId && filtered.length > 0) {
+ setActiveId(filtered[0].id)
+ }
+ return filtered
+ })
+ }, [activeId])
+
+ const deleteAllConversations = useCallback(() => {
+ const conv = createConversation()
+ setConversations([conv])
+ setActiveId(conv.id)
+ }, [])
+
+ const renameConversation = useCallback((id, name) => {
+ setConversations(prev => prev.map(c =>
+ c.id === id ? { ...c, name, updatedAt: Date.now() } : c
+ ))
+ }, [])
+
+ const addMessage = useCallback((msg) => {
+ setConversations(prev => prev.map(c => {
+ if (c.id !== activeId) return c
+ const updated = {
+ ...c,
+ messages: [...c.messages, msg],
+ updatedAt: Date.now(),
+ }
+ // Auto-name from first user message
+ if (c.messages.length === 0 && msg.sender === 'user') {
+ const text = msg.content || ''
+ updated.name = text.slice(0, 40) + (text.length > 40 ? '...' : '')
+ }
+ return updated
+ }))
+ }, [activeId])
+
+ const clearMessages = useCallback(() => {
+ setConversations(prev => prev.map(c =>
+ c.id === activeId ? { ...c, messages: [], updatedAt: Date.now() } : c
+ ))
+ }, [activeId])
+
+ const getMessages = useCallback(() => {
+ return activeConversation?.messages || []
+ }, [activeConversation])
+
+ return {
+ conversations,
+ activeConversation,
+ activeId,
+ addConversation,
+ switchConversation,
+ deleteConversation,
+ deleteAllConversations,
+ renameConversation,
+ addMessage,
+ clearMessages,
+ getMessages,
+ }
+}
diff --git a/core/http/react-ui/src/pages/AgentChat.jsx b/core/http/react-ui/src/pages/AgentChat.jsx
index 2211fcfc2..f022636fe 100644
--- a/core/http/react-ui/src/pages/AgentChat.jsx
+++ b/core/http/react-ui/src/pages/AgentChat.jsx
@@ -1,28 +1,126 @@
-import { useState, useEffect, useRef, useCallback } from 'react'
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
import { agentsApi } from '../utils/api'
import { renderMarkdown, highlightAll } from '../utils/markdown'
-import DOMPurify from 'dompurify'
+import { extractCodeArtifacts, extractMetadataArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
+import CanvasPanel from '../components/CanvasPanel'
+import ResourceCards from '../components/ResourceCards'
+import { useAgentChat } from '../hooks/useAgentChat'
+
+function relativeTime(ts) {
+ if (!ts) return ''
+ const diff = Date.now() - ts
+ const seconds = Math.floor(diff / 1000)
+ if (seconds < 60) return 'Just now'
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${minutes}m ago`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h ago`
+ const days = Math.floor(hours / 24)
+ if (days < 7) return `${days}d ago`
+ return new Date(ts).toLocaleDateString()
+}
+
+function getLastMessagePreview(conv) {
+ if (!conv.messages || conv.messages.length === 0) return ''
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
+ const msg = conv.messages[i]
+ if (msg.sender === 'user' || msg.sender === 'agent') {
+ return (msg.content || '').slice(0, 40).replace(/\n/g, ' ')
+ }
+ }
+ return ''
+}
+
+function stripHtml(html) {
+ if (!html) return ''
+ return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
+}
+
+function summarizeStatus(text) {
+ const plain = stripHtml(text)
+ // Extract a short label from "Thinking: ...", "Reasoning: ...", etc.
+ const match = plain.match(/^(Thinking|Reasoning|Action taken|Result)[:\s]*/i)
+ if (match) return match[1]
+ return plain.length > 60 ? plain.slice(0, 60) + '...' : plain
+}
+
+function AgentActivityGroup({ items }) {
+ const [expanded, setExpanded] = useState(false)
+ if (!items || items.length === 0) return null
+
+ const latest = items[items.length - 1]
+ const summary = summarizeStatus(latest.content)
+
+ return (
+
+
+
+
+
+
+ {expanded && (
+
+ {items.map((item, idx) => (
+
+
{new Date(item.timestamp).toLocaleTimeString()}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
export default function AgentChat() {
const { name } = useParams()
const navigate = useNavigate()
const { addToast } = useOutletContext()
- const [messages, setMessages] = useState([])
+
+ const {
+ conversations, activeConversation, activeId,
+ addConversation, switchConversation, deleteConversation,
+ deleteAllConversations, renameConversation, addMessage, clearMessages,
+ } = useAgentChat(name)
+
+ const messages = activeConversation?.messages || []
+
const [input, setInput] = useState('')
- const [processing, setProcessing] = useState(false)
+ const [processingChatId, setProcessingChatId] = useState(null)
+ const [canvasMode, setCanvasMode] = useState(false)
+ const [canvasOpen, setCanvasOpen] = useState(false)
+ const [selectedArtifactId, setSelectedArtifactId] = useState(null)
+ const [sidebarOpen, setSidebarOpen] = useState(true)
+ const [editingName, setEditingName] = useState(null)
+ const [editName, setEditName] = useState('')
+ const [chatSearch, setChatSearch] = useState('')
const messagesEndRef = useRef(null)
const messagesRef = useRef(null)
const textareaRef = useRef(null)
const eventSourceRef = useRef(null)
const messageIdCounter = useRef(0)
+ const addMessageRef = useRef(addMessage)
+ addMessageRef.current = addMessage
+ const activeIdRef = useRef(activeId)
+ activeIdRef.current = activeId
+
+ const processing = processingChatId === activeId
const nextId = useCallback(() => {
messageIdCounter.current += 1
return messageIdCounter.current
}, [])
- // Connect to SSE endpoint
+ // Connect to SSE endpoint — only reconnect when agent name changes
useEffect(() => {
const url = `/api/agents/${encodeURIComponent(name)}/sse`
const es = new EventSource(url)
@@ -31,12 +129,16 @@ export default function AgentChat() {
es.addEventListener('json_message', (e) => {
try {
const data = JSON.parse(e.data)
- setMessages(prev => [...prev, {
+ const msg = {
id: nextId(),
sender: data.sender || (data.role === 'user' ? 'user' : 'agent'),
content: data.content || data.message || '',
timestamp: data.timestamp || Date.now(),
- }])
+ }
+ if (data.metadata && Object.keys(data.metadata).length > 0) {
+ msg.metadata = data.metadata
+ }
+ addMessageRef.current(msg)
} catch (_err) {
// ignore malformed messages
}
@@ -46,9 +148,9 @@ export default function AgentChat() {
try {
const data = JSON.parse(e.data)
if (data.status === 'processing') {
- setProcessing(true)
+ setProcessingChatId(activeIdRef.current)
} else if (data.status === 'completed') {
- setProcessing(false)
+ setProcessingChatId(null)
}
} catch (_err) {
// ignore
@@ -58,12 +160,12 @@ export default function AgentChat() {
es.addEventListener('status', (e) => {
const text = e.data
if (!text) return
- setMessages(prev => [...prev, {
+ addMessageRef.current({
id: nextId(),
sender: 'system',
content: text,
timestamp: Date.now(),
- }])
+ })
})
es.addEventListener('json_error', (e) => {
@@ -73,7 +175,7 @@ export default function AgentChat() {
} catch (_err) {
addToast('Agent error', 'error')
}
- setProcessing(false)
+ setProcessingChatId(null)
})
es.onerror = () => {
@@ -96,19 +198,82 @@ export default function AgentChat() {
if (messagesRef.current) highlightAll(messagesRef.current)
}, [messages])
+ const agentMessages = useMemo(() => messages.filter(m => m.sender === 'agent'), [messages])
+ const codeArtifacts = useMemo(
+ () => canvasMode ? extractCodeArtifacts(agentMessages, 'sender', 'agent') : [],
+ [agentMessages, canvasMode]
+ )
+ const metaArtifacts = useMemo(
+ () => canvasMode ? extractMetadataArtifacts(messages, name) : [],
+ [messages, canvasMode, name]
+ )
+ const artifacts = useMemo(() => [...codeArtifacts, ...metaArtifacts], [codeArtifacts, metaArtifacts])
+
+ const prevArtifactCountRef = useRef(0)
+ useEffect(() => {
+ prevArtifactCountRef.current = artifacts.length
+ }, [activeId])
+ useEffect(() => {
+ if (artifacts.length > prevArtifactCountRef.current && artifacts.length > 0) {
+ setSelectedArtifactId(artifacts[artifacts.length - 1].id)
+ if (!canvasOpen) setCanvasOpen(true)
+ }
+ prevArtifactCountRef.current = artifacts.length
+ }, [artifacts])
+
+ // Event delegation for artifact cards
+ useEffect(() => {
+ const el = messagesRef.current
+ if (!el || !canvasMode) return
+ const handler = (e) => {
+ const openBtn = e.target.closest('.artifact-card-open')
+ const downloadBtn = e.target.closest('.artifact-card-download')
+ const card = e.target.closest('.artifact-card')
+ if (downloadBtn) {
+ e.stopPropagation()
+ const id = downloadBtn.dataset.artifactId
+ const artifact = artifacts.find(a => a.id === id)
+ if (artifact?.code) {
+ const blob = new Blob([artifact.code], { type: 'text/plain' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = artifact.title || 'download.txt'
+ a.click()
+ URL.revokeObjectURL(url)
+ }
+ return
+ }
+ if (openBtn || card) {
+ const id = (openBtn || card).dataset.artifactId
+ if (id) {
+ setSelectedArtifactId(id)
+ setCanvasOpen(true)
+ }
+ }
+ }
+ el.addEventListener('click', handler)
+ return () => el.removeEventListener('click', handler)
+ }, [canvasMode, artifacts])
+
+ const openArtifactById = useCallback((id) => {
+ setSelectedArtifactId(id)
+ setCanvasOpen(true)
+ }, [])
+
const handleSend = useCallback(async () => {
const msg = input.trim()
if (!msg || processing) return
setInput('')
if (textareaRef.current) textareaRef.current.style.height = 'auto'
- setProcessing(true)
+ setProcessingChatId(activeId)
try {
await agentsApi.chat(name, msg)
} catch (err) {
addToast(`Failed to send message: ${err.message}`, 'error')
- setProcessing(false)
+ setProcessingChatId(null)
}
- }, [input, processing, name, addToast])
+ }, [input, processing, name, activeId, addToast])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -128,19 +293,173 @@ export default function AgentChat() {
return 'system'
}
+ const startRename = (id, currentName) => {
+ setEditingName(id)
+ setEditName(currentName)
+ }
+
+ const finishRename = () => {
+ if (editingName && editName.trim()) {
+ renameConversation(editingName, editName.trim())
+ }
+ setEditingName(null)
+ }
+
+ const filteredConversations = chatSearch.trim()
+ ? conversations.filter(c => {
+ const q = chatSearch.toLowerCase()
+ if ((c.name || '').toLowerCase().includes(q)) return true
+ return c.messages?.some(m => {
+ return (m.content || '').toLowerCase().includes(q)
+ })
+ })
+ : conversations
+
return (
+
+ {/* Conversation sidebar */}
+
+
+
+
+
+
+
+
+
+ setChatSearch(e.target.value)}
+ placeholder="Search conversations..."
+ />
+ {chatSearch && (
+
+ )}
+
+
+
+
+ {filteredConversations.map(conv => (
+
switchConversation(conv.id)}
+ >
+
+ {editingName === conv.id ? (
+
setEditName(e.target.value)}
+ onBlur={finishRename}
+ onKeyDown={(e) => e.key === 'Enter' && finishRename()}
+ autoFocus
+ onClick={(e) => e.stopPropagation()}
+ style={{ padding: '2px 4px', fontSize: '0.8125rem' }}
+ />
+ ) : (
+
+
+ startRename(conv.id, conv.name)}
+ >
+ {processingChatId === conv.id && }
+ {conv.name}
+
+ {relativeTime(conv.updatedAt)}
+
+
+ {getLastMessagePreview(conv) || 'No messages yet'}
+
+
+ )}
+
+
+ {conversations.length > 1 && (
+
+ )}
+
+
+ ))}
+ {filteredConversations.length === 0 && chatSearch && (
+
+ No conversations match your search
+
+ )}
+
+
+
{/* Header */}
+
{name}
+
+ {canvasMode && artifacts.length > 0 && !canvasOpen && (
+
+ )}
-
@@ -161,55 +480,70 @@ export default function AgentChat() {
)}
- {messages.map(msg => {
- const role = senderToRole(msg.sender)
-
- if (role === 'system') {
- return (
-
+ {(() => {
+ const elements = []
+ let systemBuf = []
+ const flushSystem = (key) => {
+ if (systemBuf.length > 0) {
+ elements.push(
)
+ systemBuf = []
+ }
+ }
+ messages.forEach((msg, idx) => {
+ const role = senderToRole(msg.sender)
+ if (role === 'system') {
+ systemBuf.push(msg)
+ return
+ }
+ flushSystem(idx)
+ elements.push(
+
+
+
+
-
+
+ {role === 'user' ? (
+
/g, '>').replace(/\n/g, '
') }} />
+ ) : (
+
+ )}
+
+ {role === 'assistant' && msg.metadata && (
+
+ )}
+
+ copyMessage(msg.content)} title="Copy">
+
+
+
{new Date(msg.timestamp).toLocaleTimeString()}
)
- }
-
- return (
-
-
-
-
-
-
- {role === 'user' ? (
-
/g, '>').replace(/\n/g, '
') }} />
- ) : (
-
- )}
-
-
- copyMessage(msg.content)} title="Copy">
-
-
-
-
- {new Date(msg.timestamp).toLocaleTimeString()}
-
-
-
- )
- })}
+ })
+ flushSystem('end')
+ return elements
+ })()}
{processing && (
-
-
+
+
-
-
@@ -245,5 +579,14 @@ export default function AgentChat() {
+ {canvasOpen && artifacts.length > 0 && (
+
setCanvasOpen(false)}
+ />
+ )}
+
)
}
diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx
index 4fecb1a73..fe16d98a9 100644
--- a/core/http/react-ui/src/pages/Chat.jsx
+++ b/core/http/react-ui/src/pages/Chat.jsx
@@ -1,8 +1,10 @@
-import { useState, useEffect, useRef, useCallback } from 'react'
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useParams, useOutletContext, useNavigate } from 'react-router-dom'
import { useChat } from '../hooks/useChat'
import ModelSelector from '../components/ModelSelector'
import { renderMarkdown, highlightAll } from '../utils/markdown'
+import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
+import CanvasPanel from '../components/CanvasPanel'
import { fileToBase64, modelsApi } from '../utils/api'
function relativeTime(ts) {
@@ -54,87 +56,165 @@ function exportChatAsMarkdown(chat) {
URL.revokeObjectURL(url)
}
-function ThinkingMessage({ msg, onToggle }) {
+function formatToolContent(raw) {
+ try {
+ const data = JSON.parse(raw)
+ const name = data.name || 'unknown'
+ const params = data.arguments || data.input || data.result || data.parameters || {}
+ const entries = typeof params === 'object' && params !== null ? Object.entries(params) : []
+ return { name, entries, fallback: null }
+ } catch (_e) {
+ return { name: null, entries: [], fallback: raw }
+ }
+}
+
+function ToolParams({ entries, fallback }) {
+ if (fallback) {
+ return
{fallback}
+ }
+ if (entries.length === 0) return null
+ return (
+
+ {entries.map(([k, v]) => {
+ const val = typeof v === 'string' ? v : JSON.stringify(v, null, 2)
+ const isLong = val.length > 120
+ return (
+
+ {k}:
+ {val}
+
+ )
+ })}
+
+ )
+}
+
+function ActivityGroup({ items, updateChatSettings, activeChat }) {
+ const [expanded, setExpanded] = useState(false)
const contentRef = useRef(null)
useEffect(() => {
- if (msg.expanded && contentRef.current) {
- highlightAll(contentRef.current)
+ if (expanded && contentRef.current) highlightAll(contentRef.current)
+ }, [expanded])
+
+ if (!items || items.length === 0) return null
+
+ const labels = items.map(item => {
+ if (item.role === 'thinking' || item.role === 'reasoning') return 'Thought'
+ if (item.role === 'tool_call') {
+ try { return JSON.parse(item.content)?.name || 'Tool' } catch (_e) { return 'Tool' }
}
- }, [msg.expanded, msg.content])
-
- return (
-
-
-
-
- Thinking
-
- ({(msg.content || '').split('\n').length} lines)
-
-
-
-
- {msg.expanded && (
-
- )}
-
- )
-}
-
-function ToolCallMessage({ msg, onToggle }) {
- let parsed = null
- try { parsed = JSON.parse(msg.content) } catch (_e) { /* ignore */ }
- const isCall = msg.role === 'tool_call'
-
- return (
-
-
-
-
-
- {isCall ? 'Tool Call' : 'Tool Result'}: {parsed?.name || 'unknown'}
-
-
-
-
- {msg.expanded && (
-
- )}
-
- )
-}
-
-function StreamingToolCalls({ toolCalls }) {
- if (!toolCalls || toolCalls.length === 0) return null
- return toolCalls.map((tc, i) => {
- const isCall = tc.type === 'tool_call'
- return (
-
-
-
-
-
- {isCall ? 'Tool Call' : 'Tool Result'}: {tc.name}
-
-
-
-
-
-
{JSON.stringify(isCall ? tc.arguments : tc.result, null, 2)}
-
-
- )
+ if (item.role === 'tool_result') {
+ try { return `${JSON.parse(item.content)?.name || 'Tool'} result` } catch (_e) { return 'Result' }
+ }
+ return item.role
})
+ const summary = labels.join(' → ')
+
+ return (
+
+
+
+
+
+
setExpanded(!expanded)}>
+ {summary}
+
+
+ {expanded && (
+
+ {items.map((item, idx) => {
+ if (item.role === 'thinking' || item.role === 'reasoning') {
+ return (
+
+ )
+ }
+ const isCall = item.role === 'tool_call'
+ const parsed = formatToolContent(item.content)
+ return (
+
+ {labels[idx]}
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
+
+function StreamingActivity({ reasoning, toolCalls, hasResponse }) {
+ const hasContent = reasoning || (toolCalls && toolCalls.length > 0)
+ if (!hasContent) return null
+
+ const contentRef = useRef(null)
+ const [manualCollapse, setManualCollapse] = useState(null)
+
+ // Auto-expand while thinking, auto-collapse when response starts
+ const autoExpanded = reasoning && !hasResponse
+ const expanded = manualCollapse !== null ? !manualCollapse : autoExpanded
+
+ // Scroll to bottom of thinking content as it streams
+ useEffect(() => {
+ if (expanded && contentRef.current) {
+ contentRef.current.scrollTop = contentRef.current.scrollHeight
+ }
+ }, [reasoning, expanded])
+
+ // Reset manual override when streaming state changes significantly
+ useEffect(() => {
+ setManualCollapse(null)
+ }, [hasResponse])
+
+ const lastTool = toolCalls && toolCalls.length > 0 ? toolCalls[toolCalls.length - 1] : null
+ const label = reasoning
+ ? 'Thinking...'
+ : lastTool
+ ? (lastTool.type === 'tool_call' ? lastTool.name : `${lastTool.name} result`)
+ : ''
+
+ return (
+
+
+
+
+
+
setManualCollapse(expanded)}>
+
+ {label}
+
+
+
+ {expanded && reasoning && (
+
+ )}
+ {expanded && toolCalls && toolCalls.length > 0 && (
+
+ {toolCalls.map((tc, idx) => {
+ const parsed = formatToolContent(JSON.stringify(tc, null, 2))
+ return (
+
+ {tc.name || tc.type}
+
+
+ )
+ })}
+
+ )}
+
+
+ )
}
function UserMessageContent({ content, files }) {
@@ -180,12 +260,31 @@ export default function Chat() {
const [modelInfo, setModelInfo] = useState(null)
const [showModelInfo, setShowModelInfo] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(true)
+ const [canvasMode, setCanvasMode] = useState(false)
+ const [canvasOpen, setCanvasOpen] = useState(false)
+ const [selectedArtifactId, setSelectedArtifactId] = useState(null)
const messagesEndRef = useRef(null)
const fileInputRef = useRef(null)
const messagesRef = useRef(null)
- const thinkingBoxRef = useRef(null)
const textareaRef = useRef(null)
+ const artifacts = useMemo(
+ () => canvasMode ? extractCodeArtifacts(activeChat?.history, 'role', 'assistant') : [],
+ [activeChat?.history, canvasMode]
+ )
+
+ const prevArtifactCountRef = useRef(0)
+ useEffect(() => {
+ prevArtifactCountRef.current = artifacts.length
+ }, [activeChat?.id])
+ useEffect(() => {
+ if (artifacts.length > prevArtifactCountRef.current && artifacts.length > 0) {
+ setSelectedArtifactId(artifacts[artifacts.length - 1].id)
+ if (!canvasOpen) setCanvasOpen(true)
+ }
+ prevArtifactCountRef.current = artifacts.length
+ }, [artifacts])
+
// Check MCP availability and fetch model config
useEffect(() => {
const model = activeChat?.model
@@ -242,13 +341,6 @@ export default function Chat() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [activeChat?.history, streamingContent, streamingReasoning, streamingToolCalls])
- // Scroll streaming thinking box
- useEffect(() => {
- if (thinkingBoxRef.current) {
- thinkingBoxRef.current.scrollTop = thinkingBoxRef.current.scrollHeight
- }
- }, [streamingReasoning])
-
// Highlight code blocks
useEffect(() => {
if (messagesRef.current) {
@@ -268,6 +360,41 @@ export default function Chat() {
autoGrowTextarea()
}, [input, autoGrowTextarea])
+ // Event delegation for artifact cards
+ useEffect(() => {
+ const el = messagesRef.current
+ if (!el || !canvasMode) return
+ const handler = (e) => {
+ const openBtn = e.target.closest('.artifact-card-open')
+ const downloadBtn = e.target.closest('.artifact-card-download')
+ const card = e.target.closest('.artifact-card')
+ if (downloadBtn) {
+ e.stopPropagation()
+ const id = downloadBtn.dataset.artifactId
+ const artifact = artifacts.find(a => a.id === id)
+ if (artifact?.code) {
+ const blob = new Blob([artifact.code], { type: 'text/plain' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = artifact.title || 'download.txt'
+ a.click()
+ URL.revokeObjectURL(url)
+ }
+ return
+ }
+ if (openBtn || card) {
+ const id = (openBtn || card).dataset.artifactId
+ if (id) {
+ setSelectedArtifactId(id)
+ setCanvasOpen(true)
+ }
+ }
+ }
+ el.addEventListener('click', handler)
+ return () => el.removeEventListener('click', handler)
+ }, [canvasMode, artifacts])
+
const handleFileChange = useCallback(async (e) => {
const newFiles = []
for (const file of e.target.files) {
@@ -517,6 +644,30 @@ export default function Chat() {
)}
+
+ {canvasMode && artifacts.length > 0 && !canvasOpen && (
+ { setSelectedArtifactId(artifacts[0]?.id); setCanvasOpen(true) }}
+ title="Open canvas panel"
+ >
+ {artifacts.length}
+
+ )}
exportChatAsMarkdown(activeChat)}
@@ -663,81 +814,69 @@ export default function Chat() {
)}
- {activeChat.history.map((msg, i) => {
- if (msg.role === 'thinking' || msg.role === 'reasoning') {
- return (
- {
- const newHistory = [...activeChat.history]
- newHistory[i] = { ...newHistory[i], expanded: !newHistory[i].expanded }
- updateChatSettings(activeChat.id, { history: newHistory })
- }} />
- )
+ {(() => {
+ const elements = []
+ let activityBuf = []
+ const flushActivity = (key) => {
+ if (activityBuf.length > 0) {
+ elements.push(
+
+ )
+ activityBuf = []
+ }
}
- if (msg.role === 'tool_call' || msg.role === 'tool_result') {
- return (
- {
- const newHistory = [...activeChat.history]
- newHistory[i] = { ...newHistory[i], expanded: !newHistory[i].expanded }
- updateChatSettings(activeChat.id, { history: newHistory })
- }} />
- )
- }
- return (
-
-
-
-
-
- {msg.role === 'assistant' && activeChat.model && (
-
{activeChat.model}
- )}
-
- {msg.role === 'user' ? (
-
- ) : (
-
- )}
+ activeChat.history.forEach((msg, i) => {
+ const isActivity = msg.role === 'thinking' || msg.role === 'reasoning' ||
+ msg.role === 'tool_call' || msg.role === 'tool_result'
+ if (isActivity) {
+ activityBuf.push(msg)
+ return
+ }
+ flushActivity(i)
+ elements.push(
+
+
+
-
-
copyMessage(msg.content)} title="Copy">
-
-
- {msg.role === 'assistant' && i === activeChat.history.length - 1 && !isStreaming && (
-
-
+
+ {msg.role === 'assistant' && activeChat.model && (
+
{activeChat.model}
+ )}
+
+ {msg.role === 'user' ? (
+
+ ) : (
+
+ )}
+
+
+ copyMessage(msg.content)} title="Copy">
+
- )}
+ {msg.role === 'assistant' && i === activeChat.history.length - 1 && !isStreaming && (
+
+
+
+ )}
+
-
- )
- })}
+ )
+ })
+ flushActivity('end')
+ return elements
+ })()}
- {/* Streaming reasoning box */}
- {isStreaming && streamingReasoning && (
-
-
-
- {streamingReasoning}
-
-
+ {/* Streaming activity (thinking + tools) */}
+ {isStreaming && (streamingReasoning || streamingToolCalls.length > 0) && (
+
)}
- {/* Streaming tool calls */}
- {isStreaming &&
}
-
{/* Streaming message */}
{isStreaming && streamingContent && (
@@ -848,6 +987,14 @@ export default function Chat() {
+ {canvasOpen && artifacts.length > 0 && (
+
setCanvasOpen(false)}
+ />
+ )}
)
}
diff --git a/core/http/react-ui/src/utils/artifacts.js b/core/http/react-ui/src/utils/artifacts.js
new file mode 100644
index 000000000..8a8ff786a
--- /dev/null
+++ b/core/http/react-ui/src/utils/artifacts.js
@@ -0,0 +1,168 @@
+import { Marked } from 'marked'
+import DOMPurify from 'dompurify'
+import hljs from 'highlight.js'
+
+const FENCE_REGEX = /```(\w*)\n([\s\S]*?)```/g
+
+export function extractCodeArtifacts(messages, roleField = 'role', targetRole = 'assistant') {
+ if (!messages) return []
+ const artifacts = []
+ messages.forEach((msg, mi) => {
+ if (msg[roleField] !== targetRole) return
+ const text = typeof msg.content === 'string' ? msg.content : ''
+ if (!text) return
+ let match
+ let blockIndex = 0
+ const re = new RegExp(FENCE_REGEX.source, 'g')
+ while ((match = re.exec(text)) !== null) {
+ const lang = (match[1] || 'text').toLowerCase()
+ const code = match[2]
+ artifacts.push({
+ id: `${mi}-${blockIndex}`,
+ type: 'code',
+ language: lang,
+ code,
+ title: guessTitle(lang, blockIndex),
+ messageIndex: mi,
+ })
+ blockIndex++
+ }
+ })
+ return artifacts
+}
+
+const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp|svg|ico)$/i
+const AUDIO_EXTS = /\.(mp3|wav|ogg|flac|aac|m4a|wma)$/i
+const VIDEO_EXTS = /\.(mp4|webm|mkv|avi|mov)$/i
+const PDF_EXT = /\.pdf$/i
+
+export function inferMetadataType(key, value) {
+ const k = key.toLowerCase()
+ if (k.includes('image') || k.includes('img') || k.includes('photo') || k.includes('picture')) return 'image'
+ if (k.includes('pdf')) return 'pdf'
+ if (k.includes('song') || k.includes('audio') || k.includes('music') || k.includes('voice') || k.includes('tts')) return 'audio'
+ if (k.includes('video')) return 'video'
+ if (k === 'urls' || k === 'url' || k.includes('links')) return 'url'
+ // Infer from value content
+ if (IMAGE_EXTS.test(value)) return 'image'
+ if (AUDIO_EXTS.test(value)) return 'audio'
+ if (VIDEO_EXTS.test(value)) return 'video'
+ if (PDF_EXT.test(value)) return 'pdf'
+ try { new URL(value); return 'url' } catch (_e) { /* not a URL */ }
+ return 'file'
+}
+
+function isWebUrl(v) {
+ return typeof v === 'string' && (v.startsWith('http://') || v.startsWith('https://'))
+}
+
+export function extractMetadataArtifacts(messages, agentName) {
+ if (!messages) return []
+ const artifacts = []
+ messages.forEach((msg, mi) => {
+ const meta = msg.metadata
+ if (!meta) return
+ const fileUrl = (absPath) => {
+ if (!agentName) return absPath
+ return `/api/agents/${encodeURIComponent(agentName)}/files?path=${encodeURIComponent(absPath)}`
+ }
+ Object.entries(meta).forEach(([key, values]) => {
+ if (!Array.isArray(values)) return
+ values.forEach((v, i) => {
+ if (typeof v !== 'string') return
+ const type = inferMetadataType(key, v)
+ const url = isWebUrl(v) ? v : fileUrl(v)
+ let title
+ if (type === 'url') {
+ try { title = new URL(v).hostname } catch (_e) { title = v }
+ } else {
+ title = v.split('/').pop() || key
+ }
+ artifacts.push({ id: `meta-${mi}-${key}-${i}`, type, url, title, messageIndex: mi })
+ })
+ })
+ })
+ return artifacts
+}
+
+function guessTitle(lang, index) {
+ const extMap = {
+ html: 'index.html', javascript: 'script.js', js: 'script.js',
+ typescript: 'script.ts', ts: 'script.ts', jsx: 'component.jsx', tsx: 'component.tsx',
+ python: 'script.py', py: 'script.py', css: 'styles.css', svg: 'image.svg',
+ json: 'data.json', yaml: 'config.yaml', yml: 'config.yaml',
+ go: 'main.go', rust: 'main.rs', java: 'Main.java',
+ markdown: 'document.md', md: 'document.md',
+ bash: 'script.sh', sh: 'script.sh', sql: 'query.sql',
+ }
+ const base = extMap[lang] || `snippet-${index}.${lang || 'txt'}`
+ return index > 0 && extMap[lang] ? base.replace('.', `-${index}.`) : base
+}
+
+export function getArtifactIcon(type, language) {
+ if (type === 'image') return 'fa-image'
+ if (type === 'pdf') return 'fa-file-pdf'
+ if (type === 'audio') return 'fa-music'
+ if (type === 'video') return 'fa-video'
+ if (type === 'url') return 'fa-link'
+ if (type === 'file') return 'fa-file'
+ if (type === 'code') {
+ if (language === 'html') return 'fa-globe'
+ if (language === 'svg') return 'fa-image'
+ if (language === 'css') return 'fa-palette'
+ if (language === 'md' || language === 'markdown') return 'fa-file-lines'
+ }
+ return 'fa-code'
+}
+
+const artifactMarked = new Marked({
+ renderer: {
+ code({ text, lang }) {
+ // Will be overridden per-call
+ if (lang && hljs.getLanguage(lang)) {
+ const highlighted = hljs.highlight(text, { language: lang }).value
+ return `${highlighted}
`
+ }
+ return `${text.replace(//g, '>')}
`
+ },
+ },
+ breaks: true,
+ gfm: true,
+})
+
+export function renderMarkdownWithArtifacts(text, messageIndex) {
+ if (!text) return ''
+
+ // Check if there are any complete code blocks
+ const hasComplete = /```\w*\n[\s\S]*?```/.test(text)
+ if (!hasComplete) {
+ // Fall back to normal rendering for incomplete/streaming content
+ return DOMPurify.sanitize(artifactMarked.parse(text))
+ }
+
+ let blockIndex = 0
+ const renderer = {
+ code({ text: codeText, lang }) {
+ const id = `${messageIndex}-${blockIndex}`
+ const language = (lang || 'text').toLowerCase()
+ const icon = getArtifactIcon('code', language)
+ const title = guessTitle(language, blockIndex)
+ blockIndex++
+ return `
+
+
+ ${title}
+ ${language}
+
+
+
+
+
+
`
+ },
+ }
+
+ const customMarked = new Marked({ renderer, breaks: true, gfm: true })
+ const html = customMarked.parse(text)
+ return DOMPurify.sanitize(html, { ADD_ATTR: ['data-artifact-id'] })
+}
diff --git a/core/http/routes/agents.go b/core/http/routes/agents.go
index e21e9044e..679c7cc97 100644
--- a/core/http/routes/agents.go
+++ b/core/http/routes/agents.go
@@ -28,6 +28,7 @@ func RegisterAgentPoolRoutes(e *echo.Echo, app *application.Application) {
e.POST("/api/agents/:name/chat", localai.ChatWithAgentEndpoint(app))
e.GET("/api/agents/:name/sse", localai.AgentSSEEndpoint(app))
e.GET("/api/agents/:name/export", localai.ExportAgentEndpoint(app))
+ e.GET("/api/agents/:name/files", localai.AgentFileEndpoint(app))
// Actions
e.GET("/api/agents/actions", localai.ListActionsEndpoint(app))
diff --git a/core/services/agent_pool.go b/core/services/agent_pool.go
index 324edf316..b42117f32 100644
--- a/core/services/agent_pool.go
+++ b/core/services/agent_pool.go
@@ -38,6 +38,8 @@ type AgentPoolService struct {
configMeta state.AgentConfigMeta
actionsConfig map[string]string
sharedState *coreTypes.AgentSharedState
+ stateDir string
+ outputsDir string
mu sync.Mutex
}
@@ -103,7 +105,15 @@ func (s *AgentPoolService) Start(ctx context.Context) error {
actionsConfig[agiServices.CustomActionsDir] = cfg.CustomActionsDir
}
+ // Create outputs subdirectory for action-generated files (PDFs, audio, etc.)
+ outputsDir := filepath.Join(stateDir, "outputs")
+ if err := os.MkdirAll(outputsDir, 0750); err != nil {
+ xlog.Error("Failed to create outputs directory", "path", outputsDir, "error", err)
+ }
+
s.actionsConfig = actionsConfig
+ s.stateDir = stateDir
+ s.outputsDir = outputsDir
s.sharedState = coreTypes.NewAgentSharedState(5 * time.Minute)
// Create the agent pool
@@ -306,12 +316,36 @@ func (s *AgentPoolService) Chat(name, message string) (string, error) {
})
manager.Send(sse.NewMessage(string(errMsg)).WithEvent("json_error"))
} else {
- respMsg, _ := json.Marshal(map[string]any{
+ // Collect metadata from all action states
+ metadata := map[string]any{}
+ for _, state := range response.State {
+ for k, v := range state.Metadata {
+ if existing, ok := metadata[k]; ok {
+ if existList, ok := existing.([]string); ok {
+ if newList, ok := v.([]string); ok {
+ metadata[k] = append(existList, newList...)
+ continue
+ }
+ }
+ }
+ metadata[k] = v
+ }
+ }
+
+ if len(metadata) > 0 {
+ s.collectAndCopyMetadata(metadata)
+ }
+
+ msg := map[string]any{
"id": messageID + "-agent",
"sender": "agent",
"content": response.Response,
"timestamp": time.Now().Format(time.RFC3339),
- })
+ }
+ if len(metadata) > 0 {
+ msg["metadata"] = metadata
+ }
+ respMsg, _ := json.Marshal(msg)
manager.Send(sse.NewMessage(string(respMsg)).WithEvent("json_message"))
}
@@ -325,6 +359,63 @@ func (s *AgentPoolService) Chat(name, message string) (string, error) {
return messageID, nil
}
+// copyToOutputs copies a file into the outputs directory and returns the new path.
+// If the file is already inside outputsDir, it returns the original path unchanged.
+func (s *AgentPoolService) copyToOutputs(srcPath string) (string, error) {
+ srcClean := filepath.Clean(srcPath)
+ absOutputs, _ := filepath.Abs(s.outputsDir)
+ absSrc, _ := filepath.Abs(srcClean)
+ if strings.HasPrefix(absSrc, absOutputs+string(os.PathSeparator)) {
+ return srcPath, nil
+ }
+
+ src, err := os.Open(srcClean)
+ if err != nil {
+ return "", err
+ }
+ defer src.Close()
+
+ dstPath := filepath.Join(s.outputsDir, filepath.Base(srcClean))
+ dst, err := os.Create(dstPath)
+ if err != nil {
+ return "", err
+ }
+ defer dst.Close()
+
+ if _, err := io.Copy(dst, src); err != nil {
+ return "", err
+ }
+ return dstPath, nil
+}
+
+// collectAndCopyMetadata iterates all metadata keys and, for any value that is
+// a []string of local file paths, copies those files into the outputs directory
+// so the file endpoint can serve them from a single confined location.
+// Entries that are URLs (http/https) are left unchanged.
+func (s *AgentPoolService) collectAndCopyMetadata(metadata map[string]any) {
+ for key, val := range metadata {
+ list, ok := val.([]string)
+ if !ok {
+ continue
+ }
+ updated := make([]string, 0, len(list))
+ for _, p := range list {
+ if strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
+ updated = append(updated, p)
+ continue
+ }
+ newPath, err := s.copyToOutputs(p)
+ if err != nil {
+ xlog.Error("Failed to copy file to outputs", "src", p, "error", err)
+ updated = append(updated, p)
+ continue
+ }
+ updated = append(updated, newPath)
+ }
+ metadata[key] = updated
+ }
+}
+
func (s *AgentPoolService) GetSSEManager(name string) sse.Manager {
return s.pool.GetManager(name)
}
@@ -337,6 +428,14 @@ func (s *AgentPoolService) AgentHubURL() string {
return s.appConfig.AgentPool.AgentHubURL
}
+func (s *AgentPoolService) StateDir() string {
+ return s.stateDir
+}
+
+func (s *AgentPoolService) OutputsDir() string {
+ return s.outputsDir
+}
+
// ExportAgent returns the agent config as JSON bytes.
func (s *AgentPoolService) ExportAgent(name string) ([]byte, error) {
cfg := s.pool.GetConfig(name)
diff --git a/docs/content/features/agents.md b/docs/content/features/agents.md
index e8c4a86c9..0cfa231fe 100644
--- a/docs/content/features/agents.md
+++ b/docs/content/features/agents.md
@@ -190,7 +190,8 @@ All agent endpoints are grouped under `/api/agents/`:
| `GET` | `/api/agents/:name/sse` | SSE stream for real-time agent events |
| `GET` | `/api/agents/:name/export` | Export agent configuration as JSON |
| `POST` | `/api/agents/import` | Import an agent from JSON |
-| `GET` | `/api/agents/config/metadata` | Get dynamic config form metadata |
+| `GET` | `/api/agents/:name/files?path=...` | Serve a generated file from the outputs directory |
+| `GET` | `/api/agents/config/metadata` | Get dynamic config form metadata (includes `outputsDir`) |
### Skills
@@ -300,6 +301,62 @@ The SSE stream emits the following event types:
- `status` — system messages (reasoning steps, action results)
- `json_error` — error notifications
+## Generated Files and Outputs
+
+Some agent actions (image generation, PDF creation, audio synthesis) produce files. These files are automatically managed by LocalAI through a confined **outputs directory**.
+
+### How It Works
+
+1. Actions generate files to their configured `outputDir` (which can be any path on the filesystem)
+2. After each agent response, LocalAI automatically copies generated files into `{stateDir}/outputs/`
+3. The file-serving endpoint (`/api/agents/:name/files?path=...`) only serves files from this outputs directory
+4. File paths in agent response metadata are rewritten to point to the copied files
+
+This design ensures that:
+- Actions can write files to any directory they need
+- The file-serving endpoint is confined to a single trusted directory — no arbitrary filesystem access
+- Symlink traversal is blocked via `filepath.EvalSymlinks` validation
+
+### Accessing Generated Files
+
+Use the file-serving endpoint to retrieve files produced by agent actions:
+
+```bash
+curl http://localhost:8080/api/agents/my-agent/files?path=/path/to/outputs/image.png
+```
+
+The `path` parameter must point to a file inside the outputs directory. Requests for files outside this directory are rejected with `403 Forbidden`.
+
+### Metadata in SSE Messages
+
+When an agent action produces files, the SSE `json_message` event includes a `metadata` field with the generated resources:
+
+```json
+{
+ "id": "msg-123-agent",
+ "sender": "agent",
+ "content": "Here is the image you requested.",
+ "metadata": {
+ "images_url": ["http://localhost:8080/api/agents/my-agent/files?path=..."],
+ "pdf_paths": ["/path/to/outputs/document.pdf"],
+ "songs_paths": ["/path/to/outputs/song.mp3"]
+ },
+ "timestamp": "2025-01-01T00:00:00Z"
+}
+```
+
+The web UI uses this metadata to display inline resource cards (images, PDFs, audio players) and to open files in the canvas panel.
+
+### Configuration
+
+The outputs directory is created at `{stateDir}/outputs/` where `stateDir` defaults to `LOCALAI_AGENT_POOL_STATE_DIR` (or `LOCALAI_DATA_PATH` / `LOCALAI_CONFIG_DIR` as fallbacks). You can query the current outputs directory path via:
+
+```bash
+curl http://localhost:8080/api/agents/config/metadata
+```
+
+This returns a JSON object including the `outputsDir` field.
+
## Architecture
Agents run in-process within LocalAI. By default, each agent calls back into LocalAI's own API (`http://127.0.0.1:/v1/chat/completions`) for LLM inference. This means: