feat: add comfyui agent attachments

This commit is contained in:
boos
2026-05-22 01:16:37 +08:00
parent 7c6364a160
commit 8de175a8c5
2 changed files with 426 additions and 72 deletions
+103 -11
View File
@@ -76,6 +76,17 @@ struct AgentChatRequest {
struct AgentMessage {
role: String,
content: String,
#[serde(default)]
attachments: Vec<AgentAttachment>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentAttachment {
name: String,
mime_type: Option<String>,
text: Option<String>,
data_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -285,6 +296,15 @@ async fn status_handler(
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
let auth = resolve_agent_auth(&headers, &state).await?;
let (mode, upstream_base, _, key_name) = upstream_config(&state, &auth);
let upstream_label = match mode {
AgentMode::Local => "BoosAPI 本地路由".to_string(),
AgentMode::FallbackRemote => {
let parsed = reqwest::Url::parse(&upstream_base).ok();
parsed
.and_then(|url| url.host_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "外部上游".to_string())
}
};
Ok(Json(json!({
"success": true,
"model": state.config.comfyui.agent_model,
@@ -292,7 +312,7 @@ async fn status_handler(
"keyName": key_name,
"userId": auth.user_id,
"role": auth.role,
"upstreamBase": upstream_base,
"upstreamLabel": upstream_label,
"comfyui": state.comfyui.status_response().await,
})))
}
@@ -558,6 +578,83 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
.is_ok()
}
fn attachment_summary(attachment: &AgentAttachment) -> String {
let mime = attachment
.mime_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream");
let name = attachment.name.trim();
let name = if name.is_empty() { "untitled" } else { name };
if let Some(text) = attachment
.text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("\n\n[附件: {name}, 类型: {mime}]\n{text}")
} else {
format!("\n\n[附件: {name}, 类型: {mime}]")
}
}
fn agent_message_to_upstream(message: AgentMessage) -> Value {
let role = message.role;
let mut text = message.content;
let mut content_parts = Vec::new();
for attachment in &message.attachments {
let mime = attachment
.mime_type
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
if mime.starts_with("image/") {
if let Some(data_url) = attachment
.data_url
.as_deref()
.map(str::trim)
.filter(|value| value.starts_with("data:image/"))
{
if !content_parts.iter().any(|part: &Value| part["type"] == "text") {
content_parts.push(json!({
"type": "text",
"text": text,
}));
text = String::new();
}
content_parts.push(json!({
"type": "image_url",
"image_url": { "url": data_url },
}));
continue;
}
}
text.push_str(&attachment_summary(attachment));
}
if !content_parts.is_empty() {
if !text.trim().is_empty() {
content_parts.push(json!({
"type": "text",
"text": text,
}));
}
json!({
"role": role,
"content": content_parts,
})
} else {
json!({
"role": role,
"content": text,
})
}
}
async fn chat_handler(
State(state): State<AppState>,
headers: HeaderMap,
@@ -567,10 +664,10 @@ async fn chat_handler(
let auth = resolve_agent_auth(&headers, &state).await?;
if body.messages.is_empty()
|| body
.messages
.iter()
.any(|message| message.role.trim().is_empty() || message.content.trim().is_empty())
|| body.messages.iter().any(|message| {
message.role.trim().is_empty()
|| (message.content.trim().is_empty() && message.attachments.is_empty())
})
{
return Err((
axum::http::StatusCode::BAD_REQUEST,
@@ -588,12 +685,7 @@ async fn chat_handler(
let messages: Vec<Value> = body
.messages
.into_iter()
.map(|message| {
json!({
"role": message.role,
"content": message.content,
})
})
.map(agent_message_to_upstream)
.collect();
let (mode, api_base, api_key, key_name) = upstream_config(&state, &auth);
info!(
+323 -61
View File
@@ -25,10 +25,20 @@ type AgentTts = {
url?: string;
};
type AgentAttachment = {
id: string;
name: string;
mimeType: string;
size: number;
text?: string;
dataUrl?: string;
};
type AgentMessage = {
id: string;
role: AgentRole;
content: string;
attachments?: AgentAttachment[];
images?: AgentImage[];
scenes?: AgentScene[];
tts?: AgentTts[];
@@ -37,7 +47,8 @@ type AgentMessage = {
type AgentStatus = {
model?: string;
upstreamBase?: string;
mode?: string;
keyName?: string;
comfyui?: {
status?: string;
mode?: string;
@@ -71,6 +82,11 @@ type StoredAgentConversation = {
selectedModel: string;
};
type RunningConversation = {
draft: StreamAccumulator;
controller: AbortController;
};
const WELCOME_MESSAGE: AgentMessage = {
id: 'welcome',
role: 'system',
@@ -121,6 +137,46 @@ function isValidMessage(value: unknown): value is AgentMessage {
);
}
function isValidAttachment(value: unknown): value is AgentAttachment {
if (!value || typeof value !== 'object') return false;
const item = value as Partial<AgentAttachment>;
return (
typeof item.id === 'string' &&
typeof item.name === 'string' &&
typeof item.mimeType === 'string' &&
typeof item.size === 'number'
);
}
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('读取附件失败'));
reader.readAsDataURL(file);
});
}
async function fileToAttachment(file: File): Promise<AgentAttachment> {
const mimeType = file.type || 'application/octet-stream';
const base = {
id: createId('attachment'),
name: file.name || 'untitled',
mimeType,
size: file.size,
};
if (mimeType.startsWith('image/')) {
return { ...base, dataUrl: await fileToDataUrl(file) };
}
const text = await file.text();
return {
...base,
text: text.slice(0, 120_000),
};
}
function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
try {
const raw = localStorage.getItem(sessionStorageKey(audience));
@@ -130,7 +186,12 @@ function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
? parsed.conversations
.map((conversation): StoredAgentConversation | null => {
const messages = Array.isArray(conversation?.messages)
? conversation.messages.filter(isValidMessage)
? conversation.messages.filter(isValidMessage).map((message) => ({
...message,
attachments: Array.isArray(message.attachments)
? message.attachments.filter(isValidAttachment)
: undefined,
}))
: [];
if (!conversation?.id || !messages.length) return null;
return {
@@ -162,7 +223,12 @@ function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
}
const messages = Array.isArray(parsed.messages)
? parsed.messages.filter(isValidMessage)
? parsed.messages.filter(isValidMessage).map((message) => ({
...message,
attachments: Array.isArray(message.attachments)
? message.attachments.filter(isValidAttachment)
: undefined,
}))
: undefined;
return {
messages: messages?.length ? messages : undefined,
@@ -271,8 +337,9 @@ export default function ComfyUIAgent() {
() => initialActiveConversation.messages,
);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState<StreamAccumulator | null>(null);
const [runningByConversation, setRunningByConversation] = useState<
Record<string, RunningConversation>
>({});
const [status, setStatus] = useState<AgentStatus | null>(null);
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState(
@@ -280,10 +347,18 @@ export default function ComfyUIAgent() {
);
const [modelSearch, setModelSearch] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const [attachments, setAttachments] = useState<AgentAttachment[]>([]);
const [readingAttachments, setReadingAttachments] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const activeConversationIdRef = useRef(activeConversationId);
const messagesRef = useRef(messages);
const conversationsRef = useRef(conversations);
const canSend = input.trim().length > 0 && !loading;
const streaming = runningByConversation[activeConversationId]?.draft || null;
const loading = !!runningByConversation[activeConversationId];
const canSend =
(input.trim().length > 0 || attachments.length > 0) && !loading && !readingAttachments;
const latestWorkflow = useMemo(() => {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].workflowJson) return messages[index].workflowJson;
@@ -305,6 +380,18 @@ export default function ComfyUIAgent() {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, streaming?.content]);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
}, [activeConversationId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => {
conversationsRef.current = conversations;
}, [conversations]);
useEffect(() => {
const stored = readStoredSession(sessionAudience);
const nextConversations = stored.conversations?.length
@@ -319,10 +406,8 @@ export default function ComfyUIAgent() {
setMessages(nextActive.messages);
setSelectedModel(nextActive.selectedModel || '');
setModelSearch('');
setStreaming(null);
setLoading(false);
abortRef.current?.abort();
abortRef.current = null;
Object.values(runningByConversation).forEach((running) => running.controller.abort());
setRunningByConversation({});
}, [sessionAudience]);
useEffect(() => {
@@ -404,6 +489,7 @@ export default function ComfyUIAgent() {
.map((message) => ({
role: message.role,
content: message.content,
attachments: message.attachments || [],
images: message.images || [],
scenes: message.scenes || [],
tts: message.tts || [],
@@ -486,9 +572,33 @@ export default function ComfyUIAgent() {
apply(next);
};
const appendAssistantMessageToConversation = (
conversationId: string,
assistantMessage: AgentMessage,
) => {
setConversations((current) => {
const now = Date.now();
const next = current.map((conversation) => (
conversation.id === conversationId
? {
...conversation,
updatedAt: now,
messages: [...conversation.messages, assistantMessage],
}
: conversation
));
persistStoredSession(sessionAudience, next, activeConversationIdRef.current);
return next;
});
if (activeConversationIdRef.current === conversationId) {
setMessages((current) => [...current, assistantMessage]);
}
};
const sendMessage = async () => {
const content = input.trim();
if (!content || loading) return;
if ((!content && attachments.length === 0) || loading || readingAttachments) return;
const token = getSessionToken(sessionAudience, localStorage);
if (!token) {
@@ -500,9 +610,12 @@ export default function ComfyUIAgent() {
id: createId('user'),
role: 'user',
content,
attachments: attachments.length ? attachments : undefined,
};
const nextMessages = [...messages, userMessage];
const controller = new AbortController();
const conversationId = activeConversationId;
const modelForRequest = selectedModel || status?.model;
const draft: StreamAccumulator = {
content: '',
images: [],
@@ -513,9 +626,11 @@ export default function ComfyUIAgent() {
setMessages(nextMessages);
setInput('');
setLoading(true);
setStreaming(draft);
abortRef.current = controller;
setAttachments([]);
setRunningByConversation((current) => ({
...current,
[conversationId]: { draft, controller },
}));
try {
const payloadMessages = nextMessages
@@ -523,6 +638,7 @@ export default function ComfyUIAgent() {
.map((message) => ({
role: message.role,
content: message.content,
attachments: message.attachments || [],
}));
const response = await fetch('/api/comfyui-agent/chat', {
@@ -533,7 +649,7 @@ export default function ComfyUIAgent() {
},
body: JSON.stringify({
messages: payloadMessages,
model: selectedModel || status?.model,
model: modelForRequest,
}),
signal: controller.signal,
});
@@ -544,57 +660,69 @@ export default function ComfyUIAgent() {
}
await readSseStream(response, (event) => {
handleStreamEvent(event, draft, setStreaming);
handleStreamEvent(event, draft, (nextDraft) => {
setRunningByConversation((current) => {
const running = current[conversationId];
if (!running) return current;
return {
...current,
[conversationId]: {
...running,
draft: nextDraft,
},
};
});
});
});
setMessages((current) => [
...current,
{
appendAssistantMessageToConversation(conversationId, {
id: createId('assistant'),
role: 'assistant',
content: draft.content || '生成完成,但没有返回文本。',
images: draft.images.length ? draft.images : undefined,
scenes: draft.scenes.length ? draft.scenes : undefined,
tts: draft.tts.length ? draft.tts : undefined,
workflowJson: draft.workflowJson || undefined,
});
} catch (error: any) {
if (error?.name === 'AbortError') {
if (draft.content) {
appendAssistantMessageToConversation(conversationId, {
id: createId('assistant'),
role: 'assistant',
content: appendLine(draft.content, '已停止生成。'),
images: draft.images.length ? draft.images : undefined,
scenes: draft.scenes.length ? draft.scenes : undefined,
tts: draft.tts.length ? draft.tts : undefined,
workflowJson: draft.workflowJson || undefined,
});
}
} else {
toast.error(error?.message || '视频助手请求失败');
appendAssistantMessageToConversation(conversationId, {
id: createId('assistant'),
role: 'assistant',
content: draft.content || '生成完成,但没有返回文本。',
content: appendLine(draft.content, `请求失败:${error?.message || '未知错误'}`),
images: draft.images.length ? draft.images : undefined,
scenes: draft.scenes.length ? draft.scenes : undefined,
tts: draft.tts.length ? draft.tts : undefined,
workflowJson: draft.workflowJson || undefined,
},
]);
setStreaming(null);
} catch (error: any) {
if (error?.name === 'AbortError') {
if (draft.content) {
setMessages((current) => [
...current,
{
id: createId('assistant'),
role: 'assistant',
content: appendLine(draft.content, '已停止生成。'),
images: draft.images.length ? draft.images : undefined,
scenes: draft.scenes.length ? draft.scenes : undefined,
tts: draft.tts.length ? draft.tts : undefined,
workflowJson: draft.workflowJson || undefined,
},
]);
}
} else {
toast.error(error?.message || '视频助手请求失败');
setStreaming({
...draft,
content: appendLine(draft.content, `请求失败:${error?.message || '未知错误'}`),
});
}
} finally {
setLoading(false);
abortRef.current = null;
setRunningByConversation((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
}
};
const stopGeneration = () => {
abortRef.current?.abort();
runningByConversation[activeConversationId]?.controller.abort();
};
const startNewConversation = () => {
if (loading) return;
const conversation = createConversation([WELCOME_MESSAGE], selectedModel || status?.model || '');
setConversations((current) => {
const next = [conversation, ...current];
@@ -605,23 +733,22 @@ export default function ComfyUIAgent() {
setMessages(conversation.messages);
setSelectedModel(conversation.selectedModel || '');
setInput('');
setStreaming(null);
setAttachments([]);
setModelSearch('');
};
const selectConversation = (conversation: StoredAgentConversation) => {
if (loading || conversation.id === activeConversationId) return;
abortRef.current?.abort();
if (conversation.id === activeConversationId) return;
setActiveConversationId(conversation.id);
setMessages(conversation.messages);
setSelectedModel(conversation.selectedModel || '');
setInput('');
setStreaming(null);
setAttachments([]);
setModelSearch('');
};
const deleteConversation = (conversationId: string) => {
if (loading) return;
runningByConversation[conversationId]?.controller.abort();
setConversations((current) => {
const remaining = current.filter((conversation) => conversation.id !== conversationId);
const next = remaining.length ? remaining : [createConversation([WELCOME_MESSAGE], selectedModel)];
@@ -633,10 +760,52 @@ export default function ComfyUIAgent() {
setMessages(nextActive.messages);
setSelectedModel(nextActive.selectedModel || '');
setInput('');
setStreaming(null);
setAttachments([]);
persistStoredSession(sessionAudience, next, nextActive.id);
return next;
});
setRunningByConversation((current) => {
const next = { ...current };
delete next[conversationId];
return next;
});
};
const handleFilesSelected = async (files: FileList | null) => {
if (!files?.length) return;
setReadingAttachments(true);
try {
const next = await Promise.all(Array.from(files).slice(0, 8).map(fileToAttachment));
setAttachments((current) => [...current, ...next].slice(0, 8));
} catch (error: any) {
toast.error(error?.message || '读取附件失败');
} finally {
setReadingAttachments(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const removeAttachment = (id: string) => {
setAttachments((current) => current.filter((attachment) => attachment.id !== id));
};
const renderAttachments = (items?: AgentAttachment[], compact = false) => {
if (!items?.length) return null;
return (
<div style={attachmentListStyle}>
{items.map((attachment) => (
<div key={attachment.id} style={attachmentPillStyle}>
{attachment.dataUrl ? (
<img src={attachment.dataUrl} alt={attachment.name} style={attachmentThumbStyle} />
) : null}
<span style={attachmentNameStyle}>
{attachment.name}
{!compact ? ` · ${Math.ceil(attachment.size / 1024)} KB` : ''}
</span>
</div>
))}
</div>
);
};
const renderAssets = (message: AgentMessage | StreamAccumulator) => (
@@ -686,11 +855,9 @@ export default function ComfyUIAgent() {
<button
type="button"
onClick={startNewConversation}
disabled={loading}
style={{
...secondaryButtonStyle,
padding: '6px 10px',
opacity: loading ? 0.6 : 1,
}}
>
新建
@@ -702,7 +869,6 @@ export default function ComfyUIAgent() {
key={conversation.id}
type="button"
onClick={() => selectConversation(conversation)}
disabled={loading}
style={{
...conversationItemStyle,
borderColor:
@@ -717,7 +883,9 @@ export default function ComfyUIAgent() {
>
<span style={conversationTitleStyle}>{conversation.title}</span>
<span style={conversationMetaStyle}>
{Math.max(0, conversation.messages.filter((message) => message.role !== 'system').length)} 条消息
{runningByConversation[conversation.id]
? '生成中'
: `${Math.max(0, conversation.messages.filter((message) => message.role !== 'system').length)} 条消息`}
</span>
<span
role="button"
@@ -748,7 +916,7 @@ export default function ComfyUIAgent() {
<div>
<h1 style={titleStyle}>视频助手</h1>
<div style={statusStyle}>
模型 {selectedModel || status?.model || '加载中'} · 上游 {status?.upstreamBase || '检测中'} · ComfyUI {status?.comfyui?.status || 'unknown'}
模型 {selectedModel || status?.model || '加载中'} · 模式 {status?.mode || 'local'} · Key {status?.keyName || '当前用户'} · ComfyUI {status?.comfyui?.status || 'unknown'}
</div>
</div>
<div style={headerActionsStyle}>
@@ -824,6 +992,7 @@ export default function ComfyUIAgent() {
}}
>
<div style={messageContentStyle}>{message.content}</div>
{renderAttachments(message.attachments, message.role !== 'user')}
{renderAssets(message)}
</article>
))}
@@ -841,6 +1010,30 @@ export default function ComfyUIAgent() {
</main>
<footer style={inputBarStyle}>
<div style={composerMainStyle}>
{attachments.length > 0 && (
<div style={attachmentListStyle}>
{attachments.map((attachment) => (
<div key={attachment.id} style={attachmentPillStyle}>
{attachment.dataUrl ? (
<img src={attachment.dataUrl} alt={attachment.name} style={attachmentThumbStyle} />
) : null}
<span style={attachmentNameStyle}>
{attachment.name} · {Math.ceil(attachment.size / 1024)} KB
</span>
<button
type="button"
onClick={() => removeAttachment(attachment.id)}
disabled={loading}
style={attachmentRemoveStyle}
aria-label={`移除附件 ${attachment.name}`}
>
×
</button>
</div>
))}
</div>
)}
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
@@ -855,6 +1048,25 @@ export default function ComfyUIAgent() {
disabled={loading}
style={textareaStyle}
/>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.txt,.md,.json,.csv,.srt,.vtt"
style={{ display: 'none' }}
onChange={(event) => {
void handleFilesSelected(event.target.files);
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={loading || readingAttachments}
style={secondaryButtonStyle}
>
{readingAttachments ? '读取中' : '附件'}
</button>
{loading ? (
<button type="button" onClick={stopGeneration} style={dangerButtonStyle}>
停止
@@ -1054,11 +1266,17 @@ const inputBarStyle: CSSProperties = {
padding: 16,
borderTop: '1px solid var(--color-border)',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gridTemplateColumns: 'minmax(0, 1fr) auto auto',
gap: 10,
background: 'var(--color-surface)',
};
const composerMainStyle: CSSProperties = {
display: 'grid',
gap: 8,
minWidth: 0,
};
const textareaStyle: CSSProperties = {
width: '100%',
minHeight: 72,
@@ -1148,6 +1366,50 @@ const assetRowStyle: CSSProperties = {
fontSize: 13,
};
const attachmentListStyle: CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: 8,
marginTop: 8,
};
const attachmentPillStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 7,
maxWidth: 280,
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: '5px 8px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 12,
};
const attachmentThumbStyle: CSSProperties = {
width: 28,
height: 28,
borderRadius: 6,
objectFit: 'cover',
flexShrink: 0,
};
const attachmentNameStyle: CSSProperties = {
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const attachmentRemoveStyle: CSSProperties = {
border: 0,
background: 'transparent',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
};
const cursorStyle: CSSProperties = {
display: 'inline-block',
marginLeft: 2,