feat(ui): add canvas mode, support history in agent chat (#8927)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-03-09 23:42:47 +01:00
committed by GitHub
parent 01bd3d8212
commit 85f3558d22
12 changed files with 1928 additions and 300 deletions

View File

@@ -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)
}
}

View File

@@ -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;

View File

@@ -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()

View File

@@ -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 <img src={current.url} alt={current.title} className="canvas-preview-image" />
}
if (current.type === 'pdf') {
return <iframe src={current.url} className="canvas-preview-iframe" title={current.title} />
}
if (current.type === 'audio') {
return (
<div className="canvas-audio-wrapper">
<i className="fas fa-music canvas-audio-icon" />
<p>{current.title}</p>
<audio controls src={current.url} style={{ width: '100%' }} />
</div>
)
}
if (current.type === 'video') {
return <video controls src={current.url} className="canvas-preview-image" />
}
if (current.type === 'url') {
return (
<div className="canvas-url-card">
<i className="fas fa-external-link-alt" />
<a href={current.url} target="_blank" rel="noopener noreferrer">{current.url}</a>
</div>
)
}
if (current.type === 'file') {
return (
<div className="canvas-url-card">
<i className="fas fa-file" />
<a href={current.url} target="_blank" rel="noopener noreferrer" download={current.title}>{current.title}</a>
</div>
)
}
// Code artifacts
if (showPreview && hasPreview) {
if (current.language === 'html') {
return <iframe srcDoc={current.code} sandbox="allow-scripts" className="canvas-preview-iframe" title="HTML Preview" />
}
if (current.language === 'svg') {
return <div className="canvas-preview-svg" dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(current.code, { USE_PROFILES: { svg: true, svgFilters: true } })
}} />
}
if (current.language === 'md' || current.language === 'markdown') {
return <div className="canvas-preview-markdown" dangerouslySetInnerHTML={{
__html: renderMarkdown(current.code)
}} />
}
}
return (
<pre ref={codeRef}><code className={current.language ? `language-${current.language}` : ''}>
{current.code}
</code></pre>
)
}
return (
<div className="canvas-panel">
<div className="canvas-panel-header">
<span className="canvas-panel-title">{current.title || 'Artifact'}</span>
<button className="btn btn-secondary btn-sm" onClick={onClose} title="Close canvas">
<i className="fas fa-times" />
</button>
</div>
{artifacts.length > 1 && (
<div className="canvas-panel-tabs">
{artifacts.map(a => (
<button
key={a.id}
className={`canvas-panel-tab${a.id === (current?.id) ? ' active' : ''}`}
onClick={() => onSelect(a.id)}
title={a.title}
>
<i className={`fas ${getArtifactIcon(a.type, a.language)}`} />
<span>{a.title}</span>
</button>
))}
</div>
)}
<div className="canvas-panel-toolbar">
<span className="badge badge-sm">{current.type === 'code' ? current.language : current.type}</span>
{hasPreview && (
<div className="canvas-toggle-group">
<button
className={`canvas-toggle-btn${!showPreview ? ' active' : ''}`}
onClick={() => setShowPreview(false)}
>Code</button>
<button
className={`canvas-toggle-btn${showPreview ? ' active' : ''}`}
onClick={() => setShowPreview(true)}
>Preview</button>
</div>
)}
<div style={{ flex: 1 }} />
<button className="btn btn-secondary btn-sm" onClick={handleCopy} title="Copy">
<i className={`fas ${copySuccess ? 'fa-check' : 'fa-copy'}`} />
</button>
<button className="btn btn-secondary btn-sm" onClick={handleDownload} title="Download">
<i className="fas fa-download" />
</button>
</div>
<div className="canvas-panel-body">
{renderBody()}
</div>
</div>
)
}

View File

@@ -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 (
<div className="resource-cards">
{shown.map(item => (
<div
key={item.id}
className={`resource-card resource-card-${item.type}`}
onClick={() => onOpenArtifact && onOpenArtifact(item.id)}
>
{item.type === 'image' ? (
<img src={item.url} alt={item.title} className="resource-card-thumb" />
) : (
<i className={`fas ${getArtifactIcon(item.type)}`} />
)}
<span className="resource-card-label">{item.title}</span>
</div>
))}
{hasMore && !expanded && (
<button className="resource-cards-more" onClick={(e) => { e.stopPropagation(); setExpanded(true) }}>
+{items.length - 3} more
</button>
)}
{hasMore && expanded && (
<button className="resource-cards-more" onClick={(e) => { e.stopPropagation(); setExpanded(false) }}>
Show less
</button>
)}
</div>
)
}

