feat: add comfyui agent attachments
This commit is contained in:
+103
-11
@@ -76,6 +76,17 @@ struct AgentChatRequest {
|
|||||||
struct AgentMessage {
|
struct AgentMessage {
|
||||||
role: String,
|
role: String,
|
||||||
content: 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -285,6 +296,15 @@ async fn status_handler(
|
|||||||
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
|
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
|
||||||
let auth = resolve_agent_auth(&headers, &state).await?;
|
let auth = resolve_agent_auth(&headers, &state).await?;
|
||||||
let (mode, upstream_base, _, key_name) = upstream_config(&state, &auth);
|
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!({
|
Ok(Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"model": state.config.comfyui.agent_model,
|
"model": state.config.comfyui.agent_model,
|
||||||
@@ -292,7 +312,7 @@ async fn status_handler(
|
|||||||
"keyName": key_name,
|
"keyName": key_name,
|
||||||
"userId": auth.user_id,
|
"userId": auth.user_id,
|
||||||
"role": auth.role,
|
"role": auth.role,
|
||||||
"upstreamBase": upstream_base,
|
"upstreamLabel": upstream_label,
|
||||||
"comfyui": state.comfyui.status_response().await,
|
"comfyui": state.comfyui.status_response().await,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -558,6 +578,83 @@ fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) ->
|
|||||||
.is_ok()
|
.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(
|
async fn chat_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -567,10 +664,10 @@ async fn chat_handler(
|
|||||||
let auth = resolve_agent_auth(&headers, &state).await?;
|
let auth = resolve_agent_auth(&headers, &state).await?;
|
||||||
|
|
||||||
if body.messages.is_empty()
|
if body.messages.is_empty()
|
||||||
|| body
|
|| body.messages.iter().any(|message| {
|
||||||
.messages
|
message.role.trim().is_empty()
|
||||||
.iter()
|
|| (message.content.trim().is_empty() && message.attachments.is_empty())
|
||||||
.any(|message| message.role.trim().is_empty() || message.content.trim().is_empty())
|
})
|
||||||
{
|
{
|
||||||
return Err((
|
return Err((
|
||||||
axum::http::StatusCode::BAD_REQUEST,
|
axum::http::StatusCode::BAD_REQUEST,
|
||||||
@@ -588,12 +685,7 @@ async fn chat_handler(
|
|||||||
let messages: Vec<Value> = body
|
let messages: Vec<Value> = body
|
||||||
.messages
|
.messages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|message| {
|
.map(agent_message_to_upstream)
|
||||||
json!({
|
|
||||||
"role": message.role,
|
|
||||||
"content": message.content,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
let (mode, api_base, api_key, key_name) = upstream_config(&state, &auth);
|
let (mode, api_base, api_key, key_name) = upstream_config(&state, &auth);
|
||||||
info!(
|
info!(
|
||||||
|
|||||||
+323
-61
@@ -25,10 +25,20 @@ type AgentTts = {
|
|||||||
url?: string;
|
url?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AgentAttachment = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
text?: string;
|
||||||
|
dataUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AgentMessage = {
|
type AgentMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
role: AgentRole;
|
role: AgentRole;
|
||||||
content: string;
|
content: string;
|
||||||
|
attachments?: AgentAttachment[];
|
||||||
images?: AgentImage[];
|
images?: AgentImage[];
|
||||||
scenes?: AgentScene[];
|
scenes?: AgentScene[];
|
||||||
tts?: AgentTts[];
|
tts?: AgentTts[];
|
||||||
@@ -37,7 +47,8 @@ type AgentMessage = {
|
|||||||
|
|
||||||
type AgentStatus = {
|
type AgentStatus = {
|
||||||
model?: string;
|
model?: string;
|
||||||
upstreamBase?: string;
|
mode?: string;
|
||||||
|
keyName?: string;
|
||||||
comfyui?: {
|
comfyui?: {
|
||||||
status?: string;
|
status?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
@@ -71,6 +82,11 @@ type StoredAgentConversation = {
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RunningConversation = {
|
||||||
|
draft: StreamAccumulator;
|
||||||
|
controller: AbortController;
|
||||||
|
};
|
||||||
|
|
||||||
const WELCOME_MESSAGE: AgentMessage = {
|
const WELCOME_MESSAGE: AgentMessage = {
|
||||||
id: 'welcome',
|
id: 'welcome',
|
||||||
role: 'system',
|
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 {
|
function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(sessionStorageKey(audience));
|
const raw = localStorage.getItem(sessionStorageKey(audience));
|
||||||
@@ -130,7 +186,12 @@ function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
|
|||||||
? parsed.conversations
|
? parsed.conversations
|
||||||
.map((conversation): StoredAgentConversation | null => {
|
.map((conversation): StoredAgentConversation | null => {
|
||||||
const messages = Array.isArray(conversation?.messages)
|
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;
|
if (!conversation?.id || !messages.length) return null;
|
||||||
return {
|
return {
|
||||||
@@ -162,7 +223,12 @@ function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messages = Array.isArray(parsed.messages)
|
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;
|
: undefined;
|
||||||
return {
|
return {
|
||||||
messages: messages?.length ? messages : undefined,
|
messages: messages?.length ? messages : undefined,
|
||||||
@@ -271,8 +337,9 @@ export default function ComfyUIAgent() {
|
|||||||
() => initialActiveConversation.messages,
|
() => initialActiveConversation.messages,
|
||||||
);
|
);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [runningByConversation, setRunningByConversation] = useState<
|
||||||
const [streaming, setStreaming] = useState<StreamAccumulator | null>(null);
|
Record<string, RunningConversation>
|
||||||
|
>({});
|
||||||
const [status, setStatus] = useState<AgentStatus | null>(null);
|
const [status, setStatus] = useState<AgentStatus | null>(null);
|
||||||
const [modelOptions, setModelOptions] = useState<string[]>([]);
|
const [modelOptions, setModelOptions] = useState<string[]>([]);
|
||||||
const [selectedModel, setSelectedModel] = useState(
|
const [selectedModel, setSelectedModel] = useState(
|
||||||
@@ -280,10 +347,18 @@ export default function ComfyUIAgent() {
|
|||||||
);
|
);
|
||||||
const [modelSearch, setModelSearch] = useState('');
|
const [modelSearch, setModelSearch] = useState('');
|
||||||
const [loadingModels, setLoadingModels] = useState(false);
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
|
const [attachments, setAttachments] = useState<AgentAttachment[]>([]);
|
||||||
|
const [readingAttachments, setReadingAttachments] = useState(false);
|
||||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
const latestWorkflow = useMemo(() => {
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
if (messages[index].workflowJson) return messages[index].workflowJson;
|
if (messages[index].workflowJson) return messages[index].workflowJson;
|
||||||
@@ -305,6 +380,18 @@ export default function ComfyUIAgent() {
|
|||||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
}, [messages, streaming?.content]);
|
}, [messages, streaming?.content]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeConversationIdRef.current = activeConversationId;
|
||||||
|
}, [activeConversationId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesRef.current = messages;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
conversationsRef.current = conversations;
|
||||||
|
}, [conversations]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stored = readStoredSession(sessionAudience);
|
const stored = readStoredSession(sessionAudience);
|
||||||
const nextConversations = stored.conversations?.length
|
const nextConversations = stored.conversations?.length
|
||||||
@@ -319,10 +406,8 @@ export default function ComfyUIAgent() {
|
|||||||
setMessages(nextActive.messages);
|
setMessages(nextActive.messages);
|
||||||
setSelectedModel(nextActive.selectedModel || '');
|
setSelectedModel(nextActive.selectedModel || '');
|
||||||
setModelSearch('');
|
setModelSearch('');
|
||||||
setStreaming(null);
|
Object.values(runningByConversation).forEach((running) => running.controller.abort());
|
||||||
setLoading(false);
|
setRunningByConversation({});
|
||||||
abortRef.current?.abort();
|
|
||||||
abortRef.current = null;
|
|
||||||
}, [sessionAudience]);
|
}, [sessionAudience]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -404,6 +489,7 @@ export default function ComfyUIAgent() {
|
|||||||
.map((message) => ({
|
.map((message) => ({
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
|
attachments: message.attachments || [],
|
||||||
images: message.images || [],
|
images: message.images || [],
|
||||||
scenes: message.scenes || [],
|
scenes: message.scenes || [],
|
||||||
tts: message.tts || [],
|
tts: message.tts || [],
|
||||||
@@ -486,9 +572,33 @@ export default function ComfyUIAgent() {
|
|||||||
apply(next);
|
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 sendMessage = async () => {
|
||||||
const content = input.trim();
|
const content = input.trim();
|
||||||
if (!content || loading) return;
|
if ((!content && attachments.length === 0) || loading || readingAttachments) return;
|
||||||
|
|
||||||
const token = getSessionToken(sessionAudience, localStorage);
|
const token = getSessionToken(sessionAudience, localStorage);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -500,9 +610,12 @@ export default function ComfyUIAgent() {
|
|||||||
id: createId('user'),
|
id: createId('user'),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content,
|
content,
|
||||||
|
attachments: attachments.length ? attachments : undefined,
|
||||||
};
|
};
|
||||||
const nextMessages = [...messages, userMessage];
|
const nextMessages = [...messages, userMessage];
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
const conversationId = activeConversationId;
|
||||||
|
const modelForRequest = selectedModel || status?.model;
|
||||||
const draft: StreamAccumulator = {
|
const draft: StreamAccumulator = {
|
||||||
content: '',
|
content: '',
|
||||||
images: [],
|
images: [],
|
||||||
@@ -513,9 +626,11 @@ export default function ComfyUIAgent() {
|
|||||||
|
|
||||||
setMessages(nextMessages);
|
setMessages(nextMessages);
|
||||||
setInput('');
|
setInput('');
|
||||||
setLoading(true);
|
setAttachments([]);
|
||||||
setStreaming(draft);
|
setRunningByConversation((current) => ({
|
||||||
abortRef.current = controller;
|
...current,
|
||||||
|
[conversationId]: { draft, controller },
|
||||||
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payloadMessages = nextMessages
|
const payloadMessages = nextMessages
|
||||||
@@ -523,6 +638,7 @@ export default function ComfyUIAgent() {
|
|||||||
.map((message) => ({
|
.map((message) => ({
|
||||||
role: message.role,
|
role: message.role,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
|
attachments: message.attachments || [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const response = await fetch('/api/comfyui-agent/chat', {
|
const response = await fetch('/api/comfyui-agent/chat', {
|
||||||
@@ -533,7 +649,7 @@ export default function ComfyUIAgent() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
messages: payloadMessages,
|
messages: payloadMessages,
|
||||||
model: selectedModel || status?.model,
|
model: modelForRequest,
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
@@ -544,57 +660,69 @@ export default function ComfyUIAgent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await readSseStream(response, (event) => {
|
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) => [
|
appendAssistantMessageToConversation(conversationId, {
|
||||||
...current,
|
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'),
|
id: createId('assistant'),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: draft.content || '生成完成,但没有返回文本。',
|
content: appendLine(draft.content, `请求失败:${error?.message || '未知错误'}`),
|
||||||
images: draft.images.length ? draft.images : undefined,
|
images: draft.images.length ? draft.images : undefined,
|
||||||
scenes: draft.scenes.length ? draft.scenes : undefined,
|
scenes: draft.scenes.length ? draft.scenes : undefined,
|
||||||
tts: draft.tts.length ? draft.tts : undefined,
|
tts: draft.tts.length ? draft.tts : undefined,
|
||||||
workflowJson: draft.workflowJson || 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 {
|
} finally {
|
||||||
setLoading(false);
|
setRunningByConversation((current) => {
|
||||||
abortRef.current = null;
|
const next = { ...current };
|
||||||
|
delete next[conversationId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopGeneration = () => {
|
const stopGeneration = () => {
|
||||||
abortRef.current?.abort();
|
runningByConversation[activeConversationId]?.controller.abort();
|
||||||
};
|
};
|
||||||
|
|
||||||
const startNewConversation = () => {
|
const startNewConversation = () => {
|
||||||
if (loading) return;
|
|
||||||
const conversation = createConversation([WELCOME_MESSAGE], selectedModel || status?.model || '');
|
const conversation = createConversation([WELCOME_MESSAGE], selectedModel || status?.model || '');
|
||||||
setConversations((current) => {
|
setConversations((current) => {
|
||||||
const next = [conversation, ...current];
|
const next = [conversation, ...current];
|
||||||
@@ -605,23 +733,22 @@ export default function ComfyUIAgent() {
|
|||||||
setMessages(conversation.messages);
|
setMessages(conversation.messages);
|
||||||
setSelectedModel(conversation.selectedModel || '');
|
setSelectedModel(conversation.selectedModel || '');
|
||||||
setInput('');
|
setInput('');
|
||||||
setStreaming(null);
|
setAttachments([]);
|
||||||
setModelSearch('');
|
setModelSearch('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectConversation = (conversation: StoredAgentConversation) => {
|
const selectConversation = (conversation: StoredAgentConversation) => {
|
||||||
if (loading || conversation.id === activeConversationId) return;
|
if (conversation.id === activeConversationId) return;
|
||||||
abortRef.current?.abort();
|
|
||||||
setActiveConversationId(conversation.id);
|
setActiveConversationId(conversation.id);
|
||||||
setMessages(conversation.messages);
|
setMessages(conversation.messages);
|
||||||
setSelectedModel(conversation.selectedModel || '');
|
setSelectedModel(conversation.selectedModel || '');
|
||||||
setInput('');
|
setInput('');
|
||||||
setStreaming(null);
|
setAttachments([]);
|
||||||
setModelSearch('');
|
setModelSearch('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteConversation = (conversationId: string) => {
|
const deleteConversation = (conversationId: string) => {
|
||||||
if (loading) return;
|
runningByConversation[conversationId]?.controller.abort();
|
||||||
setConversations((current) => {
|
setConversations((current) => {
|
||||||
const remaining = current.filter((conversation) => conversation.id !== conversationId);
|
const remaining = current.filter((conversation) => conversation.id !== conversationId);
|
||||||
const next = remaining.length ? remaining : [createConversation([WELCOME_MESSAGE], selectedModel)];
|
const next = remaining.length ? remaining : [createConversation([WELCOME_MESSAGE], selectedModel)];
|
||||||
@@ -633,10 +760,52 @@ export default function ComfyUIAgent() {
|
|||||||
setMessages(nextActive.messages);
|
setMessages(nextActive.messages);
|
||||||
setSelectedModel(nextActive.selectedModel || '');
|
setSelectedModel(nextActive.selectedModel || '');
|
||||||
setInput('');
|
setInput('');
|
||||||
setStreaming(null);
|
setAttachments([]);
|
||||||
persistStoredSession(sessionAudience, next, nextActive.id);
|
persistStoredSession(sessionAudience, next, nextActive.id);
|
||||||
return next;
|
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) => (
|
const renderAssets = (message: AgentMessage | StreamAccumulator) => (
|
||||||
@@ -686,11 +855,9 @@ export default function ComfyUIAgent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={startNewConversation}
|
onClick={startNewConversation}
|
||||||
disabled={loading}
|
|
||||||
style={{
|
style={{
|
||||||
...secondaryButtonStyle,
|
...secondaryButtonStyle,
|
||||||
padding: '6px 10px',
|
padding: '6px 10px',
|
||||||
opacity: loading ? 0.6 : 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
新建
|
新建
|
||||||
@@ -702,7 +869,6 @@ export default function ComfyUIAgent() {
|
|||||||
key={conversation.id}
|
key={conversation.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectConversation(conversation)}
|
onClick={() => selectConversation(conversation)}
|
||||||
disabled={loading}
|
|
||||||
style={{
|
style={{
|
||||||
...conversationItemStyle,
|
...conversationItemStyle,
|
||||||
borderColor:
|
borderColor:
|
||||||
@@ -717,7 +883,9 @@ export default function ComfyUIAgent() {
|
|||||||
>
|
>
|
||||||
<span style={conversationTitleStyle}>{conversation.title}</span>
|
<span style={conversationTitleStyle}>{conversation.title}</span>
|
||||||
<span style={conversationMetaStyle}>
|
<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>
|
||||||
<span
|
<span
|
||||||
role="button"
|
role="button"
|
||||||
@@ -748,7 +916,7 @@ export default function ComfyUIAgent() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 style={titleStyle}>视频助手</h1>
|
<h1 style={titleStyle}>视频助手</h1>
|
||||||
<div style={statusStyle}>
|
<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>
|
</div>
|
||||||
<div style={headerActionsStyle}>
|
<div style={headerActionsStyle}>
|
||||||
@@ -824,6 +992,7 @@ export default function ComfyUIAgent() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={messageContentStyle}>{message.content}</div>
|
<div style={messageContentStyle}>{message.content}</div>
|
||||||
|
{renderAttachments(message.attachments, message.role !== 'user')}
|
||||||
{renderAssets(message)}
|
{renderAssets(message)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -841,6 +1010,30 @@ export default function ComfyUIAgent() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer style={inputBarStyle}>
|
<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
|
<textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(event) => setInput(event.target.value)}
|
onChange={(event) => setInput(event.target.value)}
|
||||||
@@ -855,6 +1048,25 @@ export default function ComfyUIAgent() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={textareaStyle}
|
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 ? (
|
{loading ? (
|
||||||
<button type="button" onClick={stopGeneration} style={dangerButtonStyle}>
|
<button type="button" onClick={stopGeneration} style={dangerButtonStyle}>
|
||||||
停止
|
停止
|
||||||
@@ -1054,11 +1266,17 @@ const inputBarStyle: CSSProperties = {
|
|||||||
padding: 16,
|
padding: 16,
|
||||||
borderTop: '1px solid var(--color-border)',
|
borderTop: '1px solid var(--color-border)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'minmax(0, 1fr) auto',
|
gridTemplateColumns: 'minmax(0, 1fr) auto auto',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const composerMainStyle: CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gap: 8,
|
||||||
|
minWidth: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const textareaStyle: CSSProperties = {
|
const textareaStyle: CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: 72,
|
minHeight: 72,
|
||||||
@@ -1148,6 +1366,50 @@ const assetRowStyle: CSSProperties = {
|
|||||||
fontSize: 13,
|
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 = {
|
const cursorStyle: CSSProperties = {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user