feat(agent-jobs): add multimedia support (#7398)

* feat(agent-jobs): add multimedia support

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Refactoring

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2025-11-30 14:09:25 +01:00
committed by GitHub
parent 45ee10ec50
commit a3423f33e1
10 changed files with 913 additions and 47 deletions

View File

@@ -147,7 +147,18 @@ func ExecuteJobEndpoint(app *application.Application) echo.HandlerFunc {
req.Parameters = make(map[string]string)
}
jobID, err := app.AgentJobService().ExecuteJob(req.TaskID, req.Parameters, "api")
// Build multimedia struct from request
var multimedia *schema.MultimediaAttachment
if len(req.Images) > 0 || len(req.Videos) > 0 || len(req.Audios) > 0 || len(req.Files) > 0 {
multimedia = &schema.MultimediaAttachment{
Images: req.Images,
Videos: req.Videos,
Audios: req.Audios,
Files: req.Files,
}
}
jobID, err := app.AgentJobService().ExecuteJob(req.TaskID, req.Parameters, "api", multimedia)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
@@ -281,7 +292,7 @@ func ExecuteTaskByNameEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
name := c.Param("name")
var params map[string]string
// Try to bind parameters from request body
// If body is empty or invalid, use empty params
if c.Request().ContentLength > 0 {
@@ -323,7 +334,7 @@ func ExecuteTaskByNameEndpoint(app *application.Application) echo.HandlerFunc {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Task not found: " + name})
}
jobID, err := app.AgentJobService().ExecuteJob(task.ID, params, "api")
jobID, err := app.AgentJobService().ExecuteJob(task.ID, params, "api", nil)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
@@ -336,4 +347,3 @@ func ExecuteTaskByNameEndpoint(app *application.Application) echo.HandlerFunc {
})
}
}

View File

@@ -365,21 +365,40 @@
x-cloak
@click.away="showExecuteTaskModal = false; selectedTaskForExecution = null; executionParameters = {}; executionParametersText = ''"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded-xl p-8 max-w-2xl w-full mx-4">
<div class="flex justify-between items-center mb-6">
<div class="bg-[var(--color-bg-secondary)] border border-[var(--color-primary-border)]/20 rounded-xl max-w-2xl w-full mx-4 max-h-[90vh] flex flex-col">
<div class="flex justify-between items-center p-8 pb-6 border-b border-[var(--color-primary-border)]/20">
<h3 class="text-2xl font-semibold text-[var(--color-text-primary)]">Execute Task</h3>
<button @click="showExecuteTaskModal = false; selectedTaskForExecution = null; executionParameters = {}; executionParametersText = ''"
<button @click="showExecuteTaskModal = false; selectedTaskForExecution = null; executionParameters = {}; executionParametersText = ''; executionMultimedia = {images: '', videos: '', audios: '', files: ''}; executeModalTab = 'parameters'"
class="text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<template x-if="selectedTaskForExecution">
<div class="space-y-4">
<div class="flex flex-col flex-1 min-h-0">
<div class="flex-1 overflow-y-auto px-8 py-6 space-y-4">
<div>
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-2">Task</label>
<div class="text-[var(--color-text-secondary)]" x-text="selectedTaskForExecution.name"></div>
</div>
<div>
<!-- Tabs for Parameters and Multimedia -->
<div class="border-b border-[var(--color-primary-border)]/20">
<div class="flex space-x-4">
<button @click="executeModalTab = 'parameters'"
:class="executeModalTab === 'parameters' ? 'border-b-2 border-[var(--color-primary)] text-[var(--color-primary)]' : 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'"
class="px-4 py-2 font-medium transition-colors">
Parameters
</button>
<button @click="executeModalTab = 'multimedia'"
:class="executeModalTab === 'multimedia' ? 'border-b-2 border-[var(--color-primary)] text-[var(--color-primary)]' : 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'"
class="px-4 py-2 font-medium transition-colors">
Multimedia
</button>
</div>
</div>
<!-- Parameters Tab -->
<div x-show="executeModalTab === 'parameters'">
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-2">Parameters</label>
<p class="text-xs text-[var(--color-text-secondary)] mb-3">
Enter parameters as key-value pairs (one per line, format: key=value).
@@ -393,8 +412,61 @@
Example: <code class="bg-[var(--color-bg-primary)] px-1 py-0.5 rounded text-[var(--color-primary)]">user_name=Alice</code>
</p>
</div>
<div class="flex justify-end space-x-4">
<button @click="showExecuteTaskModal = false; selectedTaskForExecution = null; executionParameters = {}; executionParametersText = ''"
<!-- Multimedia Tab -->
<div x-show="executeModalTab === 'multimedia'" class="space-y-4">
<p class="text-xs text-[var(--color-text-secondary)] mb-3">
Provide multimedia content as URLs or base64-encoded data URIs. You can also upload files which will be converted to base64.
</p>
<!-- Images -->
<div>
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-2">Images</label>
<textarea x-model="executionMultimedia.images"
rows="3"
placeholder="https://example.com/image.png&#10;data:image/png;base64,iVBORw0KG..."
class="w-full bg-[var(--color-bg-primary)] border border-[var(--color-primary-border)]/20 rounded px-4 py-2 text-[var(--color-text-primary)] font-mono text-sm focus:border-[var(--color-primary-border)] focus:ring-2 focus:ring-[#38BDF8]/50"></textarea>
<input type="file" @change="handleFileUpload($event, 'image')" accept="image/*" multiple
class="mt-2 text-sm text-[var(--color-text-secondary)] file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-[var(--color-primary)] file:text-white hover:file:bg-[var(--color-primary-hover)]">
</div>
<!-- Videos -->
<div>
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-2">Videos</label>
<textarea x-model="executionMultimedia.videos"
rows="3"
placeholder="https://example.com/video.mp4&#10;data:video/mp4;base64,..."
class="w-full bg-[var(--color-bg-primary)] border border-[var(--color-primary-border)]/20 rounded px-4 py-2 text-[var(--color-text-primary)] font-mono text-sm focus:border-[var(--color-primary-border)] focus:ring-2 focus:ring-[#38BDF8]/50"></textarea>
<input type="file" @change="handleFileUpload($event, 'video')" accept="video/*" multiple
class="mt-2 text-sm text-[var(--color-text-secondary)] file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-[var(--color-primary)] file:text-white hover:file:bg-[var(--color-primary-hover)]">
</div>
<!-- Audios -->
<div>
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-2">Audios</label>
<textarea x-model="executionMultimedia.audios"
rows="3"
placeholder="https://example.com/audio.mp3&#10;data:audio/mpeg;base64,..."
class="w-full bg-[var(--color-bg-primary)] border border-[var(--color-primary-border)]/20 rounded px-4 py-2 text-[var(--color-text-primary)] font-mono text-sm focus:border-[var(--color-primary-border)] focus:ring-2 focus:ring-[#38BDF8]/50"></textarea>
<input type="file" @change="handleFileUpload($event, 'audio')" accept="audio/*" multiple
class="mt-2 text-sm text-[var(--color-text-secondary)] file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-[var(--color-primary)] file:text-white hover:file:bg-[var(--color-primary-hover)]">
</div>
<!-- Files -->
<div>
<label class="block text-sm font-medium text-[var(--color-text-primary)] mb-2">Files</label>
<textarea x-model="executionMultimedia.files"
rows="3"
placeholder="https://example.com/file.pdf&#10;data:application/pdf;base64,..."
class="w-full bg-[var(--color-bg-primary)] border border-[var(--color-primary-border)]/20 rounded px-4 py-2 text-[var(--color-text-primary)] font-mono text-sm focus:border-[var(--color-primary-border)] focus:ring-2 focus:ring-[#38BDF8]/50"></textarea>
<input type="file" @change="handleFileUpload($event, 'file')" multiple
class="mt-2 text-sm text-[var(--color-text-secondary)] file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-[var(--color-primary)] file:text-white hover:file:bg-[var(--color-primary-hover)]">
</div>
</div>
</div>
<div class="flex justify-end space-x-4 p-8 pt-6 border-t border-[var(--color-primary-border)]/20 bg-[var(--color-bg-secondary)]">
<button @click="showExecuteTaskModal = false; selectedTaskForExecution = null; executionParameters = {}; executionParametersText = ''; executionMultimedia = {images: '', videos: '', audios: '', files: ''}; executeModalTab = 'parameters'"
class="px-4 py-2 bg-[var(--color-bg-primary)] hover:bg-[#0A0E1A] text-[var(--color-text-primary)] rounded-lg transition-colors">
Cancel
</button>
@@ -433,6 +505,13 @@
selectedTaskForExecution: null,
executionParameters: {},
executionParametersText: '',
executionMultimedia: {
images: '',
videos: '',
audios: '',
files: ''
},
executeModalTab: 'parameters',
modelsConfig: [],
hasModels: false,
hasMCPModels: false,
@@ -528,20 +607,36 @@
// Parse parameters from text
this.executionParameters = this.parseParameters(this.executionParametersText);
// Parse multimedia from text (split by newlines, filter empty)
const parseMultimedia = (text) => {
if (!text || !text.trim()) return [];
return text.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
};
const requestBody = {
task_id: this.selectedTaskForExecution.id,
parameters: this.executionParameters,
images: parseMultimedia(this.executionMultimedia.images),
videos: parseMultimedia(this.executionMultimedia.videos),
audios: parseMultimedia(this.executionMultimedia.audios),
files: parseMultimedia(this.executionMultimedia.files)
};
try {
const response = await fetch('/api/agent/jobs/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
task_id: this.selectedTaskForExecution.id,
parameters: this.executionParameters
})
body: JSON.stringify(requestBody)
});
if (response.ok) {
this.showExecuteTaskModal = false;
this.selectedTaskForExecution = null;
this.executionParameters = {};
this.executionParametersText = '';
this.executionMultimedia = {images: '', videos: '', audios: '', files: ''};
this.executeModalTab = 'parameters';
this.fetchJobs();
} else {
const error = await response.json();
@@ -552,6 +647,34 @@
alert('Failed to execute task: ' + error.message);
}
},
handleFileUpload(event, type) {
const files = event.target.files;
if (!files || files.length === 0) return;
const dataURIs = [];
let processed = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const reader = new FileReader();
reader.onload = (e) => {
const dataURI = e.target.result;
dataURIs.push(dataURI);
processed++;
if (processed === files.length) {
// Append to existing content
const current = this.executionMultimedia[type + 's'] || '';
const newContent = current ? current + '\n' + dataURIs.join('\n') : dataURIs.join('\n');
this.executionMultimedia[type + 's'] = newContent;
}
};
reader.readAsDataURL(file);
}
},
async deleteTask(taskId) {
if (!confirm('Are you sure you want to delete this task?')) return;

View File

@@ -165,6 +165,59 @@ Provide a detailed response that addresses their specific needs.</pre>
</div>
</div>
<!-- Multimedia Sources Configuration -->
<div class="bg-[#1E293B] border border-[#38BDF8]/20 rounded-xl p-8">
<h2 class="text-2xl font-semibold text-[#E5E7EB] mb-6">Multimedia Sources (Optional)</h2>
<p class="text-sm text-[#94A3B8] mb-4">
Configure multimedia sources (images, videos, audios, files) to fetch when cron jobs execute.
Each source can have custom headers for authentication/authorization. These will be fetched and included in the job execution.
</p>
<div class="space-y-4">
<template x-for="(source, index) in taskForm.multimedia_sources" :key="index">
<div class="bg-[#101827] p-4 rounded border border-[#38BDF8]/10">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-[#E5E7EB]">Multimedia Source <span x-text="index + 1"></span></h3>
<button type="button" @click="taskForm.multimedia_sources.splice(index, 1)"
class="text-red-400 hover:text-red-300">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-[#E5E7EB] mb-2">Type *</label>
<select x-model="source.type" required
class="w-full bg-[#0A0E1A] border border-[#38BDF8]/20 rounded px-4 py-2 text-[#E5E7EB] focus:border-[#38BDF8] focus:ring-2 focus:ring-[#38BDF8]/50">
<option value="">Select type...</option>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
<option value="file">File</option>
</select>
</div>
<div>
<label class="block text-[#E5E7EB] mb-2">URL *</label>
<input type="url" x-model="source.url" required
placeholder="https://example.com/image.png"
class="w-full bg-[#0A0E1A] border border-[#38BDF8]/20 rounded px-4 py-2 text-[#E5E7EB] focus:border-[#38BDF8] focus:ring-2 focus:ring-[#38BDF8]/50">
<p class="text-xs text-[#94A3B8] mt-1">URL where multimedia content will be fetched from</p>
</div>
<div>
<label class="block text-[#E5E7EB] mb-2">Headers (JSON)</label>
<textarea x-model="source.headers_json" rows="3"
placeholder='{"Authorization": "Bearer token"}'
class="w-full bg-[#0A0E1A] border border-[#38BDF8]/20 rounded px-4 py-2 text-[#E5E7EB] font-mono text-sm focus:border-[#38BDF8] focus:ring-2 focus:ring-[#38BDF8]/50"></textarea>
<p class="text-xs text-[#94A3B8] mt-1">Custom headers for the HTTP request (e.g., Authorization)</p>
</div>
</div>
</div>
</template>
<button type="button" @click="addMultimediaSource()"
class="w-full bg-[#101827] hover:bg-[#0A0E1A] border border-[#38BDF8]/20 border-dashed rounded-lg p-4 text-[#94A3B8] hover:text-[#E5E7EB] transition-colors">
<i class="fas fa-plus mr-2"></i>Add Multimedia Source
</button>
</div>
</div>
<!-- Webhook Configuration -->
<div class="bg-[#1E293B] border border-[#38BDF8]/20 rounded-xl p-8">
<h2 class="text-2xl font-semibold text-[#E5E7EB] mb-6">Webhooks (Optional)</h2>
@@ -350,6 +403,33 @@ Provide a detailed response that addresses their specific needs.</pre>
</p>
</div>
<!-- Execute Task with Multimedia -->
<div>
<h3 class="text-lg font-semibold text-[#E5E7EB] mb-3 flex items-center">
<i class="fas fa-images text-[#38BDF8] mr-2"></i>
Execute Task with Multimedia (Images)
</h3>
<div class="bg-[#101827] border border-[#38BDF8]/10 rounded-lg p-4">
<pre class="text-xs text-[#E5E7EB] font-mono overflow-x-auto"><code>curl -X POST {{ .BaseURL }}api/agent/jobs/execute \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"task_id": "<span x-text="task ? task.id : 'task-uuid'"></span>",
"parameters": {
"user_name": "Alice",
"task_description": "Analyze this image"
},
"images": [
"https://example.com/image.png",
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
]
}'</code></pre>
</div>
<p class="text-xs text-[#94A3B8] mt-2">
You can provide multimedia content as URLs or base64-encoded data URIs. Supported types: <code class="bg-[#101827] px-1 py-0.5 rounded text-[#38BDF8]">images</code>, <code class="bg-[#101827] px-1 py-0.5 rounded text-[#38BDF8]">videos</code>, <code class="bg-[#101827] px-1 py-0.5 rounded text-[#38BDF8]">audios</code>, and <code class="bg-[#101827] px-1 py-0.5 rounded text-[#38BDF8]">files</code>.
</p>
</div>
<!-- Check Job Status -->
<div>
<h3 class="text-lg font-semibold text-[#E5E7EB] mb-3 flex items-center">
@@ -478,23 +558,42 @@ Provide a detailed response that addresses their specific needs.</pre>
<!-- Execute Task Modal -->
<div x-show="showExecuteTaskModal"
x-cloak
@click.away="showExecuteTaskModal = false; executionParameters = {}; executionParametersText = ''"
@click.away="showExecuteTaskModal = false; executionParameters = {}; executionParametersText = ''; executionMultimedia = {images: '', videos: '', audios: '', files: ''}; executeModalTab = 'parameters'"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-[#1E293B] border border-[#38BDF8]/20 rounded-xl p-8 max-w-2xl w-full mx-4">
<div class="flex justify-between items-center mb-6">
<div class="bg-[#1E293B] border border-[#38BDF8]/20 rounded-xl max-w-2xl w-full mx-4 max-h-[90vh] flex flex-col" @click.stop>
<div class="flex justify-between items-center p-8 pb-6 border-b border-[#38BDF8]/20">
<h3 class="text-2xl font-semibold text-[#E5E7EB]">Execute Task</h3>
<button @click="showExecuteTaskModal = false; executionParameters = {}; executionParametersText = ''"
<button @click="showExecuteTaskModal = false; executionParameters = {}; executionParametersText = ''; executionMultimedia = {images: '', videos: '', audios: '', files: ''}; executeModalTab = 'parameters'"
class="text-[#94A3B8] hover:text-[#E5E7EB]">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<template x-if="task">
<div class="space-y-4">
<div class="flex flex-col flex-1 min-h-0">
<div class="flex-1 overflow-y-auto px-8 py-6 space-y-4">
<div>
<label class="block text-sm font-medium text-[#E5E7EB] mb-2">Task</label>
<div class="text-[#94A3B8]" x-text="task.name"></div>
</div>
<div>
<!-- Tabs for Parameters and Multimedia -->
<div class="border-b border-[#38BDF8]/20">
<div class="flex space-x-4">
<button @click="executeModalTab = 'parameters'"
:class="executeModalTab === 'parameters' ? 'border-b-2 border-[#38BDF8] text-[#38BDF8]' : 'text-[#94A3B8] hover:text-[#E5E7EB]'"
class="px-4 py-2 font-medium transition-colors">
Parameters
</button>
<button @click="executeModalTab = 'multimedia'"
:class="executeModalTab === 'multimedia' ? 'border-b-2 border-[#38BDF8] text-[#38BDF8]' : 'text-[#94A3B8] hover:text-[#E5E7EB]'"
class="px-4 py-2 font-medium transition-colors">
Multimedia
</button>
</div>
</div>
<!-- Parameters Tab -->
<div x-show="executeModalTab === 'parameters'">
<label class="block text-sm font-medium text-[#E5E7EB] mb-2">Parameters</label>
<p class="text-xs text-[#94A3B8] mb-3">
Enter parameters as key-value pairs (one per line, format: key=value).
@@ -508,8 +607,61 @@ Provide a detailed response that addresses their specific needs.</pre>
Example: <code class="bg-[#101827] px-1 py-0.5 rounded text-[#38BDF8]">user_name=Alice</code>
</p>
</div>
<div class="flex justify-end space-x-4">
<button @click="showExecuteTaskModal = false; executionParameters = {}; executionParametersText = ''"
<!-- Multimedia Tab -->
<div x-show="executeModalTab === 'multimedia'" class="space-y-4">
<p class="text-xs text-[#94A3B8] mb-3">
Provide multimedia content as URLs or base64-encoded data URIs. You can also upload files which will be converted to base64.
</p>
<!-- Images -->
<div>
<label class="block text-sm font-medium text-[#E5E7EB] mb-2">Images</label>
<textarea x-model="executionMultimedia.images"
rows="3"
placeholder="https://example.com/image.png&#10;data:image/png;base64,iVBORw0KG..."
class="w-full bg-[#101827] border border-[#38BDF8]/20 rounded px-4 py-2 text-[#E5E7EB] font-mono text-sm focus:border-[#38BDF8] focus:ring-2 focus:ring-[#38BDF8]/50"></textarea>
<input type="file" @change="handleFileUpload($event, 'image')" accept="image/*" multiple
class="mt-2 text-sm text-[#94A3B8] file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-[#38BDF8] file:text-white hover:file:bg-[#38BDF8]/80">
</div>
<!-- Videos -->
<div>
<label class="block text-sm font-medium text-[#E5E7EB] mb-2">Videos</label>
<textarea x-model="executionMultimedia.videos"
rows="3"
placeholder="https://example.com/video.mp4&#10;data:video/mp4;base64,..."
class="w-full bg-[#101827] border border-[#38BDF8]/20 rounded px-4 py-2 text-[#E5E7EB] font-mono text-sm focus:border-[#38BDF8] focus:ring-2 focus:ring-[#38BDF8]/50"></textarea>
<input type="file" @change="handleFileUpload($event, 'video')" accept="video/*" multiple
class="mt-2 text-sm text-[#94A3B8] file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-[#38BDF8] file:text-white hover:file:bg-[#38BDF8]/80">
</div>
<!-- Audios -->
<div>
<label class="block text-sm font-medium text-[#E5E7EB] mb-2">Audios</label>
<textarea x-model="executionMultimedia.audios"
rows="3"
placeholder="https://example.com/audio.mp3&#10;data:audio/mpeg;base64,..."
class="w-full bg-[#101827] border border-[#38BDF8]/20 rounded px-4 py-2 text-[#E5E7EB] font-mono text-sm focus:border-[#38BDF8] focus:ring-2 focus:ring-[#38BDF8]/50"></textarea>
<input type="file" @change="handleFileUpload($event, 'audio')" accept="audio/*" multiple
class="mt-2 text-sm text-[#94A3B8] file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-[#38BDF8] file:text-white hover:file:bg-[#38BDF8]/80">
</div>
<!-- Files -->
<div>
<label class="block text-sm font-medium text-[#E5E7EB] mb-2">Files</label>
<textarea x-model="executionMultimedia.files"
rows="3"
placeholder="https://example.com/file.pdf&#10;data:application/pdf;base64,..."
class="w-full bg-[#101827] border border-[#38BDF8]/20 rounded px-4 py-2 text-[#E5E7EB] font-mono text-sm focus:border-[#38BDF8] focus:ring-2 focus:ring-[#38BDF8]/50"></textarea>
<input type="file" @change="handleFileUpload($event, 'file')" multiple
class="mt-2 text-sm text-[#94A3B8] file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-[#38BDF8] file:text-white hover:file:bg-[#38BDF8]/80">
</div>
</div>
</div>
<div class="flex justify-end space-x-4 p-8 pt-6 border-t border-[#38BDF8]/20 bg-[#1E293B]">
<button @click="showExecuteTaskModal = false; executionParameters = {}; executionParametersText = ''; executionMultimedia = {images: '', videos: '', audios: '', files: ''}; executeModalTab = 'parameters'"
class="px-4 py-2 bg-[#101827] hover:bg-[#0A0E1A] text-[#E5E7EB] rounded-lg transition-colors">
Cancel
</button>
@@ -533,6 +685,13 @@ Provide a detailed response that addresses their specific needs.</pre>
showExecuteTaskModal: false,
executionParameters: {},
executionParametersText: '',
executionMultimedia: {
images: '',
videos: '',
audios: '',
files: ''
},
executeModalTab: 'parameters',
isNewTask: false,
isEditMode: false,
taskForm: {
@@ -543,7 +702,8 @@ Provide a detailed response that addresses their specific needs.</pre>
enabled: true,
cron: '',
cron_parameters: {},
webhooks: []
webhooks: [],
multimedia_sources: []
},
cronError: '',
cronParametersText: '',
@@ -600,6 +760,15 @@ Provide a detailed response that addresses their specific needs.</pre>
// Note: Legacy fields (webhook_url, webhook_auth, webhook_template) should be migrated
// by the backend, so we don't need to handle them here
// Handle multimedia sources
let multimediaSources = [];
if (this.task.multimedia_sources && Array.isArray(this.task.multimedia_sources) && this.task.multimedia_sources.length > 0) {
multimediaSources = this.task.multimedia_sources.map(ms => ({
...ms,
headers_json: JSON.stringify(ms.headers || {}, null, 2)
}));
}
// Convert cron_parameters to text format
let cronParamsText = '';
if (this.task.cron_parameters && Object.keys(this.task.cron_parameters).length > 0) {
@@ -616,7 +785,8 @@ Provide a detailed response that addresses their specific needs.</pre>
enabled: this.task.enabled !== undefined ? this.task.enabled : true,
cron: this.task.cron || '',
cron_parameters: this.task.cron_parameters || {},
webhooks: webhooks
webhooks: webhooks,
multimedia_sources: multimediaSources
};
this.cronParametersText = cronParamsText;
} else {
@@ -667,6 +837,19 @@ Provide a detailed response that addresses their specific needs.</pre>
this.taskForm.webhooks.push(webhook);
},
addMultimediaSource() {
const source = {
type: '',
url: '',
headers: {},
headers_json: '{}'
};
if (!this.taskForm.multimedia_sources) {
this.taskForm.multimedia_sources = [];
}
this.taskForm.multimedia_sources.push(source);
},
updateCronParameters() {
// Parse text input into parameters object
const params = {};
@@ -791,6 +974,8 @@ Provide a detailed response that addresses their specific needs.</pre>
showExecuteModal() {
this.executionParameters = {};
this.executionParametersText = '';
this.executionMultimedia = {images: '', videos: '', audios: '', files: ''};
this.executeModalTab = 'parameters';
this.showExecuteTaskModal = true;
},
@@ -821,19 +1006,35 @@ Provide a detailed response that addresses their specific needs.</pre>
// Parse parameters from text
this.executionParameters = this.parseParameters(this.executionParametersText);
// Parse multimedia from text (split by newlines, filter empty)
const parseMultimedia = (text) => {
if (!text || !text.trim()) return [];
return text.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
};
const requestBody = {
task_id: this.task.id,
parameters: this.executionParameters,
images: parseMultimedia(this.executionMultimedia.images),
videos: parseMultimedia(this.executionMultimedia.videos),
audios: parseMultimedia(this.executionMultimedia.audios),
files: parseMultimedia(this.executionMultimedia.files)
};
try {
const response = await fetch('/api/agent/jobs/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
task_id: this.task.id,
parameters: this.executionParameters
})
body: JSON.stringify(requestBody)
});
if (response.ok) {
this.showExecuteTaskModal = false;
this.executionParameters = {};
this.executionParametersText = '';
this.executionMultimedia = {images: '', videos: '', audios: '', files: ''};
this.executeModalTab = 'parameters';
this.fetchJobs();
} else {
const error = await response.json();
@@ -844,6 +1045,34 @@ Provide a detailed response that addresses their specific needs.</pre>
alert('Failed to execute task: ' + error.message);
}
},
handleFileUpload(event, type) {
const files = event.target.files;
if (!files || files.length === 0) return;
const dataURIs = [];
let processed = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const reader = new FileReader();
reader.onload = (e) => {
const dataURI = e.target.result;
dataURIs.push(dataURI);
processed++;
if (processed === files.length) {
// Append to existing content
const current = this.executionMultimedia[type + 's'] || '';
const newContent = current ? current + '\n' + dataURIs.join('\n') : dataURIs.join('\n');
this.executionMultimedia[type + 's'] = newContent;
}
};
reader.readAsDataURL(file);
}
},
async deleteTask() {
if (!confirm('Are you sure you want to delete this task? This will also delete all associated jobs.')) return;

View File

@@ -75,13 +75,14 @@
Find Backend Components
</h3>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none">
<div class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none z-10">
<i class="fas fa-search text-[#94A3B8]"></i>
</div>
<input
x-model="searchTerm"
@input.debounce.500ms="fetchBackends()"
class="input w-full pl-12 pr-16 py-4"
class="input w-full pr-16 py-4"
style="padding-left: 3.5rem !important;"
type="search"
placeholder="Search backends by name, description or type...">
<span class="absolute right-4 top-4" x-show="loading">

View File

@@ -717,9 +717,10 @@ SOFTWARE.
type="text"
x-model="searchQuery"
placeholder="Search conversations..."
class="w-full bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] border border-[var(--color-bg-secondary)] focus:border-[var(--color-primary-border)] focus:ring-1 focus:ring-[var(--color-primary)]/50 rounded px-2 py-1.5 pl-7 text-xs placeholder-[var(--color-text-secondary)]"
class="w-full bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] border border-[var(--color-bg-secondary)] focus:border-[var(--color-primary-border)] focus:ring-1 focus:ring-[var(--color-primary)]/50 rounded py-1.5 pr-2 text-xs placeholder-[var(--color-text-secondary)]"
style="padding-left: 2rem !important;"
/>
<i class="fa-solid fa-search absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--color-text-secondary)] text-xs"></i>
<i class="fa-solid fa-search absolute left-2.5 top-1/2 transform -translate-y-1/2 text-[var(--color-text-secondary)] text-xs pointer-events-none z-10"></i>
<button
x-show="searchQuery.length > 0"
@click="searchQuery = ''"

View File

@@ -80,13 +80,14 @@
Find Your Perfect Model
</h3>
<div class="relative">
<div class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none">
<div class="absolute inset-y-0 start-0 flex items-center ps-4 pointer-events-none z-10">
<i class="fas fa-search text-[var(--color-text-secondary)]"></i>
</div>
<input
x-model="searchTerm"
@input.debounce.500ms="fetchModels()"
class="input w-full pl-12 pr-16 py-4"
class="input w-full pr-16 py-4"
style="padding-left: 3.5rem !important;"
type="search"
placeholder="Search models by name, tag, or description...">
<span class="absolute right-4 top-4" x-show="loading">

View File

@@ -56,7 +56,7 @@
<input id="image-model" type="hidden" value="{{.Model}}">
<form id="genimage" action="text2image/{{.Model}}" method="get" class="mb-8">
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-4">
<div class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<i class="fas fa-magic text-[var(--color-primary)]"></i>
</div>
<input
@@ -65,7 +65,8 @@
name="input"
placeholder="Describe the image you want to generate..."
autocomplete="off"
class="input w-full pl-12 pr-12 py-4 text-lg"
class="input w-full pr-12 py-4 text-lg"
style="padding-left: 3.5rem !important;"
required
/>
<span id="loader" class="my-2 loader absolute right-4 top-4 hidden">

View File

@@ -27,6 +27,10 @@ type Task struct {
// - {{.Status}} - Job status string
Webhooks []WebhookConfig `json:"webhooks,omitempty"` // Webhook configs for job completion notifications
// Multimedia sources (for cron jobs)
// URLs to fetch multimedia content from when cron job executes
// Each source can have custom headers for authentication/authorization
MultimediaSources []MultimediaSourceConfig `json:"multimedia_sources,omitempty"` // Multimedia sources for cron jobs
}
// WebhookConfig represents configuration for sending webhook notifications
@@ -44,6 +48,21 @@ type WebhookConfig struct {
// - {{.Status}} - Job status string
}
// MultimediaSourceConfig represents configuration for fetching multimedia content
// Used in cron jobs to periodically fetch multimedia from URLs with custom headers
type MultimediaSourceConfig struct {
Type string `json:"type"` // "image", "video", "audio", "file"
URL string `json:"url"` // URL to fetch from
Headers map[string]string `json:"headers,omitempty"` // Custom headers for HTTP request (e.g., Authorization)
}
type MultimediaAttachment struct {
Images []string `json:"images,omitempty"`
Videos []string `json:"videos,omitempty"`
Audios []string `json:"audios,omitempty"`
Files []string `json:"files,omitempty"`
}
// JobStatus represents the status of a job
type JobStatus string
@@ -75,6 +94,13 @@ type Job struct {
// Execution traces (reasoning, tool calls, tool results)
Traces []JobTrace `json:"traces,omitempty"`
// Multimedia content (for manual execution)
// Can contain URLs or base64-encoded data URIs
Images []string `json:"images,omitempty"` // List of image URLs or base64 strings
Videos []string `json:"videos,omitempty"` // List of video URLs or base64 strings
Audios []string `json:"audios,omitempty"` // List of audio URLs or base64 strings
Files []string `json:"files,omitempty"` // List of file URLs or base64 strings
}
// JobTrace represents a single execution trace entry
@@ -90,6 +116,12 @@ type JobTrace struct {
type JobExecutionRequest struct {
TaskID string `json:"task_id"` // Required
Parameters map[string]string `json:"parameters"` // Optional, for templating
// Multimedia content (optional, for manual execution)
// Can contain URLs or base64-encoded data URIs
Images []string `json:"images,omitempty"` // List of image URLs or base64 strings
Videos []string `json:"videos,omitempty"` // List of video URLs or base64 strings
Audios []string `json:"audios,omitempty"` // List of audio URLs or base64 strings
Files []string `json:"files,omitempty"` // List of file URLs or base64 strings
}
// JobExecutionResponse represents the response after creating a job

View File

@@ -3,6 +3,7 @@ package services
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -72,6 +73,13 @@ type JobExecution struct {
Cancel context.CancelFunc
}
const (
JobImageType = "image"
JobVideoType = "video"
JobAudioType = "audio"
JobFileType = "file"
)
// NewAgentJobService creates a new AgentJobService instance
func NewAgentJobService(
appConfig *config.ApplicationConfig,
@@ -366,7 +374,8 @@ func (s *AgentJobService) buildPrompt(templateStr string, params map[string]stri
}
// ExecuteJob creates and queues a job for execution
func (s *AgentJobService) ExecuteJob(taskID string, params map[string]string, triggeredBy string) (string, error) {
// multimedia can be nil for backward compatibility
func (s *AgentJobService) ExecuteJob(taskID string, params map[string]string, triggeredBy string, multimedia *schema.MultimediaAttachment) (string, error) {
task := s.tasks.Get(taskID)
if task.ID == "" {
return "", fmt.Errorf("task not found: %s", taskID)
@@ -388,6 +397,52 @@ func (s *AgentJobService) ExecuteJob(taskID string, params map[string]string, tr
TriggeredBy: triggeredBy,
}
// Handle multimedia: merge task-level (for cron) and job-level (for manual execution)
if triggeredBy == "cron" && len(task.MultimediaSources) > 0 {
// Fetch multimedia from task sources
job.Images = []string{}
job.Videos = []string{}
job.Audios = []string{}
job.Files = []string{}
for _, source := range task.MultimediaSources {
// Fetch content from URL with custom headers
dataURI, err := s.fetchMultimediaFromURL(source.URL, source.Headers, source.Type)
if err != nil {
log.Warn().Err(err).Str("url", source.URL).Str("type", source.Type).Msg("Failed to fetch multimedia from task source")
continue
}
// Add to appropriate slice based on type
switch source.Type {
case JobImageType:
job.Images = append(job.Images, dataURI)
case JobVideoType:
job.Videos = append(job.Videos, dataURI)
case JobAudioType:
job.Audios = append(job.Audios, dataURI)
case JobFileType:
job.Files = append(job.Files, dataURI)
}
}
}
// Override with job-level multimedia if provided (manual execution takes precedence)
if multimedia != nil {
if len(multimedia.Images) > 0 {
job.Images = multimedia.Images
}
if len(multimedia.Videos) > 0 {
job.Videos = multimedia.Videos
}
if len(multimedia.Audios) > 0 {
job.Audios = multimedia.Audios
}
if len(multimedia.Files) > 0 {
job.Files = multimedia.Files
}
}
// Store job
s.jobs.Set(jobID, job)
@@ -512,6 +567,108 @@ func (s *AgentJobService) DeleteJob(id string) error {
return nil
}
type multimediaContent struct {
url string
mediaType string
}
func (mu multimediaContent) URL() string {
return mu.url
}
// fetchMultimediaFromURL fetches multimedia content from a URL with custom headers
// and converts it to a data URI string
func (s *AgentJobService) fetchMultimediaFromURL(url string, headers map[string]string, mediaType string) (string, error) {
// Create HTTP request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// Set custom headers
for key, value := range headers {
req.Header.Set(key, value)
}
// Execute request
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("HTTP error: %d", resp.StatusCode)
}
// Read content
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
// Encode to base64
encoded := base64.StdEncoding.EncodeToString(data)
// Determine MIME type
mimeType := s.getMimeTypeForMediaType(mediaType)
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
mimeType = contentType
}
// Return as data URI
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil
}
// getMimeTypeForMediaType returns the default MIME type for a media type
func (s *AgentJobService) getMimeTypeForMediaType(mediaType string) string {
switch mediaType {
case JobImageType:
return "image/png"
case JobVideoType:
return "video/mp4"
case JobAudioType:
return "audio/mpeg"
case JobFileType:
return "application/octet-stream"
default:
return "application/octet-stream"
}
}
// convertToMultimediaContent converts a slice of strings (URLs or base64) to multimediaContent objects
func (s *AgentJobService) convertToMultimediaContent(items []string, mediaType string) ([]cogito.Multimedia, error) {
result := make([]cogito.Multimedia, 0, len(items))
for _, item := range items {
if item == "" {
continue
}
// Check if it's already a data URI
if strings.HasPrefix(item, "data:") {
result = append(result, multimediaContent{url: item, mediaType: mediaType})
continue
}
// Check if it's a URL
if strings.HasPrefix(item, "http://") || strings.HasPrefix(item, "https://") {
// Pass URL directly to cogito (it handles fetching)
result = append(result, multimediaContent{url: item, mediaType: mediaType})
continue
}
// Assume it's base64 without data URI prefix
// Add appropriate prefix based on media type
mimeType := s.getMimeTypeForMediaType(mediaType)
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, item)
result = append(result, multimediaContent{url: dataURI, mediaType: mediaType})
}
return result, nil
}
// executeJobInternal executes a job using cogito
func (s *AgentJobService) executeJobInternal(job schema.Job, task schema.Task, ctx context.Context) error {
// Update job status to running
@@ -585,7 +742,51 @@ func (s *AgentJobService) executeJobInternal(job schema.Job, task schema.Task, c
// Create cogito fragment
fragment := cogito.NewEmptyFragment()
fragment = fragment.AddMessage("user", prompt)
// Collect all multimedia content
multimediaItems := []cogito.Multimedia{}
// Convert images
if len(job.Images) > 0 {
images, err := s.convertToMultimediaContent(job.Images, JobImageType)
if err != nil {
log.Warn().Err(err).Str("job_id", job.ID).Msg("Failed to convert images")
} else {
multimediaItems = append(multimediaItems, images...)
}
}
// Convert videos
if len(job.Videos) > 0 {
videos, err := s.convertToMultimediaContent(job.Videos, JobVideoType)
if err != nil {
log.Warn().Err(err).Str("job_id", job.ID).Msg("Failed to convert videos")
} else {
multimediaItems = append(multimediaItems, videos...)
}
}
// Convert audios
if len(job.Audios) > 0 {
audios, err := s.convertToMultimediaContent(job.Audios, JobAudioType)
if err != nil {
log.Warn().Err(err).Str("job_id", job.ID).Msg("Failed to convert audios")
} else {
multimediaItems = append(multimediaItems, audios...)
}
}
// Convert files
if len(job.Files) > 0 {
files, err := s.convertToMultimediaContent(job.Files, JobFileType)
if err != nil {
log.Warn().Err(err).Str("job_id", job.ID).Msg("Failed to convert files")
} else {
multimediaItems = append(multimediaItems, files...)
}
}
fragment = fragment.AddMessage("user", prompt, multimediaItems...)
// Get API address and key
_, port, err := net.SplitHostPort(s.appConfig.APIAddress)
@@ -845,7 +1046,8 @@ func (s *AgentJobService) ScheduleCronTask(task schema.Task) error {
}
entryID, err := s.cronScheduler.AddFunc(cronExpr, func() {
// Create job for cron execution with configured parameters
_, err := s.ExecuteJob(task.ID, cronParams, "cron")
// Multimedia will be fetched from task sources in ExecuteJob
_, err := s.ExecuteJob(task.ID, cronParams, "cron", nil)
if err != nil {
log.Error().Err(err).Str("task_id", task.ID).Msg("Failed to execute cron job")
}

View File

@@ -153,7 +153,7 @@ var _ = Describe("AgentJobService", func() {
It("should create and queue a job", func() {
params := map[string]string{"name": "World"}
jobID, err := service.ExecuteJob(taskID, params, "test")
jobID, err := service.ExecuteJob(taskID, params, "test", nil)
Expect(err).NotTo(HaveOccurred())
Expect(jobID).NotTo(BeEmpty())
@@ -166,12 +166,12 @@ var _ = Describe("AgentJobService", func() {
It("should list jobs with filters", func() {
params := map[string]string{}
jobID1, err := service.ExecuteJob(taskID, params, "test")
jobID1, err := service.ExecuteJob(taskID, params, "test", nil)
Expect(err).NotTo(HaveOccurred())
time.Sleep(10 * time.Millisecond) // Ensure different timestamps
jobID2, err := service.ExecuteJob(taskID, params, "test")
jobID2, err := service.ExecuteJob(taskID, params, "test", nil)
Expect(err).NotTo(HaveOccurred())
allJobs := service.ListJobs(nil, nil, 0)
@@ -195,7 +195,7 @@ var _ = Describe("AgentJobService", func() {
It("should cancel a pending job", func() {
params := map[string]string{}
jobID, err := service.ExecuteJob(taskID, params, "test")
jobID, err := service.ExecuteJob(taskID, params, "test", nil)
Expect(err).NotTo(HaveOccurred())
err = service.CancelJob(jobID)
@@ -208,7 +208,7 @@ var _ = Describe("AgentJobService", func() {
It("should delete a job", func() {
params := map[string]string{}
jobID, err := service.ExecuteJob(taskID, params, "test")
jobID, err := service.ExecuteJob(taskID, params, "test", nil)
Expect(err).NotTo(HaveOccurred())
err = service.DeleteJob(jobID)
@@ -258,7 +258,7 @@ var _ = Describe("AgentJobService", func() {
Expect(err).NotTo(HaveOccurred())
params := map[string]string{}
jobID, err := service.ExecuteJob(taskID, params, "test")
jobID, err := service.ExecuteJob(taskID, params, "test", nil)
Expect(err).NotTo(HaveOccurred())
service.SaveJobsToFile()
@@ -311,7 +311,7 @@ var _ = Describe("AgentJobService", func() {
Expect(err).NotTo(HaveOccurred())
params := map[string]string{}
jobID, err := service.ExecuteJob(taskID, params, "test")
jobID, err := service.ExecuteJob(taskID, params, "test", nil)
Expect(err).NotTo(HaveOccurred())
// Manually set job creation time to be old
@@ -329,4 +329,270 @@ var _ = Describe("AgentJobService", func() {
Expect(err).NotTo(HaveOccurred())
})
})
Describe("Multimedia support", func() {
Describe("Task multimedia sources", func() {
It("should create a task with multimedia sources", func() {
task := schema.Task{
Name: "Multimedia Task",
Model: "test-model",
Prompt: "Analyze this image",
MultimediaSources: []schema.MultimediaSourceConfig{
{
Type: "image",
URL: "https://example.com/image.png",
Headers: map[string]string{"Authorization": "Bearer token123"},
},
{
Type: "video",
URL: "https://example.com/video.mp4",
},
},
}
id, err := service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
Expect(id).NotTo(BeEmpty())
retrieved, err := service.GetTask(id)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.MultimediaSources).To(HaveLen(2))
Expect(retrieved.MultimediaSources[0].Type).To(Equal("image"))
Expect(retrieved.MultimediaSources[0].URL).To(Equal("https://example.com/image.png"))
Expect(retrieved.MultimediaSources[0].Headers["Authorization"]).To(Equal("Bearer token123"))
Expect(retrieved.MultimediaSources[1].Type).To(Equal("video"))
})
It("should save and load tasks with multimedia sources from file", func() {
task := schema.Task{
Name: "Persistent Multimedia Task",
Model: "test-model",
Prompt: "Test prompt",
MultimediaSources: []schema.MultimediaSourceConfig{
{
Type: "audio",
URL: "https://example.com/audio.mp3",
Headers: map[string]string{
"X-Custom-Header": "value",
},
},
},
}
id, err := service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
// Create a new service instance to test loading
newService := services.NewAgentJobService(
appConfig,
modelLoader,
configLoader,
evaluator,
)
err = newService.LoadTasksFromFile()
Expect(err).NotTo(HaveOccurred())
retrieved, err := newService.GetTask(id)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.Name).To(Equal("Persistent Multimedia Task"))
Expect(retrieved.MultimediaSources).To(HaveLen(1))
Expect(retrieved.MultimediaSources[0].Type).To(Equal("audio"))
Expect(retrieved.MultimediaSources[0].URL).To(Equal("https://example.com/audio.mp3"))
Expect(retrieved.MultimediaSources[0].Headers["X-Custom-Header"]).To(Equal("value"))
})
})
Describe("Job multimedia", func() {
var taskID string
BeforeEach(func() {
task := schema.Task{
Name: "Test Task",
Model: "test-model",
Prompt: "Hello {{.name}}",
Enabled: true,
}
var err error
taskID, err = service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
})
It("should create a job with multimedia content", func() {
params := map[string]string{"name": "World"}
multimedia := &schema.MultimediaAttachment{
Images: []string{"https://example.com/image1.png", "data:image/png;base64,iVBORw0KG"},
Videos: []string{"https://example.com/video.mp4"},
Audios: []string{"data:audio/mpeg;base64,SUQzBAAAAA"},
Files: []string{"https://example.com/file.pdf"},
}
jobID, err := service.ExecuteJob(taskID, params, "test", multimedia)
Expect(err).NotTo(HaveOccurred())
Expect(jobID).NotTo(BeEmpty())
job, err := service.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
Expect(job.TaskID).To(Equal(taskID))
Expect(job.Images).To(HaveLen(2))
Expect(job.Images[0]).To(Equal("https://example.com/image1.png"))
Expect(job.Images[1]).To(Equal("data:image/png;base64,iVBORw0KG"))
Expect(job.Videos).To(HaveLen(1))
Expect(job.Videos[0]).To(Equal("https://example.com/video.mp4"))
Expect(job.Audios).To(HaveLen(1))
Expect(job.Audios[0]).To(Equal("data:audio/mpeg;base64,SUQzBAAAAA"))
Expect(job.Files).To(HaveLen(1))
Expect(job.Files[0]).To(Equal("https://example.com/file.pdf"))
})
It("should create a job with partial multimedia (only images)", func() {
params := map[string]string{}
multimedia := &schema.MultimediaAttachment{
Images: []string{"https://example.com/image.png"},
}
jobID, err := service.ExecuteJob(taskID, params, "test", multimedia)
Expect(err).NotTo(HaveOccurred())
job, err := service.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
Expect(job.Images).To(HaveLen(1))
Expect(job.Videos).To(BeEmpty())
Expect(job.Audios).To(BeEmpty())
Expect(job.Files).To(BeEmpty())
})
It("should create a job without multimedia (nil)", func() {
params := map[string]string{"name": "Test"}
jobID, err := service.ExecuteJob(taskID, params, "test", nil)
Expect(err).NotTo(HaveOccurred())
job, err := service.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
Expect(job.Images).To(BeEmpty())
Expect(job.Videos).To(BeEmpty())
Expect(job.Audios).To(BeEmpty())
Expect(job.Files).To(BeEmpty())
})
It("should save and load jobs with multimedia from file", func() {
params := map[string]string{}
multimedia := &schema.MultimediaAttachment{
Images: []string{"https://example.com/image.png"},
Videos: []string{"https://example.com/video.mp4"},
}
jobID, err := service.ExecuteJob(taskID, params, "test", multimedia)
Expect(err).NotTo(HaveOccurred())
// Wait a bit for async save to complete
time.Sleep(50 * time.Millisecond)
// Ensure directory exists before saving
err = os.MkdirAll(tempDir, 0755)
Expect(err).NotTo(HaveOccurred())
err = service.SaveJobsToFile()
Expect(err).NotTo(HaveOccurred())
// Create a new service instance to test loading
newService := services.NewAgentJobService(
appConfig,
modelLoader,
configLoader,
evaluator,
)
err = newService.LoadJobsFromFile()
Expect(err).NotTo(HaveOccurred())
retrieved, err := newService.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.TaskID).To(Equal(taskID))
Expect(retrieved.Images).To(HaveLen(1))
Expect(retrieved.Images[0]).To(Equal("https://example.com/image.png"))
Expect(retrieved.Videos).To(HaveLen(1))
Expect(retrieved.Videos[0]).To(Equal("https://example.com/video.mp4"))
})
})
Describe("Multimedia format handling", func() {
var taskID string
BeforeEach(func() {
task := schema.Task{
Name: "Test Task",
Model: "test-model",
Prompt: "Test prompt",
Enabled: true,
}
var err error
taskID, err = service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
})
It("should handle URLs correctly", func() {
multimedia := &schema.MultimediaAttachment{
Images: []string{"https://example.com/image.png"},
Videos: []string{"http://example.com/video.mp4"},
}
jobID, err := service.ExecuteJob(taskID, map[string]string{}, "test", multimedia)
Expect(err).NotTo(HaveOccurred())
job, err := service.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
Expect(job.Images[0]).To(Equal("https://example.com/image.png"))
Expect(job.Videos[0]).To(Equal("http://example.com/video.mp4"))
})
It("should handle data URIs correctly", func() {
multimedia := &schema.MultimediaAttachment{
Images: []string{"data:image/png;base64,iVBORw0KG"},
Videos: []string{"data:video/mp4;base64,AAAAIGZ0eXBpc29t"},
}
jobID, err := service.ExecuteJob(taskID, map[string]string{}, "test", multimedia)
Expect(err).NotTo(HaveOccurred())
job, err := service.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
Expect(job.Images[0]).To(Equal("data:image/png;base64,iVBORw0KG"))
Expect(job.Videos[0]).To(Equal("data:video/mp4;base64,AAAAIGZ0eXBpc29t"))
})
It("should handle base64 strings (will be converted during execution)", func() {
// Base64 strings without data URI prefix should be stored as-is
// They will be converted to data URIs during execution
multimedia := &schema.MultimediaAttachment{
Images: []string{"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="},
}
jobID, err := service.ExecuteJob(taskID, map[string]string{}, "test", multimedia)
Expect(err).NotTo(HaveOccurred())
job, err := service.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
// The base64 string is stored as-is in the job
Expect(job.Images[0]).To(Equal("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="))
})
It("should handle empty multimedia arrays", func() {
multimedia := &schema.MultimediaAttachment{
Images: []string{""},
}
jobID, err := service.ExecuteJob(taskID, map[string]string{}, "test", multimedia)
Expect(err).NotTo(HaveOccurred())
job, err := service.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
// Empty strings are stored in the job but will be filtered during execution
// The job stores what was provided, filtering happens in convertToMultimediaContent
Expect(job.Images).To(HaveLen(1))
Expect(job.Images[0]).To(Equal(""))
Expect(job.Videos).To(BeEmpty())
})
})
})
})