+
{
+ userToggled = true;
+ isExpanded = !isExpanded;
+ }
+ "
+ >
{{ store.getTranslation('chat.tool_execution.tool') }}: {{ name }}
@@ -27,7 +35,21 @@
+
+
{{
+ store.getTranslation('chat.tool_execution.generated_image') || 'Generated Image'
+ }}
+
+
![Generated image]()
+
+
+
+
{{ store.getTranslation('chat.tool_execution.output') }}
{{ formattedOutput }}
@@ -44,11 +66,20 @@
{{ store.getTranslation('chat.tool_execution.completed_in', {ms: durationMs}) }}
+
+
diff --git a/frontend/composables/useMarkdown.ts b/frontend/composables/useMarkdown.ts
index 7aa69a4..82f4e54 100644
--- a/frontend/composables/useMarkdown.ts
+++ b/frontend/composables/useMarkdown.ts
@@ -12,10 +12,23 @@ import {Marked} from 'marked';
import remend from 'remend';
function sanitize(html: string): string {
- return DOMPurify.sanitize(html, {
- ADD_ATTR: ['data-language', 'data-previewable', 'title'],
- ADD_TAGS: ['button'],
+ DOMPurify.addHook('afterSanitizeAttributes', node => {
+ if (node.tagName === 'A' && node.hasAttribute('href')) {
+ const href = node.getAttribute('href') || '';
+ if (href.startsWith('data:')) {
+ node.removeAttribute('href');
+ }
+ }
+ });
+
+ const result = DOMPurify.sanitize(html, {
+ ADD_ATTR: ['data-language', 'data-previewable', 'title', 'src', 'alt'],
+ ADD_TAGS: ['button', 'img'],
+ ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
});
+
+ DOMPurify.removeHook('afterSanitizeAttributes');
+ return result;
}
// Language aliases for normalization
diff --git a/frontend/pages/chats/[id].vue b/frontend/pages/chats/[id].vue
index e52d2c3..030a62e 100644
--- a/frontend/pages/chats/[id].vue
+++ b/frontend/pages/chats/[id].vue
@@ -14,7 +14,7 @@ import {useRoute} from '#app';
const chatStore = useChatStore();
const route = useRoute();
-async function handleSendMessage(content: string) {
+async function handleSendMessage(content: string, parts?: any[]) {
let chatId = chatStore.activeChat?.id;
if (!chatId) {
const chat = await chatStore.createChat({
@@ -23,7 +23,7 @@ async function handleSendMessage(content: string) {
if (!chat) return;
chatId = chat.id;
}
- await chatStore.sendAndStream(chatId, content);
+ await chatStore.sendAndStream(chatId, content, parts);
}
if (!chatStore.activeChat || chatStore.activeChat.id !== route.params.id) {
diff --git a/frontend/pages/settings/tools.vue b/frontend/pages/settings/tools.vue
index 71e95ff..9ace36f 100644
--- a/frontend/pages/settings/tools.vue
+++ b/frontend/pages/settings/tools.vue
@@ -457,6 +457,69 @@ const builtinToolTemplates = [
},
},
},
+ {
+ name: 'imagegen',
+ display_name: 'Image Generation',
+ description: 'Generate and edit images using OpenAI, Replicate, or Google APIs',
+ source_kind: 'BUILTIN',
+ icon: Sparkles,
+ source_config: {builtin_id: 'imagegen'},
+ functions: [
+ {
+ name: 'generate',
+ description: 'Generate an image from a text prompt',
+ input_schema: {
+ type: 'object',
+ properties: {
+ prompt: {type: 'string', description: 'The text prompt describing the image to generate'},
+ size: {
+ type: 'string',
+ description: 'Image size',
+ enum: ['1024x1024', '1792x1024', '1024x1792', '512x512', '256x256'],
+ default: '1024x1024',
+ },
+ quality: {
+ type: 'string',
+ description: 'Image quality',
+ enum: ['standard', 'hd'],
+ default: 'standard',
+ },
+ },
+ required: ['prompt'],
+ },
+ },
+ {
+ name: 'edit',
+ description: 'Edit an existing image using a text prompt',
+ input_schema: {
+ type: 'object',
+ properties: {
+ image_url: {type: 'string', description: 'URL of the image to edit'},
+ prompt: {type: 'string', description: 'The text prompt describing the desired edit'},
+ },
+ required: ['image_url', 'prompt'],
+ },
+ },
+ ],
+ settings_schema: {
+ type: 'object',
+ required: ['api_key', 'provider'],
+ properties: {
+ api_key: {type: 'string', title: 'API Key', secret: true, description: 'API key for the selected provider'},
+ provider: {
+ type: 'string',
+ title: 'Provider',
+ enum: ['openai', 'replicate', 'google'],
+ description: 'Image generation provider to use',
+ },
+ model: {
+ type: 'string',
+ title: 'Model',
+ description: 'Model to use (optional, defaults: dall-e-3, flux-schnell, imagen-3)',
+ },
+ },
+ },
+ },
];
const displayTools = computed(() => {
@@ -717,7 +780,8 @@ async function saveTool() {
body_template: httpConfig.body_template || null,
};
} else if (toolForm.source_kind === 'BUILTIN') {
- source_config = {builtin_id: builtinConfig.builtin_id};
+ const builtin = displayTools.value.find(t => t.name === toolForm.name);
+ source_config = {builtin_id: builtin?.source_config?.builtin_id || builtin?.name || toolForm.name};
} else if (toolForm.source_kind === 'WASM') {
source_config = {
wasm_blob_id: wasmConfig.blob_id,
diff --git a/frontend/stores/chatStore.ts b/frontend/stores/chatStore.ts
index 32eff6a..2901506 100644
--- a/frontend/stores/chatStore.ts
+++ b/frontend/stores/chatStore.ts
@@ -328,7 +328,7 @@ export const useChatStore = defineStore('chat', {
}
},
- async sendAndStream(chatId: string, content: string): Promise
{
+ async sendAndStream(chatId: string, content: string, parts?: any[]): Promise {
if (!this.selectedModel) {
console.error('No model selected');
return;
@@ -395,19 +395,25 @@ export const useChatStore = defineStore('chat', {
const config = useRuntimeConfig();
const baseUrl = config.public.apiBase || '';
+ const body: any = {
+ content,
+ model_key: this.selectedModel.model_id,
+ reasoning_effort: this.reasoningEffort || undefined,
+ reasoning_budget_tokens: this.reasoningBudget || undefined,
+ tools_enabled: this.enabledTools.length > 0 ? this.enabledTools : undefined,
+ };
+
+ if (parts && parts.length > 0) {
+ body.parts = parts;
+ }
+
const response = await fetch(`${baseUrl}/api/v1/chats/${chatId}/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
- body: JSON.stringify({
- content,
- model_key: this.selectedModel.model_id,
- reasoning_effort: this.reasoningEffort || undefined,
- reasoning_budget_tokens: this.reasoningBudget || undefined,
- tools_enabled: this.enabledTools.length > 0 ? this.enabledTools : undefined,
- }),
+ body: JSON.stringify(body),
});
if (!response.ok) {
@@ -433,7 +439,7 @@ export const useChatStore = defineStore('chat', {
for (const line of lines) {
if (line.startsWith('data: ')) {
- const jsonStr = line.slice(6);
+ const jsonStr = line.slice(6).trim();
if (!jsonStr) continue;
try {
@@ -474,7 +480,15 @@ export const useChatStore = defineStore('chat', {
const toolCall = msg?.tool_calls?.find(tc => tc.tool_call_id === data.id);
if (toolCall && typeof toolCall.input_args === 'string') {
toolCall.input_args += data.args_delta;
+ } else {
+ const newToolCall = {
+ tool_call_id: data.id,
+ tool_name: data.name,
+ input_args: data.args_delta,
+ };
+ if (msg) msg.tool_calls.push(newToolCall as any);
}
+
break;
}
case 'tool_call_end': {
diff --git a/frontend/stores/icons.ts b/frontend/stores/icons.ts
index 0da4164..15292cd 100644
--- a/frontend/stores/icons.ts
+++ b/frontend/stores/icons.ts
@@ -88,7 +88,6 @@ export const useIconsStore = defineStore('icons', {
const provider = state.providersMeta[state.providerLookup[normalized]];
return {icon: provider.icon, type: provider.type as 'svg' | 'png'};
}
- console.log(normalized);
for (const [key, meta] of Object.entries(state.providersMeta)) {
if (
meta.variants.some(variant => {
diff --git a/frontend/types/chat.ts b/frontend/types/chat.ts
index fc45b56..b63f2de 100644
--- a/frontend/types/chat.ts
+++ b/frontend/types/chat.ts
@@ -47,6 +47,7 @@ export interface ChatMessage {
content: string;
reasoning_content: string | null;
model_id: string | null;
+ content_parts?: Array<{type: string; text?: string; image_id?: string}> | null;
cost_details: {
input: string | null;
output: string | null;
diff --git a/migrations/20251226000000_initial_schema.sql b/migrations/20251226000000_initial_schema.sql
index e383236..1848a13 100644
--- a/migrations/20251226000000_initial_schema.sql
+++ b/migrations/20251226000000_initial_schema.sql
@@ -230,7 +230,7 @@ CREATE TABLE IF NOT EXISTS model_access (
);
-- Workspaces (linked to users)
-CREATE TABLE workspaces (
+CREATE TABLE IF NOT EXISTS workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
@@ -244,7 +244,7 @@ CREATE TABLE workspaces (
);
-- Chats (linked to workspaces)
-CREATE TABLE chats (
+CREATE TABLE IF NOT EXISTS chats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL,
@@ -256,7 +256,7 @@ CREATE TABLE chats (
);
-- Messages
-CREATE TABLE messages (
+CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
@@ -265,13 +265,14 @@ CREATE TABLE messages (
model_id UUID REFERENCES models(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
+ content_parts JSONB DEFAULT '[]',
cost_details JSONB DEFAULT '{}',
usage_details JSONB DEFAULT '{}',
reasoning_details JSONB DEFAULT '{}'
);
-- User preferences (streaming animation, default model, etc.)
-CREATE TABLE user_preferences (
+CREATE TABLE IF NOT EXISTS user_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
default_model_key VARCHAR(255),
favorite_model_keys JSONB DEFAULT '[]',
@@ -283,6 +284,16 @@ CREATE TABLE user_preferences (
updated_at TIMESTAMPTZ DEFAULT NOW()
);
+CREATE TABLE IF NOT EXISTS images (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ data BYTEA, -- NULL if using file storage
+ file_path VARCHAR(500), -- Path relative to storage root (for file storage)
+ mime_type VARCHAR(64) NOT NULL DEFAULT 'image/png',
+ size_bytes BIGINT NOT NULL,
+ source VARCHAR(50), -- 'imagegen', 'upload', etc.
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
INSERT INTO roles (name) VALUES ('admin'), ('user') ON CONFLICT DO NOTHING;
INSERT INTO permissions (name, description) VALUES
@@ -481,6 +492,9 @@ CREATE INDEX IF NOT EXISTS idx_chats_updated ON chats(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_chats_pinned ON chats(user_id, is_pinned) WHERE is_pinned = true;
CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_id);
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at);
+CREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);
+CREATE INDEX IF NOT EXISTS idx_messages_content_parts_gin ON messages USING GIN (content_parts);
-- Tool indexes
CREATE INDEX IF NOT EXISTS idx_wasm_blobs_owner ON wasm_blobs(owner_id);
@@ -1121,6 +1135,18 @@ INSERT INTO i18n_translations (language, key_path, value) VALUES
('de', 'chat.tool_execution.error', 'Fehler'),
('de', 'chat.tool_execution.completed_in', 'Abgeschlossen in {ms}ms'),
+ -- Image Generation
+ ('en', 'chat.image_preview.download', 'Download'),
+ ('en', 'chat.image_preview.copy', 'Copy URL'),
+ ('en', 'chat.image_preview.copied', 'Copied!'),
+ ('en', 'chat.tool_execution.generated_image', 'Generated Image'),
+
+ ('de', 'chat.image_preview.download', 'Herunterladen'),
+ ('de', 'chat.image_preview.copy', 'URL kopieren'),
+ ('de', 'chat.image_preview.copied', 'Kopiert!'),
+ ('de', 'chat.tool_execution.generated_image', 'Generiertes Bild');
+
+
-- Schema Builder
('en', 'settings.schema_builder.type', 'Type'),
('en', 'settings.schema_builder.default', 'Default'),
diff --git a/src/routes/admin/tools.rs b/src/routes/admin/tools.rs
index 09602f8..358274f 100644
--- a/src/routes/admin/tools.rs
+++ b/src/routes/admin/tools.rs
@@ -535,6 +535,7 @@ pub async fn test_tool(State(state): State>, cookies: Cookies, Pat
settings: tool_settings.map(|s| s.settings).unwrap_or_default(),
timeout_ms: Some(30000),
function_name: req.function_name.clone(),
+ db: Some(std::sync::Arc::new(state.db.clone())),
};
let start = std::time::Instant::now();
diff --git a/src/routes/mod.rs b/src/routes/mod.rs
index 9149bb3..aed5200 100644
--- a/src/routes/mod.rs
+++ b/src/routes/mod.rs
@@ -73,5 +73,8 @@ pub fn build_router() -> Router> {
.route("/api/v1/models", get(public::models::list_models))
// Tools
.route("/api/v1/tools", get(public::tools::list_tools))
+ // Images CDN (public, no auth)
+ .route("/api/v1/images/{id}", get(public::images::serve_image))
+ .route("/api/v1/images", post(public::images::upload_image))
.layer(CookieManagerLayer::new())
}
diff --git a/src/routes/public/images.rs b/src/routes/public/images.rs
new file mode 100644
index 0000000..a43f1cb
--- /dev/null
+++ b/src/routes/public/images.rs
@@ -0,0 +1,76 @@
+//! Image CDN routes for serving and uploading images.
+//!
+//! Provides public endpoints for image storage:
+//! - GET /api/v1/images/:id - Serve an image by UUID
+//! - POST /api/v1/images - Upload a base64 image (internal use)
+
+use crate::AppState;
+use crate::utils::images::{get_image, image_url, store_from_data_uri};
+use axum::{
+ Json,
+ extract::{Path, State},
+ http::{HeaderMap, StatusCode, header},
+ response::{IntoResponse, Response},
+};
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use uuid::Uuid;
+
+/// Request body for uploading an image
+#[derive(Debug, Deserialize)]
+pub struct UploadImageRequest {
+ /// Base64 data URI (e.g., "data:image/png;base64,...")
+ pub data_uri: String,
+ /// Optional user ID for attribution
+ pub user_id: Option,
+ /// Optional source identifier (e.g., "imagegen")
+ pub source: Option,
+}
+
+/// Response for successful image upload
+#[derive(Debug, Serialize)]
+pub struct UploadImageResponse {
+ pub id: Uuid,
+ pub url: String,
+ pub mime_type: String,
+ pub size_bytes: i64,
+}
+
+/// Upload a base64 image and return its URL
+///
+/// POST /api/images
+pub async fn upload_image(State(state): State>, Json(req): Json) -> Result, (StatusCode, String)> {
+ let stored = store_from_data_uri(&state.db, &req.data_uri, req.user_id, req.source.as_deref())
+ .await
+ .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
+
+ Ok(Json(UploadImageResponse {
+ id: stored.id,
+ url: image_url(stored.id),
+ mime_type: stored.mime_type,
+ size_bytes: stored.size_bytes,
+ }))
+}
+
+/// Serve an image by ID
+///
+/// GET /api/v1/images/:id
+pub async fn serve_image(State(state): State>, Path(id): Path) -> Response {
+ match get_image(&state.db, id).await {
+ Ok(Some((data, mime_type))) => {
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ header::CONTENT_TYPE,
+ mime_type.parse().unwrap_or(header::HeaderValue::from_static("application/octet-stream")),
+ );
+ headers.insert(header::CACHE_CONTROL, header::HeaderValue::from_static("public, max-age=31536000, immutable"));
+
+ (StatusCode::OK, headers, data).into_response()
+ }
+ Ok(None) => (StatusCode::NOT_FOUND, "Image not found").into_response(),
+ Err(e) => {
+ eprintln!("[IMAGES] Failed to retrieve image {id}: {e}");
+ (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
+ }
+ }
+}
diff --git a/src/routes/public/mod.rs b/src/routes/public/mod.rs
index 6b520b9..3ceea0b 100644
--- a/src/routes/public/mod.rs
+++ b/src/routes/public/mod.rs
@@ -1,6 +1,7 @@
pub mod auth;
pub mod base;
pub mod chats;
+pub mod images;
pub mod messages;
pub mod models;
pub mod oauth;
diff --git a/src/routes/public/streaming.rs b/src/routes/public/streaming.rs
index bf35fea..bdd9e3b 100644
--- a/src/routes/public/streaming.rs
+++ b/src/routes/public/streaming.rs
@@ -27,10 +27,20 @@ use std::{collections::HashMap, convert::Infallible, sync::Arc, time::Instant};
use tower_cookies::Cookies;
use uuid::Uuid;
+/// Structured message part (text or image)
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum MessagePart {
+ Text { text: String },
+ Image { image_id: String },
+}
+
/// Request body for sending a message and streaming AI response
#[derive(Debug, Deserialize)]
pub struct StreamRequest {
pub content: String,
+ #[serde(default)]
+ pub parts: Option>,
pub model_key: String,
pub reasoning_effort: Option,
pub reasoning_budget_tokens: Option,
@@ -152,7 +162,7 @@ fn merge_reasoning_with_priority(
async fn execute_tool_by_name(db: &sqlx::PgPool, user_id: Uuid, full_tool_name: &str, input: serde_json::Value) -> Result {
use crate::types::ToolSourceKind;
- let mut tool: Option = None;
+ let mut tool: Option;
let mut function_name: Option = None;
let mut function_id: Option = None;
@@ -220,6 +230,7 @@ async fn execute_tool_by_name(db: &sqlx::PgPool, user_id: Uuid, full_tool_name:
settings,
timeout_ms: Some(30000),
function_name: function_name.map(|s| s.to_string()),
+ db: Some(std::sync::Arc::new(db.clone())),
};
match tool.source_kind {
@@ -298,8 +309,6 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
}
};
- eprintln!("[STREAM] Chat verified");
-
let model = sqlx::query_as::<_, crate::types::AiModel>("SELECT * FROM models WHERE model_id = $1")
.bind(&req.model_key)
.fetch_optional(&state.db)
@@ -314,8 +323,6 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
}
};
- eprintln!("[STREAM] Model verified");
-
let model_config = sqlx::query_as::<_, ModelConfig>("SELECT * FROM model_configs WHERE owner_id = $1 AND stable_key = $2")
.bind(user.id)
.bind(&req.model_key)
@@ -324,8 +331,6 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
.ok()
.flatten();
- eprintln!("[STREAM] Model config: {:?}", model_config.as_ref().map(|mc| &mc.name));
-
let reasoning_details = crate::types::ReasoningDetails {
effort: req.reasoning_effort.clone(),
budget_tokens: req.reasoning_budget_tokens.map(|b| b as i32),
@@ -333,15 +338,18 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
let usage_details = crate::types::UsageDetails::default();
let cost_details = crate::types::CostDetails::default();
+ let content_parts_json = req.parts.as_ref().map(|parts| serde_json::to_value(parts).ok()).flatten();
+
let user_message = sqlx::query_as::<_, Message>(
r#"
- INSERT INTO messages (chat_id, role, content, model_id, reasoning_details, usage_details, cost_details)
- VALUES ($1, 'user', $2, $3, $4, $5, $6)
+ INSERT INTO messages (chat_id, role, content, content_parts, model_id, reasoning_details, usage_details, cost_details)
+ VALUES ($1, 'user', $2, $3, $4, $5, $6, $7)
RETURNING *
"#,
)
.bind(chat_id)
.bind(&req.content)
+ .bind(content_parts_json)
.bind(model.id)
.bind(sqlx::types::Json(reasoning_details))
.bind(sqlx::types::Json(usage_details))
@@ -357,8 +365,6 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
}
};
- eprintln!("[STREAM] User message saved");
-
let messages = sqlx::query_as::<_, Message>("SELECT * FROM messages WHERE chat_id = $1 ORDER BY created_at ASC")
.bind(chat_id)
.fetch_all(&state.db)
@@ -372,8 +378,6 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
}
};
- eprintln!("[STREAM] Messages fetched");
-
let engine = ai::get();
let engine_read = engine.read().await;
@@ -386,8 +390,6 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
}
};
- eprintln!("[STREAM] Model verified");
-
let provider = match engine_read.get_provider(&omni_model.provider_name).await {
Some(p) => p,
None => {
@@ -397,17 +399,45 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
}
};
- eprintln!("[STREAM] Provider verified");
+ let omni_messages: Vec = {
+ let mut result = Vec::new();
+ for m in messages.iter().filter(|m| m.role == "user" || m.role == "assistant") {
+ let parts = if let Some(content_parts_json) = &m.content_parts {
+ if let Ok(stored_parts) = serde_json::from_value::>(content_parts_json.clone()) {
+ let mut omni_parts = Vec::new();
+ for part in stored_parts {
+ match part {
+ MessagePart::Text { text } => {
+ omni_parts.push(ContentPart::Text(text));
+ }
+ MessagePart::Image { image_id } => {
+ if let Ok(uuid) = uuid::Uuid::parse_str(&image_id) {
+ if let Ok(Some((data, mime))) = crate::utils::images::get_image(&state.db, uuid).await {
+ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
+ let b64 = BASE64.encode(&data);
+ let data_uri = format!("data:{};base64,{}", mime, b64);
+ omni_parts.push(ContentPart::ImageUrl { url: data_uri, mime: Some(mime) });
+ }
+ }
+ }
+ }
+ }
+ omni_parts
+ } else {
+ vec![ContentPart::Text(m.content.clone())]
+ }
+ } else {
+ vec![ContentPart::Text(m.content.clone())]
+ };
- let omni_messages: Vec = messages
- .iter()
- .filter(|m| m.role == "user" || m.role == "assistant")
- .map(|m| OmniMessage {
- role: if m.role == "user" { Role::User } else { Role::Assistant },
- parts: vec![ContentPart::Text(m.content.clone())],
- name: None,
- })
- .collect();
+ result.push(OmniMessage {
+ role: if m.role == "user" { Role::User } else { Role::Assistant },
+ parts,
+ name: None,
+ });
+ }
+ result
+ };
eprintln!("[STREAM] Messages built");
let mut ir = ChatRequestIR::default();
@@ -439,8 +469,6 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
.fetch_all(&state.db)
.await;
- eprintln!("[STREAM] Tools query result: {:?}", tools.as_ref().map(|t| t.len()).map_err(|e| e.to_string()));
-
if let Ok(tools) = tools {
eprintln!("[STREAM] Found {} tools from DB", tools.len());
let tool_ids: Vec = tools.iter().map(|t| t.id).collect();
@@ -475,8 +503,6 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
}
}
- eprintln!("[STREAM] Chat request: {:?}", ir);
-
let omni_messages_for_stream = ir.messages.clone();
let ir_for_stream = ir.clone();
@@ -560,7 +586,6 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
let mut event_count = 0;
while let Some(event) = upstream.next().await {
event_count += 1;
- eprintln!("[STREAM] Event #{}: {:?}", event_count, &event);
match event {
StreamEvent::TextDelta { content } => {
if let Some(start) = reasoning_start.take() {
@@ -774,7 +799,7 @@ pub async fn stream_completion(State(state): State>, cookies: Cook
current_messages.push(OmniMessage {
role: Role::Tool,
parts: vec![ContentPart::Text(result_text)],
- name: Some(format!("{}:{}", tool_name, call_id)),
+ name: Some(call_id.clone()),
});
all_tool_executions.push((call_id, tool_name, args, output, error, exec_ms, tool_id, function_id));
diff --git a/src/types/chat.rs b/src/types/chat.rs
index bb52c47..0d7e414 100644
--- a/src/types/chat.rs
+++ b/src/types/chat.rs
@@ -172,6 +172,7 @@ pub struct Message {
pub chat_id: Uuid,
pub role: String,
pub content: String,
+ pub content_parts: Option,
pub reasoning_content: Option,
pub model_id: Option,
pub cost_details: sqlx::types::Json,
@@ -345,6 +346,8 @@ pub struct ChatMessageResponse {
pub content: String,
pub reasoning_content: Option,
pub model_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub content_parts: Option,
pub cost_details: CostDetails,
pub usage_details: UsageDetails,
pub reasoning_details: ReasoningDetails,
@@ -361,6 +364,7 @@ impl From for ChatMessageResponse {
content: m.content,
reasoning_content: m.reasoning_content,
model_id: m.model_id,
+ content_parts: m.content_parts,
cost_details: m.cost_details.0,
usage_details: m.usage_details.0,
reasoning_details: m.reasoning_details.0,
diff --git a/src/utils/images.rs b/src/utils/images.rs
new file mode 100644
index 0000000..610a1a2
--- /dev/null
+++ b/src/utils/images.rs
@@ -0,0 +1,210 @@
+//! Image Storage Module
+//!
+//! Provides configurable storage backends for images (database or filesystem).
+//! Set `IMAGE_STORAGE_TYPE` env var to "database" or "file" (default: "database").
+//! For file storage, set `IMAGE_STORAGE_PATH` (default: "./uploads/images").
+
+use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
+use sqlx::PgPool;
+use std::path::PathBuf;
+use tokio::fs;
+use uuid::Uuid;
+
+/// Storage type for images
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum StorageType {
+ Database,
+ File,
+}
+
+impl StorageType {
+ pub fn from_env() -> Self {
+ match std::env::var("IMAGE_STORAGE_TYPE").as_deref() {
+ Ok("file") | Ok("filesystem") => StorageType::File,
+ _ => StorageType::Database,
+ }
+ }
+}
+
+/// Get the base path for file storage
+pub fn storage_path() -> PathBuf {
+ std::env::var("IMAGE_STORAGE_PATH")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from("./uploads/images"))
+}
+
+/// Generate a URL path for an image (relative, works behind any reverse proxy)
+pub fn image_url(id: Uuid) -> String {
+ format!("/api/v1/images/{}", id)
+}
+/// Stored image metadata
+#[derive(Debug, Clone)]
+pub struct StoredImage {
+ pub id: Uuid,
+ pub mime_type: String,
+ pub size_bytes: i64,
+}
+
+/// Store an image from base64 data
+///
+/// Returns the stored image metadata including the generated UUID.
+pub async fn store_image(db: &PgPool, data: &[u8], mime_type: &str, user_id: Option, source: Option<&str>) -> Result {
+ let storage_type = StorageType::from_env();
+ let id = Uuid::new_v4();
+ let size_bytes = i64::try_from(data.len()).map_err(|_| "Image size too large to represent in 64-bit".to_string())?;
+
+ match storage_type {
+ StorageType::Database => {
+ sqlx::query(
+ r#"
+ INSERT INTO images (id, data, mime_type, size_bytes, user_id, source)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ "#,
+ )
+ .bind(id)
+ .bind(data)
+ .bind(mime_type)
+ .bind(size_bytes)
+ .bind(user_id)
+ .bind(source)
+ .execute(db)
+ .await
+ .map_err(|e| format!("Failed to store image in database: {e}"))?;
+ }
+ StorageType::File => {
+ let base_path = storage_path();
+ fs::create_dir_all(&base_path).await.map_err(|e| format!("Failed to create storage directory: {e}"))?;
+
+ let extension = mime_to_extension(mime_type);
+ let filename = format!("{id}.{extension}");
+ let file_path = base_path.join(&filename);
+
+ fs::write(&file_path, data).await.map_err(|e| format!("Failed to write image file: {e}"))?;
+
+ let relative_path = filename;
+
+ let db_result = sqlx::query(
+ r#"
+ INSERT INTO images (id, file_path, mime_type, size_bytes, user_id, source)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ "#,
+ )
+ .bind(id)
+ .bind(&relative_path)
+ .bind(mime_type)
+ .bind(size_bytes)
+ .bind(user_id)
+ .bind(source)
+ .execute(db)
+ .await;
+
+ if let Err(e) = db_result {
+ let _ = fs::remove_file(&file_path).await;
+ return Err(format!("Failed to store image metadata: {e}"));
+ }
+ }
+ }
+
+ Ok(StoredImage {
+ id,
+ mime_type: mime_type.to_string(),
+ size_bytes,
+ })
+}
+
+/// Store an image from a base64 data URI (e.g., "data:image/png;base64,...")
+pub async fn store_from_data_uri(db: &PgPool, data_uri: &str, user_id: Option, source: Option<&str>) -> Result {
+ let (mime_type, data) = parse_data_uri(data_uri)?;
+ store_image(db, &data, &mime_type, user_id, source).await
+}
+
+/// Retrieve an image by ID
+///
+/// Returns (data, mime_type) or None if not found.
+pub async fn get_image(db: &PgPool, id: Uuid) -> Result