mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-01 05:36:49 -04:00
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:
committed by
GitHub
parent
01bd3d8212
commit
85f3558d22
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
161
core/http/react-ui/src/components/CanvasPanel.jsx
Normal file
161
core/http/react-ui/src/components/CanvasPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
core/http/react-ui/src/components/ResourceCards.jsx
Normal file
65
core/http/react-ui/src/components/ResourceCards.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
core/http/react-ui/src/hooks/useAgentChat.js
vendored
Normal file
173
core/http/react-ui/src/hooks/useAgentChat.js
vendored
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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, '&').replace(/</g, '<').replace(/>/g, '>').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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
168
core/http/react-ui/src/utils/artifacts.js
vendored
Normal file
168
core/http/react-ui/src/utils/artifacts.js
vendored
Normal 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, '<').replace(/>/g, '>')}</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'] })
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user