View File

@@ -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,
}
}

View File

@@ -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 (
<div className="chat-message chat-message-assistant">
<div className="chat-message-avatar" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
<i className="fas fa-cogs" />
</div>
<div className="chat-activity-group">
<button className="chat-activity-toggle" onClick={() => setExpanded(!expanded)}>
<span className="chat-activity-summary">
{summary}
{items.length > 1 && <span className="chat-activity-count">+{items.length - 1}</span>}
</span>
<i className={`fas fa-chevron-${expanded ? 'up' : 'down'}`} />
</button>
{expanded && (
<div className="chat-activity-details">
{items.map((item, idx) => (
<div key={idx} className="chat-activity-item">
<span className="chat-activity-item-label">{new Date(item.timestamp).toLocaleTimeString()}</span>
<div className="chat-activity-item-content"
dangerouslySetInnerHTML={{ __html: item.content }} />
</div>
))}
</div>
)}
</div>
</div>
)
}
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 (
<div className={`chat-layout${sidebarOpen ? '' : ' chat-sidebar-collapsed'}`}>
{/* Conversation sidebar */}
<div className={`chat-sidebar${sidebarOpen ? '' : ' hidden'}`}>
<div className="chat-sidebar-header">
<button className="btn btn-primary btn-sm" style={{ flex: 1 }} onClick={() => addConversation()}>
<i className="fas fa-plus" /> New Chat
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => {
if (confirm('Delete all conversations? This cannot be undone.')) deleteAllConversations()
}}
title="Delete all conversations"
style={{ padding: '6px 8px' }}
>
<i className="fas fa-trash" />
</button>
</div>
<div style={{ padding: '0 var(--spacing-sm)' }}>
<div className="chat-search-wrapper">
<i className="fas fa-search chat-search-icon" />
<input
className="chat-search-input"
type="text"
value={chatSearch}
onChange={(e) => setChatSearch(e.target.value)}
placeholder="Search conversations..."
/>
{chatSearch && (
<button className="chat-search-clear" onClick={() => setChatSearch('')}>
<i className="fas fa-times" />
</button>
)}
</div>
</div>
<div className="chat-list">
{filteredConversations.map(conv => (
<div
key={conv.id}
className={`chat-list-item ${conv.id === activeId ? 'active' : ''}`}
onClick={() => switchConversation(conv.id)}
>
<i className="fas fa-message" style={{ fontSize: '0.7rem', flexShrink: 0, marginTop: '2px' }} />
{editingName === conv.id ? (
<input
className="input"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={finishRename}
onKeyDown={(e) => e.key === 'Enter' && finishRename()}
autoFocus
onClick={(e) => e.stopPropagation()}
style={{ padding: '2px 4px', fontSize: '0.8125rem' }}
/>
) : (
<div className="chat-list-item-info">
<div className="chat-list-item-top">
<span
className="chat-list-item-name"
onDoubleClick={() => startRename(conv.id, conv.name)}
>
{processingChatId === conv.id && <i className="fas fa-circle-notch fa-spin" style={{ marginRight: '6px', fontSize: '0.7rem', opacity: 0.7 }} />}
{conv.name}
</span>
<span className="chat-list-item-time">{relativeTime(conv.updatedAt)}</span>
</div>
<span className="chat-list-item-preview">
{getLastMessagePreview(conv) || 'No messages yet'}
</span>
</div>
)}
<div className="chat-list-item-actions">
<button
onClick={(e) => { e.stopPropagation(); startRename(conv.id, conv.name) }}
title="Rename"
>
<i className="fas fa-edit" />
</button>
{conversations.length > 1 && (
<button
className="chat-list-item-delete"
onClick={(e) => { e.stopPropagation(); deleteConversation(conv.id) }}
title="Delete conversation"
>
<i className="fas fa-trash" />
</button>
)}
</div>
</div>
))}
{filteredConversations.length === 0 && chatSearch && (
<div style={{ padding: 'var(--spacing-sm)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
No conversations match your search
</div>
)}
</div>
</div>
<div className="chat-main">
{/* Header */}
<div className="chat-header">
<button
className="btn btn-secondary btn-sm"
onClick={() => setSidebarOpen(prev => !prev)}
title={sidebarOpen ? 'Hide chat list' : 'Show chat list'}
style={{ flexShrink: 0 }}
>
<i className={`fas fa-${sidebarOpen ? 'angles-left' : 'angles-right'}`} />
</button>
<span className="chat-header-title">
<i className="fas fa-robot" style={{ marginRight: 'var(--spacing-xs)' }} />
{name}
</span>
<div className="chat-header-actions">
<label className="canvas-mode-toggle" title="Extract code blocks and media into a side panel for preview, copy, and download">
<i className="fas fa-columns" />
<span className="canvas-mode-label">Canvas</span>
<span className="toggle">
<input
type="checkbox"
checked={canvasMode}
onChange={(e) => {
setCanvasMode(e.target.checked)
if (!e.target.checked) setCanvasOpen(false)
}}
/>
<span className="toggle-slider" />
</span>
</label>
{canvasMode && artifacts.length > 0 && !canvasOpen && (
<button
className="btn btn-secondary btn-sm"
onClick={() => { setSelectedArtifactId(artifacts[0]?.id); setCanvasOpen(true) }}
title="Open canvas panel"
>
<i className="fas fa-layer-group" /> {artifacts.length}
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/agents/${encodeURIComponent(name)}/status`)} title="View status & observables">
<i className="fas fa-chart-bar" /> Status
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setMessages([])} disabled={messages.length === 0} title="Clear chat history">
<button className="btn btn-secondary btn-sm" onClick={() => clearMessages()} disabled={messages.length === 0} title="Clear chat history">
<i className="fas fa-eraser" /> Clear
</button>
</div>
@@ -161,55 +480,70 @@ export default function AgentChat() {
</div>
</div>
)}
{messages.map(msg => {
const role = senderToRole(msg.sender)
if (role === 'system') {
return (
<div key={msg.id} className="chat-message chat-message-system">
{(() => {
const elements = []
let systemBuf = []
const flushSystem = (key) => {
if (systemBuf.length > 0) {
elements.push(<AgentActivityGroup key={`sag-${key}`} items={[...systemBuf]} />)
systemBuf = []
}
}
messages.forEach((msg, idx) => {
const role = senderToRole(msg.sender)
if (role === 'system') {
systemBuf.push(msg)
return
}
flushSystem(idx)
elements.push(
<div key={msg.id} className={`chat-message chat-message-${role}`}>
<div className="chat-message-avatar">
<i className={`fas ${role === 'user' ? 'fa-user' : 'fa-robot'}`} />
</div>
<div className="chat-message-bubble">
<div className="chat-message-content" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(msg.content) }} />
<div className="chat-message-content">
{role === 'user' ? (
<div dangerouslySetInnerHTML={{ __html: msg.content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') }} />
) : (
<div dangerouslySetInnerHTML={{
__html: canvasMode
? renderMarkdownWithArtifacts(msg.content, idx)
: renderMarkdown(msg.content)
}} />
)}
</div>
{role === 'assistant' && msg.metadata && (
<ResourceCards
metadata={msg.metadata}
messageIndex={idx}
agentName={name}
onOpenArtifact={openArtifactById}
/>
)}
<div className="chat-message-actions">
<button onClick={() => copyMessage(msg.content)} title="Copy">
<i className="fas fa-copy" />
</button>
</div>
<div className="chat-message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
)
}
return (
<div key={msg.id} className={`chat-message chat-message-${role}`}>
<div className="chat-message-avatar">
<i className={`fas ${role === 'user' ? 'fa-user' : 'fa-robot'}`} />
</div>
<div className="chat-message-bubble">
<div className="chat-message-content">
{role === 'user' ? (
<div dangerouslySetInnerHTML={{ __html: msg.content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') }} />
) : (
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content) }} />
)}
</div>
<div className="chat-message-actions">
<button onClick={() => copyMessage(msg.content)} title="Copy">
<i className="fas fa-copy" />
</button>
</div>
<div className="chat-message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
</div>
)
})}
})
flushSystem('end')
return elements
})()}
{processing && (
<div className="chat-message chat-message-assistant">
<div className="chat-message-avatar">
<i className="fas fa-robot" />
<div className="chat-message-avatar" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>
<i className="fas fa-cogs" />
</div>
<div className="chat-message-bubble">
<div className="chat-message-content" style={{ color: 'var(--color-text-muted)' }}>
<i className="fas fa-circle-notch fa-spin" /> Thinking...
<div className="chat-activity-group chat-activity-streaming">
<div className="chat-activity-toggle" style={{ cursor: 'default' }}>
<span className="chat-activity-summary chat-activity-shimmer">Working...</span>
</div>
</div>
</div>
@@ -245,5 +579,14 @@ export default function AgentChat() {
</div>
</div>
</div>
{canvasOpen && artifacts.length > 0 && (
<CanvasPanel
artifacts={artifacts}
selectedId={selectedArtifactId}
onSelect={setSelectedArtifactId}
onClose={() => setCanvasOpen(false)}
/>
)}
</div>
)
}

View File

@@ -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 <span className="chat-activity-item-text">{fallback}</span>
}
if (entries.length === 0) return null
return (
<div className="chat-activity-params">
{entries.map(([k, v]) => {
const val = typeof v === 'string' ? v : JSON.stringify(v, null, 2)
const isLong = val.length > 120
return (
<div key={k} className="chat-activity-param">
<span className="chat-activity-param-key">{k}:</span>
<span className={`chat-activity-param-val${isLong ? ' chat-activity-param-val-long' : ''}`}>{val}</span>
</div>
)
})}
</div>
)
}
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 (
<div className="chat-thinking-box">
<button className="chat-thinking-header" onClick={onToggle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<i className="fas fa-brain" style={{ color: 'var(--color-primary)' }} />
<span className="chat-thinking-label">Thinking</span>
<span style={{ fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>
({(msg.content || '').split('\n').length} lines)
</span>
</div>
<i className={`fas fa-chevron-${msg.expanded ? 'up' : 'down'}`} style={{ color: 'var(--color-primary)', fontSize: '0.75rem' }} />
</button>
{msg.expanded && (
<div
ref={contentRef}
className="chat-thinking-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.content || '') }}
/>
)}
</div>
)
}
function ToolCallMessage({ msg, onToggle }) {
let parsed = null
try { parsed = JSON.parse(msg.content) } catch (_e) { /* ignore */ }
const isCall = msg.role === 'tool_call'
return (
<div className={`chat-tool-box chat-tool-box-${isCall ? 'call' : 'result'}`}>
<button className="chat-tool-header" onClick={onToggle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<i className={`fas ${isCall ? 'fa-wrench' : 'fa-check-circle'}`}
style={{ color: isCall ? 'var(--color-accent)' : 'var(--color-success)' }} />
<span className="chat-tool-label">
{isCall ? 'Tool Call' : 'Tool Result'}: {parsed?.name || 'unknown'}
</span>
</div>
<i className={`fas fa-chevron-${msg.expanded ? 'up' : 'down'}`}
style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }} />
</button>
{msg.expanded && (
<div className="chat-tool-content">
<pre><code>{msg.content}</code></pre>
</div>
)}
</div>
)
}
function StreamingToolCalls({ toolCalls }) {
if (!toolCalls || toolCalls.length === 0) return null
return toolCalls.map((tc, i) => {
const isCall = tc.type === 'tool_call'
return (
<div key={i} className={`chat-tool-box chat-tool-box-${isCall ? 'call' : 'result'}`}>
<div className="chat-tool-header" style={{ cursor: 'default' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<i className={`fas ${isCall ? 'fa-wrench' : 'fa-check-circle'}`}
style={{ color: isCall ? 'var(--color-accent)' : 'var(--color-success)' }} />
<span className="chat-tool-label">
{isCall ? 'Tool Call' : 'Tool Result'}: {tc.name}
</span>
<span className="chat-streaming-cursor" />
</div>
</div>
<div className="chat-tool-content">
<pre><code>{JSON.stringify(isCall ? tc.arguments : tc.result, null, 2)}</code></pre>
</div>
</div>
)
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 (
<div className="chat-message chat-message-assistant">
<div className="chat-message-avatar">
<i className="fas fa-cogs" />
</div>
<div className="chat-activity-group">
<button className="chat-activity-toggle" onClick={() => setExpanded(!expanded)}>
<span className="chat-activity-summary">{summary}</span>
<i className={`fas fa-chevron-${expanded ? 'up' : 'down'}`} />
</button>
{expanded && (
<div className="chat-activity-details" ref={contentRef}>
{items.map((item, idx) => {
if (item.role === 'thinking' || item.role === 'reasoning') {
return (
<div key={idx} className="chat-activity-item chat-activity-thinking">
<span className="chat-activity-item-label">Thought</span>
<div className="chat-activity-item-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown(item.content || '') }} />
</div>
)
}
const isCall = item.role === 'tool_call'
const parsed = formatToolContent(item.content)
return (
<div key={idx} className={`chat-activity-item ${isCall ? 'chat-activity-tool-call' : 'chat-activity-tool-result'}`}>
<span className="chat-activity-item-label">{labels[idx]}</span>
<ToolParams entries={parsed.entries} fallback={parsed.fallback} />
</div>
)
})}
</div>
)}
</div>
</div>
)
}
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 (
<div className="chat-message chat-message-assistant">
<div className="chat-message-avatar">
<i className="fas fa-cogs" />
</div>
<div className="chat-activity-group chat-activity-streaming">
<button className="chat-activity-toggle" onClick={() => setManualCollapse(expanded)}>
<span className={`chat-activity-summary${!expanded ? ' chat-activity-shimmer' : ''}`}>
{label}
</span>
<i className={`fas fa-chevron-${expanded ? 'up' : 'down'}`} />
</button>
{expanded && reasoning && (
<div className="chat-activity-details">
<div className="chat-activity-item chat-activity-thinking">
<div className="chat-activity-item-content chat-activity-live" ref={contentRef}
dangerouslySetInnerHTML={{ __html: renderMarkdown(reasoning) }} />
</div>
</div>
)}
{expanded && toolCalls && toolCalls.length > 0 && (
<div className="chat-activity-details">
{toolCalls.map((tc, idx) => {
const parsed = formatToolContent(JSON.stringify(tc, null, 2))
return (
<div key={idx} className={`chat-activity-item ${tc.type === 'tool_call' ? 'chat-activity-tool-call' : 'chat-activity-tool-result'}`}>
<span className="chat-activity-item-label">{tc.name || tc.type}</span>
<ToolParams entries={parsed.entries} fallback={parsed.fallback} />
</div>
)
})}
</div>
)}
</div>
</div>
)
}
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() {
</label>
)}
<div className="chat-header-actions">
<label className="canvas-mode-toggle" title="Extract code blocks and media into a side panel for preview, copy, and download">
<i className="fas fa-columns" />
<span className="canvas-mode-label">Canvas</span>
<span className="toggle">
<input
type="checkbox"
checked={canvasMode}
onChange={(e) => {
setCanvasMode(e.target.checked)
if (!e.target.checked) setCanvasOpen(false)
}}
/>
<span className="toggle-slider" />
</span>
</label>
{canvasMode && artifacts.length > 0 && !canvasOpen && (
<button
className="btn btn-secondary btn-sm"
onClick={() => { setSelectedArtifactId(artifacts[0]?.id); setCanvasOpen(true) }}
title="Open canvas panel"
>
<i className="fas fa-layer-group" /> {artifacts.length}
</button>
)}
<button
className="btn btn-secondary btn-sm"
onClick={() => exportChatAsMarkdown(activeChat)}
@@ -663,81 +814,69 @@ export default function Chat() {
</div>
</div>
)}
{activeChat.history.map((msg, i) => {
if (msg.role === 'thinking' || msg.role === 'reasoning') {
return (
<ThinkingMessage key={i} msg={msg} onToggle={() => {
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(
<ActivityGroup key={`ag-${key}`} items={[...activityBuf]}
updateChatSettings={updateChatSettings} activeChat={activeChat} />
)
activityBuf = []
}
}
if (msg.role === 'tool_call' || msg.role === 'tool_result') {
return (
<ToolCallMessage key={i} msg={msg} onToggle={() => {
const newHistory = [...activeChat.history]
newHistory[i] = { ...newHistory[i], expanded: !newHistory[i].expanded }
updateChatSettings(activeChat.id, { history: newHistory })
}} />
)
}
return (
<div key={i} className={`chat-message chat-message-${msg.role}`}>
<div className="chat-message-avatar">
<i className={`fas ${msg.role === 'user' ? 'fa-user' : 'fa-robot'}`} />
</div>
<div className="chat-message-bubble">
{msg.role === 'assistant' && activeChat.model && (
<span className="chat-message-model">{activeChat.model}</span>
)}
<div className="chat-message-content">
{msg.role === 'user' ? (
<UserMessageContent content={msg.content} files={msg.files} />
) : (
<div dangerouslySetInnerHTML={{
__html: renderMarkdown(typeof msg.content === 'string' ? msg.content : '')
}} />
)}
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(
<div key={i} className={`chat-message chat-message-${msg.role}`}>
<div className="chat-message-avatar">
<i className={`fas ${msg.role === 'user' ? 'fa-user' : 'fa-robot'}`} />
</div>
<div className="chat-message-actions">
<button onClick={() => copyMessage(msg.content)} title="Copy">
<i className="fas fa-copy" />
</button>
{msg.role === 'assistant' && i === activeChat.history.length - 1 && !isStreaming && (
<button onClick={handleRegenerate} title="Regenerate">
<i className="fas fa-rotate" />
<div className="chat-message-bubble">
{msg.role === 'assistant' && activeChat.model && (
<span className="chat-message-model">{activeChat.model}</span>
)}
<div className="chat-message-content">
{msg.role === 'user' ? (
<UserMessageContent content={msg.content} files={msg.files} />
) : (
<div dangerouslySetInnerHTML={{
__html: canvasMode
? renderMarkdownWithArtifacts(typeof msg.content === 'string' ? msg.content : '', i)
: renderMarkdown(typeof msg.content === 'string' ? msg.content : '')
}} />
)}
</div>
<div className="chat-message-actions">
<button onClick={() => copyMessage(msg.content)} title="Copy">
<i className="fas fa-copy" />
</button>
)}
{msg.role === 'assistant' && i === activeChat.history.length - 1 && !isStreaming && (
<button onClick={handleRegenerate} title="Regenerate">
<i className="fas fa-rotate" />
</button>
)}
</div>
</div>
</div>
</div>
)
})}
)
})
flushActivity('end')
return elements
})()}
{/* Streaming reasoning box */}
{isStreaming && streamingReasoning && (
<div className="chat-thinking-box chat-thinking-box-streaming">
<div className="chat-thinking-header" style={{ cursor: 'default' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}>
<i className="fas fa-brain" style={{ color: 'var(--color-primary)' }} />
<span className="chat-thinking-label">Thinking</span>
<span className="chat-streaming-cursor" />
</div>
</div>
<div
ref={thinkingBoxRef}
className="chat-thinking-content"
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
{streamingReasoning}
</div>
</div>
{/* Streaming activity (thinking + tools) */}
{isStreaming && (streamingReasoning || streamingToolCalls.length > 0) && (
<StreamingActivity reasoning={streamingReasoning} toolCalls={streamingToolCalls} hasResponse={!!streamingContent} />
)}
{/* Streaming tool calls */}
{isStreaming && <StreamingToolCalls toolCalls={streamingToolCalls} />}
{/* Streaming message */}
{isStreaming && streamingContent && (
<div className="chat-message chat-message-assistant">
@@ -848,6 +987,14 @@ export default function Chat() {
</div>
</div>
</div>
{canvasOpen && artifacts.length > 0 && (
<CanvasPanel
artifacts={artifacts}
selectedId={selectedArtifactId}
onSelect={setSelectedArtifactId}
onClose={() => setCanvasOpen(false)}
/>
)}
</div>
)
}

View File

@@ -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 `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>`
}
return `<pre><code>${text.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code></pre>`
},
},
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 `<div class="artifact-card" data-artifact-id="${id}">
<div class="artifact-card-icon"><i class="fas ${icon}"></i></div>
<div class="artifact-card-info">
<span class="artifact-card-title">${title}</span>
<span class="artifact-card-lang">${language}</span>
</div>
<div class="artifact-card-actions">
<button class="artifact-card-download" data-artifact-id="${id}" title="Download"><i class="fas fa-download"></i></button>
<button class="artifact-card-open" data-artifact-id="${id}" title="Open in canvas"><i class="fas fa-external-link-alt"></i></button>
</div>
</div>`
},
}
const customMarked = new Marked({ renderer, breaks: true, gfm: true })
const html = customMarked.parse(text)
return DOMPurify.sanitize(html, { ADD_ATTR: ['data-artifact-id'] })
}

View File

@@ -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))

View File

@@ -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)

View File

@@ -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:<port>/v1/chat/completions`) for LLM inference. This means: