Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"dependencies": {
"moment": "^2.30.1"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.5",
"@types/json-schema": "^7.0.15",
"@types/moment": "^2.11.29",
"@types/node": "^25.0.1",
"@vue/tsconfig": "^0.8.1"
}
}
39 changes: 34 additions & 5 deletions src/frontend/src/components/chat/ChatHeaderMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Agent, Thread } from '@/services/api';
import { useAgentStore } from '@/composables/useAgentStore';
import { IconMessage2Plus, IconEditCircle, IconHistory, IconTrash, IconInfoCircle, IconCopyPlus } from '@tabler/icons-vue';
import { useChatExport } from '@/composables/useChatExport';
import { IconMessage2Plus, IconEditCircle, IconHistory, IconTrash, IconInfoCircle, IconCopyPlus, IconDownload } from '@tabler/icons-vue';
import { useErrorHandler } from '@/composables/useErrorHandler';

const { configureAgent, cloneAgent } = useAgentStore();
const { handleError } = useErrorHandler();
const { exportChatAsJson, exportChatAsMarkdown } = useChatExport();

const props = defineProps<{
agent: Agent,
chat: Thread,
editingAgent?: boolean
editingAgent?: boolean,
messages?: any[]
}>()
const emit = defineEmits<{
(e: 'showPastChats'): void
Expand Down Expand Up @@ -61,6 +64,17 @@ const handleCloneAgent = async () => {
}
}


const handleExportAsJson = () => {
exportChatAsJson(props.chat, props.chat.name, agentName.value, props.messages ?? []);
closeMenu();
}

const handleExportAsMarkdown = () => {
exportChatAsMarkdown(props.chat, props.chat.name, agentName.value, props.messages ?? []);
closeMenu();
}

</script>
<template>
<div class="flex items-center gap-2 px-3 py-1.5 text-light-gray border border-auxiliar-gray rounded-2xl" :class="[{ '!bg-abstracta !text-white': menuIsActive }]">
Expand Down Expand Up @@ -101,6 +115,17 @@ const handleCloneAgent = async () => {
command: handleCloneAgent
},
{ separator: true },
{
label: t('exportChatAsJsonTooltip'),
tablerIcon: IconDownload,
command: handleExportAsJson
},
{
label: t('exportChatAsMarkdownTooltip'),
tablerIcon: IconDownload,
command: handleExportAsMarkdown
},
{ separator: true },
{
label: t('previousChatsTooltip'),
tablerIcon: IconHistory,
Expand Down Expand Up @@ -141,7 +166,9 @@ const handleCloneAgent = async () => {
"newChatTooltip": "New chat",
"editAgentTooltip": "Edit agent",
"agentInfoTooltip": "View details",
"cloneAgentTooltip": "Clone agent"
"cloneAgentTooltip": "Clone agent",
"exportChatAsJsonTooltip": "Export as JSON",
"exportChatAsMarkdownTooltip": "Export as Markdown"
},
"es": {
"deleteChatTooltip": "Eliminar chat",
Expand All @@ -153,7 +180,9 @@ const handleCloneAgent = async () => {
"newChatTooltip": "Nuevo chat",
"editAgentTooltip": "Editar agente",
"agentInfoTooltip": "Ver detalles",
"cloneAgentTooltip": "Clonar agente"
}
"cloneAgentTooltip": "Clonar agente",
"exportChatAsJsonTooltip": "Exportar como JSON",
"exportChatAsMarkdownTooltip": "Exportar como Markdown",
}
}
</i18n>
1 change: 1 addition & 0 deletions src/frontend/src/components/chat/ChatPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ const handleViewFile = (file: UploadedFile) => {
v-if="chat && agentsStore.currentAgent"
:chat="chat"
:agent="agentsStore.currentAgent"
:messages="messages"
:editing-agent="editingAgent"
@new-chat="onNewChat"
@show-past-chats="onShowPastChats"
Expand Down
4 changes: 2 additions & 2 deletions src/frontend/src/components/chat/ChatPanelHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const { deleteChat } = useChatStore();
const { handleError } = useErrorHandler();
const { t } = useI18n();

defineProps<{chat: Thread, agent: Agent, editingAgent?: boolean}>()
defineProps<{chat: Thread, agent: Agent, messages?: any[], editingAgent?: boolean}>()
const emit = defineEmits(['newChat', 'showPastChats']);

const handleChatDelete = async (chat: Thread)=>{
Expand All @@ -27,7 +27,7 @@ const handleChatDelete = async (chat: Thread)=>{
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3 w-full">
<div class="flex items-center gap-2">
<Animate :effect="AnimationEffect.FADE_IN">
<ChatHeaderMenu :agent="agent" :chat="chat" :editing-agent="editingAgent" @show-past-chats="emit('showPastChats')"
<ChatHeaderMenu :agent="agent" :chat="chat" :messages="messages" :editing-agent="editingAgent" @show-past-chats="emit('showPastChats')"
@delete-chat="handleChatDelete" @new-chat="emit('newChat')"/>
</Animate>
</div>
Expand Down
45 changes: 45 additions & 0 deletions src/frontend/src/composables/useChatExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ApiService, Thread } from '@/services/api'
import { useErrorHandler } from './useErrorHandler'

export function useChatExport() {
const api = new ApiService()
const { handleError } = useErrorHandler()

const validateMessagesForExport = (messages: any[] | undefined, errorMessage?: string): boolean => {
if (!messages || messages.length === 0) {
if (errorMessage) {
handleError(new Error(errorMessage))
}
return false
}
return true
}

const exportChatAsJson = (thread: Thread, threadName: string, agentName: string, messages: any[]): void => {
try {
if (!validateMessagesForExport(messages)) {
return
}
api.exportChatAsJson(thread.id, threadName, agentName, messages)
} catch (error) {
handleError(error)
}
}

const exportChatAsMarkdown = (thread: Thread, threadName: string, agentName: string, messages: any[]): void => {
try {
if (!validateMessagesForExport(messages)) {
return
}
api.exportChatAsMarkdown(thread.id, threadName, agentName, messages)
} catch (error) {
handleError(error)
}
}

return {
exportChatAsJson,
exportChatAsMarkdown,
validateMessagesForExport
}
}
113 changes: 112 additions & 1 deletion src/frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ import auth from './auth'
import moment from 'moment'
import type { JSONSchema7 } from 'json-schema'
import { UploadedFile, FileStatus, AgentPrompt } from '../../../common/src/utils/domain'
import type { StatusUpdate } from '../../../common/src/components/chat/ChatMessage.vue'

export interface StatusUpdate {
action: string
toolName?: string
description?: string
args?: any
step?: string
result?: string | string[]
timestamp: Date
}

export class HttpError extends Error {
public status: number
Expand Down Expand Up @@ -758,6 +767,108 @@ export class ApiService {
return await this.put(`/agents/${agentId}/dist`, formData)
}

exportChatAsJson(threadId: number, threadName: string, agentName: string, messages: any[]): void {
const exportData = {
schemaVersion: "1.0",
exportedAt: new Date().toISOString(),
chat: {
id: threadId,
title: threadName,
agent: { name: agentName }
},
messages: this.flattenMessages(messages)
};
this.downloadTextFile(JSON.stringify(exportData, null, 2), `chat_${threadId}_${Date.now()}.json`)
}

exportChatAsMarkdown(threadId: number, threadName: string, agentName: string, messages: any[]): void {
const markdown = this.messagesToMarkdown(threadName, agentName, messages)
this.downloadTextFile(markdown, `chat_${threadId}_${Date.now()}.md`)
}

/**
* Aplana la jerarquía de mensajes en un solo array, usando parentId para mantener la relación.
*/
private flattenMessages(messages: any[], parentId: number | null = null, acc: any[] = []): any[] {
for (const msg of messages) {
const flatMsg: any = {
id: msg.id,
parentId: parentId === undefined ? null : parentId,
author: msg.origin === undefined ? (msg.isUser ? "user" : "agent") : (msg.origin === 0 ? "user" : "agent"),
text: msg.text,
meta: {
minutesSaved: msg.minutesSaved ?? null,
stopped: msg.stopped ?? null
},
statusUpdates: Array.isArray(msg.statusUpdates) ? msg.statusUpdates : []
};
acc.push(flatMsg);
if (msg.children && Array.isArray(msg.children) && msg.children.length > 0) {
this.flattenMessages(msg.children, msg.id, acc);
}
}
return acc;
}

private messagesToMarkdown(threadName: string, agentName: string, messages: any[]): string {
const exportDate = new Date().toLocaleDateString();
let markdown = `# ${threadName}\n\n**Agente**: ${agentName}\n**Fecha de exportación**: ${exportDate}\n\n---\n\n`;

const traverseMessages = (msgs: any[], depth: number = 0) => {
msgs.forEach(msg => {
const prefix = msg.origin === ThreadMessageOrigin.USER ? '👤 Usuario' : msg.isUser ? '👤 Usuario' : '🤖 Agente';
markdown += `### ${prefix}\n\n${msg.text}\n\n`;

// Mostrar siempre minutesSaved y stopped, aunque sean null
markdown += `*minutesSaved*: ${msg.minutesSaved ?? 'null'}\n\n`;
markdown += `*stopped*: ${msg.stopped ?? 'null'}\n\n`;

if (msg.minutesSaved) {
markdown += `⏱️ *Tiempo ahorrado: ${msg.minutesSaved} minutos*\n\n`;
}

if (msg.stopped) {
markdown += `⏸️ *Respuesta detenida*\n\n`;
}

// Incluir proceso de pensamiento del agente (statusUpdates)
if (Array.isArray(msg.statusUpdates) && msg.statusUpdates.length > 0) {
markdown += `**Proceso de pensamiento del agente:**\n`;
msg.statusUpdates.forEach((su: any, idx: number) => {
markdown += `- [${idx + 1}] Acción: ${su.action || ''}`;
if (su.toolName) markdown += ` | Herramienta: ${su.toolName}`;
if (su.description) markdown += ` | Descripción: ${su.description}`;
if (su.step) markdown += ` | Paso: ${su.step}`;
if (su.result) markdown += ` | Resultado: ${Array.isArray(su.result) ? su.result.join(', ') : su.result}`;
markdown += '\n';
});
markdown += '\n';
}

markdown += '---\n\n';

if (msg.children && msg.children.length > 0) {
traverseMessages(msg.children, depth + 1);
}
});
};

traverseMessages(messages);
return markdown;
}

private downloadTextFile(content: string, filename: string): void {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}

async findAgentById(agentId: number): Promise<Agent> {
return await this.fetchJson(`/agents/${agentId}`)
}
Expand Down
11 changes: 7 additions & 4 deletions src/frontend/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
"@/*": ["./src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ES2022",
"moduleResolution": "bundler",
"target": "ES2022",
"experimentalDecorators": true
}
}