feat: add comfyui agent attachments
This commit is contained in:
+103
-11
@@ -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!(
|
||||
|
||||
+308
-46
@@ -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,12 +660,22 @@ 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 || '生成完成,但没有返回文本。',
|
||||
@@ -557,15 +683,11 @@ export default function ComfyUIAgent() {
|
||||
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,
|
||||
{
|
||||
appendAssistantMessageToConversation(conversationId, {
|
||||
id: createId('assistant'),
|
||||
role: 'assistant',
|
||||
content: appendLine(draft.content, '已停止生成。'),
|
||||
@@ -573,28 +695,34 @@ export default function ComfyUIAgent() {
|
||||
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,
|
||||
appendAssistantMessageToConversation(conversationId, {
|
||||
id: createId('assistant'),
|
||||
role: 'assistant',
|
||||
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,
|
||||
});
|
||||
}
|
||||
} 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,
|
||||
|
||||
Reference in New Issue
Block a user