forked from boos/BoosAPI
feat: add workspace and claude messages support
This commit is contained in:
@@ -11,3 +11,4 @@ pub mod oauth;
|
||||
pub mod route_rebuild;
|
||||
pub mod shared;
|
||||
pub mod update_center;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
use crate::{error::AppError, AppState};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use chrono::Utc;
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkspaceAssetInput {
|
||||
pub user_id: Option<i64>,
|
||||
pub source: String,
|
||||
pub kind: String,
|
||||
pub title: Option<String>,
|
||||
pub prompt: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub remote_url: Option<String>,
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredWorkspaceAsset {
|
||||
pub public_id: String,
|
||||
pub file_path: Option<String>,
|
||||
pub remote_url: Option<String>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
std::env::var("BOOSAPI_WORKSPACE_DATA_ROOT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/data/boosapi-workspace"))
|
||||
}
|
||||
|
||||
fn safe_segment(value: &str) -> String {
|
||||
let mut out = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
while out.contains("--") {
|
||||
out = out.replace("--", "-");
|
||||
}
|
||||
let trimmed = out.trim_matches('-').to_string();
|
||||
if trimmed.is_empty() {
|
||||
"asset".to_string()
|
||||
} else {
|
||||
trimmed.chars().take(48).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn extension_for_mime(mime: &str) -> &'static str {
|
||||
let mime = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
|
||||
match mime.as_str() {
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/svg+xml" => "svg",
|
||||
"video/mp4" => "mp4",
|
||||
"video/webm" => "webm",
|
||||
"audio/mpeg" | "audio/mp3" => "mp3",
|
||||
"audio/wav" => "wav",
|
||||
"application/json" => "json",
|
||||
_ if mime.starts_with("image/") => "png",
|
||||
_ if mime.starts_with("video/") => "mp4",
|
||||
_ if mime.starts_with("audio/") => "mp3",
|
||||
_ => "bin",
|
||||
}
|
||||
}
|
||||
|
||||
fn extension_from_url(url: &str) -> Option<String> {
|
||||
let without_query = url.split(['?', '#']).next().unwrap_or(url);
|
||||
let ext = Path::new(without_query)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())?
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if ext.len() <= 8 && ext.chars().all(|ch| ch.is_ascii_alphanumeric()) {
|
||||
Some(ext)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_asset_file(
|
||||
user_id: Option<i64>,
|
||||
source: &str,
|
||||
public_id: &str,
|
||||
ext: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<PathBuf, AppError> {
|
||||
let now = Utc::now();
|
||||
let user_segment = user_id
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "admin".to_string());
|
||||
let dir = workspace_root()
|
||||
.join(user_segment)
|
||||
.join(safe_segment(source))
|
||||
.join(now.format("%Y-%m").to_string());
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!("create workspace dir: {err}")))?;
|
||||
let path = dir.join(format!("{public_id}.{ext}"));
|
||||
let mut file = tokio::fs::File::create(&path)
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!("create workspace asset: {err}")))?;
|
||||
file.write_all(bytes)
|
||||
.await
|
||||
.map_err(|err| AppError::Internal(anyhow::anyhow!("write workspace asset: {err}")))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
async fn insert_asset(
|
||||
state: &AppState,
|
||||
input: &WorkspaceAssetInput,
|
||||
public_id: &str,
|
||||
status: &str,
|
||||
mime_type: Option<&str>,
|
||||
file_path: Option<&Path>,
|
||||
size_bytes: i64,
|
||||
metadata: Value,
|
||||
) -> Result<StoredWorkspaceAsset, AppError> {
|
||||
let file_name = file_path
|
||||
.and_then(|path| path.file_name())
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::to_string);
|
||||
let file_path_string = file_path.map(|path| path.to_string_lossy().to_string());
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_assets
|
||||
(public_id, user_id, source, kind, title, prompt, model, status,
|
||||
mime_type, file_name, file_path, remote_url, size_bytes, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(public_id)
|
||||
.bind(input.user_id)
|
||||
.bind(&input.source)
|
||||
.bind(&input.kind)
|
||||
.bind(&input.title)
|
||||
.bind(&input.prompt)
|
||||
.bind(&input.model)
|
||||
.bind(status)
|
||||
.bind(mime_type)
|
||||
.bind(&file_name)
|
||||
.bind(&file_path_string)
|
||||
.bind(&input.remote_url)
|
||||
.bind(size_bytes)
|
||||
.bind(metadata.to_string())
|
||||
.execute(&*state.db)
|
||||
.await?;
|
||||
|
||||
Ok(StoredWorkspaceAsset {
|
||||
public_id: public_id.to_string(),
|
||||
file_path: file_path_string,
|
||||
remote_url: input.remote_url.clone(),
|
||||
status: status.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn record_remote_asset(
|
||||
state: &AppState,
|
||||
input: WorkspaceAssetInput,
|
||||
) -> Result<StoredWorkspaceAsset, AppError> {
|
||||
let public_id = format!("wa_{}", Uuid::new_v4().simple());
|
||||
let Some(url) = input.remote_url.as_deref() else {
|
||||
return insert_asset(
|
||||
state,
|
||||
&input,
|
||||
&public_id,
|
||||
"failed",
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
json!({ "error": "remote_url is required", "metadata": input.metadata }),
|
||||
)
|
||||
.await;
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
match client.get(url).send().await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
let mime_type = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| AppError::UpstreamError(format!("download workspace asset: {err}")))?;
|
||||
let ext = extension_from_url(url).unwrap_or_else(|| extension_for_mime(&mime_type).to_string());
|
||||
let path = write_asset_file(input.user_id, &input.source, &public_id, &ext, &bytes).await?;
|
||||
insert_asset(
|
||||
state,
|
||||
&input,
|
||||
&public_id,
|
||||
"completed",
|
||||
Some(&mime_type),
|
||||
Some(&path),
|
||||
bytes.len() as i64,
|
||||
input.metadata.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Ok(response) => {
|
||||
let status = response.status().as_u16();
|
||||
insert_asset(
|
||||
state,
|
||||
&input,
|
||||
&public_id,
|
||||
"failed",
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
json!({ "error": format!("download returned HTTP {status}"), "metadata": input.metadata }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(err) => {
|
||||
insert_asset(
|
||||
state,
|
||||
&input,
|
||||
&public_id,
|
||||
"failed",
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
json!({ "error": err.to_string(), "metadata": input.metadata }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn record_base64_asset(
|
||||
state: &AppState,
|
||||
mut input: WorkspaceAssetInput,
|
||||
value: &str,
|
||||
) -> Result<StoredWorkspaceAsset, AppError> {
|
||||
let public_id = format!("wa_{}", Uuid::new_v4().simple());
|
||||
let trimmed = value.trim();
|
||||
let (mime_type, encoded) = if let Some(rest) = trimmed.strip_prefix("data:") {
|
||||
let (meta, data) = rest
|
||||
.split_once(',')
|
||||
.ok_or_else(|| AppError::BadRequest("invalid data URL".into()))?;
|
||||
let mime = meta.split(';').next().unwrap_or("image/png").trim();
|
||||
(mime.to_string(), data)
|
||||
} else {
|
||||
("image/png".to_string(), trimmed)
|
||||
};
|
||||
input.remote_url = None;
|
||||
let bytes = general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|err| AppError::BadRequest(format!("invalid base64 asset: {err}")))?;
|
||||
let ext = extension_for_mime(&mime_type);
|
||||
let path = write_asset_file(input.user_id, &input.source, &public_id, ext, &bytes).await?;
|
||||
insert_asset(
|
||||
state,
|
||||
&input,
|
||||
&public_id,
|
||||
"completed",
|
||||
Some(&mime_type),
|
||||
Some(&path),
|
||||
bytes.len() as i64,
|
||||
input.metadata.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_image_assets_from_response(
|
||||
state: &AppState,
|
||||
user_id: Option<i64>,
|
||||
source: &str,
|
||||
prompt: Option<String>,
|
||||
model: Option<String>,
|
||||
response: &Value,
|
||||
) {
|
||||
let Some(items) = response.get("data").and_then(Value::as_array) else {
|
||||
return;
|
||||
};
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
let title = prompt
|
||||
.as_deref()
|
||||
.map(|value| value.chars().take(36).collect::<String>())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| Some(format!("图片产出 #{}", index + 1)));
|
||||
if let Some(url) = item.get("url").and_then(Value::as_str).filter(|value| !value.trim().is_empty()) {
|
||||
let input = WorkspaceAssetInput {
|
||||
user_id,
|
||||
source: source.to_string(),
|
||||
kind: "image".to_string(),
|
||||
title,
|
||||
prompt: prompt.clone(),
|
||||
model: model.clone(),
|
||||
remote_url: Some(url.trim().to_string()),
|
||||
metadata: json!({ "responseItem": item }),
|
||||
};
|
||||
if let Err(err) = record_remote_asset(state, input).await {
|
||||
tracing::warn!(%err, "failed to archive remote image asset");
|
||||
}
|
||||
} else if let Some(b64) = item
|
||||
.get("b64_json")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let input = WorkspaceAssetInput {
|
||||
user_id,
|
||||
source: source.to_string(),
|
||||
kind: "image".to_string(),
|
||||
title,
|
||||
prompt: prompt.clone(),
|
||||
model: model.clone(),
|
||||
remote_url: None,
|
||||
metadata: json!({ "responseItem": item }),
|
||||
};
|
||||
if let Err(err) = record_base64_asset(state, input, b64).await {
|
||||
tracing::warn!(%err, "failed to archive base64 image asset");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user