fix: route video agent to comfyui and ignore openspec

This commit is contained in:
boos
2026-05-21 16:44:30 +02:00
parent 9c84759498
commit d0912e44b4
31 changed files with 350 additions and 1932 deletions
+46 -2
View File
@@ -4,10 +4,11 @@ import { getSessionToken } from '../authSession.js';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
const [iframeSrc, setIframeSrc] = React.useState('');
const [starting, setStarting] = React.useState(true);
const location = useLocation();
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const token = getSessionToken(audience, localStorage);
const iframeSrc = token ? `/comfy/?token=${encodeURIComponent(token)}` : '';
React.useEffect(() => {
const el = document.documentElement;
@@ -18,6 +19,49 @@ export default function ComfyUI() {
return () => mo.disconnect();
}, []);
React.useEffect(() => {
let cancelled = false;
const encodedToken = token ? encodeURIComponent(token) : '';
if (!token) {
setIframeSrc('');
setStarting(false);
return () => {
cancelled = true;
};
}
setStarting(true);
setIframeSrc('');
const start = async () => {
try {
const response = await fetch('/api/video-agent/start', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!cancelled && response.ok) {
setIframeSrc(`/comfy/?token=${encodedToken}`);
return;
}
} catch {
// Fall through to the legacy embedded ComfyUI proxy.
}
if (!cancelled) {
setIframeSrc('/api/comfyui/proxy/');
}
};
void start().finally(() => {
if (!cancelled) setStarting(false);
});
return () => {
cancelled = true;
};
}, [token]);
return (
<div
style={{
@@ -47,7 +91,7 @@ export default function ComfyUI() {
color: 'var(--color-text-muted)',
fontSize: 14,
}}>
登录状态已过期,请重新登录。
{starting ? '正在启动 ComfyUI...' : '登录状态已过期,请重新登录。'}
</div>
)}
</div>
+303 -10
View File
@@ -57,10 +57,20 @@ type StreamAccumulator = {
};
type StoredAgentSession = {
activeConversationId?: string;
conversations?: StoredAgentConversation[];
messages?: AgentMessage[];
selectedModel?: string;
};
type StoredAgentConversation = {
id: string;
title: string;
updatedAt: number;
messages: AgentMessage[];
selectedModel: string;
};
const WELCOME_MESSAGE: AgentMessage = {
id: 'welcome',
role: 'system',
@@ -82,6 +92,25 @@ function sessionStorageKey(audience: 'admin' | 'user') {
return `boosapi.comfyuiAgent.${audience}.session`;
}
function buildConversationTitle(messages: AgentMessage[]) {
const firstUser = messages.find((message) => message.role === 'user')?.content.trim();
if (!firstUser) return '新对话';
return firstUser.length > 24 ? `${firstUser.slice(0, 24)}...` : firstUser;
}
function createConversation(
messages: AgentMessage[] = [WELCOME_MESSAGE],
selectedModel = '',
): StoredAgentConversation {
return {
id: createId('conversation'),
title: buildConversationTitle(messages),
updatedAt: Date.now(),
messages,
selectedModel,
};
}
function isValidMessage(value: unknown): value is AgentMessage {
if (!value || typeof value !== 'object') return false;
const item = value as Partial<AgentMessage>;
@@ -97,6 +126,41 @@ function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
const raw = localStorage.getItem(sessionStorageKey(audience));
if (!raw) return {};
const parsed = JSON.parse(raw) as StoredAgentSession;
const conversations: StoredAgentConversation[] = Array.isArray(parsed.conversations)
? parsed.conversations
.map((conversation): StoredAgentConversation | null => {
const messages = Array.isArray(conversation?.messages)
? conversation.messages.filter(isValidMessage)
: [];
if (!conversation?.id || !messages.length) return null;
return {
id: String(conversation.id),
title:
typeof conversation.title === 'string' && conversation.title.trim()
? conversation.title.trim()
: buildConversationTitle(messages),
updatedAt:
typeof conversation.updatedAt === 'number'
? conversation.updatedAt
: Date.now(),
messages,
selectedModel:
typeof conversation.selectedModel === 'string'
? conversation.selectedModel
: '',
};
})
.filter((conversation): conversation is StoredAgentConversation => !!conversation)
: [];
if (conversations.length) {
const activeConversationId = conversations.some(
(conversation) => conversation.id === parsed.activeConversationId,
)
? parsed.activeConversationId
: conversations[0].id;
return { conversations, activeConversationId };
}
const messages = Array.isArray(parsed.messages)
? parsed.messages.filter(isValidMessage)
: undefined;
@@ -112,13 +176,13 @@ function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
function persistStoredSession(
audience: 'admin' | 'user',
messages: AgentMessage[],
selectedModel: string,
conversations: StoredAgentConversation[],
activeConversationId: string,
) {
try {
localStorage.setItem(
sessionStorageKey(audience),
JSON.stringify({ messages, selectedModel }),
JSON.stringify({ conversations, activeConversationId }),
);
} catch {
// Ignore quota/private-mode failures; chat still works for the current page.
@@ -184,8 +248,27 @@ export default function ComfyUIAgent() {
() => readStoredSession(sessionAudience),
[sessionAudience],
);
const initialConversations = useMemo(() => {
if (initialStoredSession.conversations?.length) return initialStoredSession.conversations;
return [
createConversation(
initialStoredSession.messages || [WELCOME_MESSAGE],
initialStoredSession.selectedModel || '',
),
];
}, [initialStoredSession]);
const initialActiveConversation =
initialConversations.find(
(conversation) => conversation.id === initialStoredSession.activeConversationId,
) || initialConversations[0];
const [conversations, setConversations] = useState<StoredAgentConversation[]>(
() => initialConversations,
);
const [activeConversationId, setActiveConversationId] = useState(
() => initialActiveConversation.id,
);
const [messages, setMessages] = useState<AgentMessage[]>(
() => initialStoredSession.messages || [WELCOME_MESSAGE],
() => initialActiveConversation.messages,
);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
@@ -193,7 +276,7 @@ export default function ComfyUIAgent() {
const [status, setStatus] = useState<AgentStatus | null>(null);
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState(
() => initialStoredSession.selectedModel || '',
() => initialActiveConversation.selectedModel || '',
);
const [modelSearch, setModelSearch] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
@@ -224,8 +307,17 @@ export default function ComfyUIAgent() {
useEffect(() => {
const stored = readStoredSession(sessionAudience);
setMessages(stored.messages || [WELCOME_MESSAGE]);
setSelectedModel(stored.selectedModel || '');
const nextConversations = stored.conversations?.length
? stored.conversations
: [createConversation(stored.messages || [WELCOME_MESSAGE], stored.selectedModel || '')];
const nextActive =
nextConversations.find(
(conversation) => conversation.id === stored.activeConversationId,
) || nextConversations[0];
setConversations(nextConversations);
setActiveConversationId(nextActive.id);
setMessages(nextActive.messages);
setSelectedModel(nextActive.selectedModel || '');
setModelSearch('');
setStreaming(null);
setLoading(false);
@@ -234,8 +326,30 @@ export default function ComfyUIAgent() {
}, [sessionAudience]);
useEffect(() => {
persistStoredSession(sessionAudience, messages, selectedModel);
}, [messages, selectedModel, sessionAudience]);
setConversations((current) => {
const now = Date.now();
const next = current.length
? current.map((conversation) =>
conversation.id === activeConversationId
? {
...conversation,
title: buildConversationTitle(messages),
updatedAt: now,
messages,
selectedModel,
}
: conversation,
)
: [createConversation(messages, selectedModel)];
const ordered = [...next].sort((a, b) => {
if (a.id === activeConversationId) return -1;
if (b.id === activeConversationId) return 1;
return b.updatedAt - a.updatedAt;
});
persistStoredSession(sessionAudience, ordered, activeConversationId);
return ordered;
});
}, [activeConversationId, messages, selectedModel, sessionAudience]);
useEffect(() => {
const token = getSessionToken(sessionAudience, localStorage);
@@ -479,6 +593,52 @@ export default function ComfyUIAgent() {
abortRef.current?.abort();
};
const startNewConversation = () => {
if (loading) return;
const conversation = createConversation([WELCOME_MESSAGE], selectedModel || status?.model || '');
setConversations((current) => {
const next = [conversation, ...current];
persistStoredSession(sessionAudience, next, conversation.id);
return next;
});
setActiveConversationId(conversation.id);
setMessages(conversation.messages);
setSelectedModel(conversation.selectedModel || '');
setInput('');
setStreaming(null);
setModelSearch('');
};
const selectConversation = (conversation: StoredAgentConversation) => {
if (loading || conversation.id === activeConversationId) return;
abortRef.current?.abort();
setActiveConversationId(conversation.id);
setMessages(conversation.messages);
setSelectedModel(conversation.selectedModel || '');
setInput('');
setStreaming(null);
setModelSearch('');
};
const deleteConversation = (conversationId: string) => {
if (loading) return;
setConversations((current) => {
const remaining = current.filter((conversation) => conversation.id !== conversationId);
const next = remaining.length ? remaining : [createConversation([WELCOME_MESSAGE], selectedModel)];
const nextActive =
conversationId === activeConversationId
? next[0]
: next.find((conversation) => conversation.id === activeConversationId) || next[0];
setActiveConversationId(nextActive.id);
setMessages(nextActive.messages);
setSelectedModel(nextActive.selectedModel || '');
setInput('');
setStreaming(null);
persistStoredSession(sessionAudience, next, nextActive.id);
return next;
});
};
const renderAssets = (message: AgentMessage | StreamAccumulator) => (
<>
{!!message.images?.length && (
@@ -520,6 +680,70 @@ export default function ComfyUIAgent() {
return (
<div style={pageStyle}>
<aside style={conversationSidebarStyle}>
<div style={conversationSidebarHeaderStyle}>
<strong>对话</strong>
<button
type="button"
onClick={startNewConversation}
disabled={loading}
style={{
...secondaryButtonStyle,
padding: '6px 10px',
opacity: loading ? 0.6 : 1,
}}
>
新建
</button>
</div>
<div style={conversationListStyle}>
{conversations.map((conversation) => (
<button
key={conversation.id}
type="button"
onClick={() => selectConversation(conversation)}
disabled={loading}
style={{
...conversationItemStyle,
borderColor:
conversation.id === activeConversationId
? 'var(--color-primary)'
: 'var(--color-border-light)',
background:
conversation.id === activeConversationId
? 'var(--color-primary-soft, rgba(79, 70, 229, 0.12))'
: 'var(--color-surface)',
}}
>
<span style={conversationTitleStyle}>{conversation.title}</span>
<span style={conversationMetaStyle}>
{Math.max(0, conversation.messages.filter((message) => message.role !== 'system').length)} 条消息
</span>
<span
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
deleteConversation(conversation.id);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
deleteConversation(conversation.id);
}
}}
style={conversationDeleteStyle}
aria-label="删除对话"
>
删除
</span>
</button>
))}
</div>
</aside>
<section style={chatPanelStyle}>
<header style={headerStyle}>
<div>
<h1 style={titleStyle}>视频助手</h1>
@@ -645,6 +869,7 @@ export default function ComfyUIAgent() {
</button>
)}
</footer>
</section>
</div>
);
}
@@ -652,11 +877,79 @@ export default function ComfyUIAgent() {
const pageStyle: CSSProperties = {
height: 'calc(100vh - 56px)',
display: 'flex',
flexDirection: 'column',
flexDirection: 'row',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
};
const chatPanelStyle: CSSProperties = {
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
};
const conversationSidebarStyle: CSSProperties = {
width: 260,
minWidth: 220,
borderRight: '1px solid var(--color-border)',
background: 'var(--color-surface)',
display: 'flex',
flexDirection: 'column',
};
const conversationSidebarHeaderStyle: CSSProperties = {
minHeight: 72,
padding: '14px 14px',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
};
const conversationListStyle: CSSProperties = {
flex: 1,
minHeight: 0,
overflowY: 'auto',
display: 'grid',
alignContent: 'start',
gap: 8,
padding: 12,
};
const conversationItemStyle: CSSProperties = {
width: '100%',
border: '1px solid var(--color-border-light)',
borderRadius: 8,
padding: 10,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: '4px 8px',
textAlign: 'left',
color: 'var(--color-text-primary)',
cursor: 'pointer',
};
const conversationTitleStyle: CSSProperties = {
gridColumn: '1 / -1',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 13,
fontWeight: 700,
};
const conversationMetaStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-text-muted)',
};
const conversationDeleteStyle: CSSProperties = {
fontSize: 12,
color: 'var(--color-danger, #dc2626)',
};
const headerStyle: CSSProperties = {
minHeight: 72,
padding: '14px 20px',