feat: merge opus comfyui and login updates

This commit is contained in:
boos
2026-05-21 16:32:04 +02:00
parent c16479da7c
commit 9c84759498
105 changed files with 20160 additions and 1034 deletions
+216 -10
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { getAuthToken } from '../authSession.js';
import { useLocation } from 'react-router-dom';
import { getSessionToken } from '../authSession.js';
import { useToast } from '../components/Toast.js';
import { filterModelTesterModelNames } from './helpers/modelTesterSession.js';
type AgentRole = 'user' | 'assistant' | 'system';
@@ -54,6 +56,11 @@ type StreamAccumulator = {
workflowJson: unknown;
};
type StoredAgentSession = {
messages?: AgentMessage[];
selectedModel?: string;
};
const WELCOME_MESSAGE: AgentMessage = {
id: 'welcome',
role: 'system',
@@ -71,6 +78,53 @@ function createId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function sessionStorageKey(audience: 'admin' | 'user') {
return `boosapi.comfyuiAgent.${audience}.session`;
}
function isValidMessage(value: unknown): value is AgentMessage {
if (!value || typeof value !== 'object') return false;
const item = value as Partial<AgentMessage>;
return (
typeof item.id === 'string' &&
(item.role === 'user' || item.role === 'assistant' || item.role === 'system') &&
typeof item.content === 'string'
);
}
function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
try {
const raw = localStorage.getItem(sessionStorageKey(audience));
if (!raw) return {};
const parsed = JSON.parse(raw) as StoredAgentSession;
const messages = Array.isArray(parsed.messages)
? parsed.messages.filter(isValidMessage)
: undefined;
return {
messages: messages?.length ? messages : undefined,
selectedModel:
typeof parsed.selectedModel === 'string' ? parsed.selectedModel : undefined,
};
} catch {
return {};
}
}
function persistStoredSession(
audience: 'admin' | 'user',
messages: AgentMessage[],
selectedModel: string,
) {
try {
localStorage.setItem(
sessionStorageKey(audience),
JSON.stringify({ messages, selectedModel }),
);
} catch {
// Ignore quota/private-mode failures; chat still works for the current page.
}
}
function angleLabel(angle?: string) {
if (angle === 'front') return '正面';
if (angle === 'back') return '背面';
@@ -124,11 +178,25 @@ async function readSseStream(
export default function ComfyUIAgent() {
const toast = useToast();
const [messages, setMessages] = useState<AgentMessage[]>([WELCOME_MESSAGE]);
const location = useLocation();
const sessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const initialStoredSession = useMemo(
() => readStoredSession(sessionAudience),
[sessionAudience],
);
const [messages, setMessages] = useState<AgentMessage[]>(
() => initialStoredSession.messages || [WELCOME_MESSAGE],
);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState<StreamAccumulator | null>(null);
const [status, setStatus] = useState<AgentStatus | null>(null);
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState(
() => initialStoredSession.selectedModel || '',
);
const [modelSearch, setModelSearch] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
@@ -139,13 +207,38 @@ export default function ComfyUIAgent() {
}
return streaming?.workflowJson || null;
}, [messages, streaming]);
const filteredModelOptions = useMemo(
() => {
const filtered = filterModelTesterModelNames(modelOptions, modelSearch).slice(0, 80);
if (selectedModel && !filtered.includes(selectedModel)) {
return [selectedModel, ...filtered];
}
return filtered;
},
[modelOptions, modelSearch, selectedModel],
);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, streaming?.content]);
useEffect(() => {
const token = getAuthToken(localStorage);
const stored = readStoredSession(sessionAudience);
setMessages(stored.messages || [WELCOME_MESSAGE]);
setSelectedModel(stored.selectedModel || '');
setModelSearch('');
setStreaming(null);
setLoading(false);
abortRef.current?.abort();
abortRef.current = null;
}, [sessionAudience]);
useEffect(() => {
persistStoredSession(sessionAudience, messages, selectedModel);
}, [messages, selectedModel, sessionAudience]);
useEffect(() => {
const token = getSessionToken(sessionAudience, localStorage);
if (!token) return;
void fetch('/api/comfyui-agent/status', {
headers: { Authorization: `Bearer ${token}` },
@@ -153,7 +246,40 @@ export default function ComfyUIAgent() {
.then((response) => (response.ok ? response.json() : null))
.then((payload) => setStatus(payload || null))
.catch(() => setStatus(null));
}, []);
}, [sessionAudience]);
useEffect(() => {
const loadRouteModels = async () => {
const token = getSessionToken(sessionAudience, localStorage);
if (!token) return;
setLoadingModels(true);
try {
const response = await fetch('/api/comfyui-agent/models', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const names = Array.isArray(payload?.models)
? payload.models
.map((model: unknown) => (typeof model === 'string' ? model.trim() : ''))
.filter(Boolean)
: [];
setModelOptions(names);
setSelectedModel((current) => {
if (current && names.includes(current)) return current;
const configuredModel = String(payload?.defaultModel || status?.model || '').trim();
if (configuredModel && names.includes(configuredModel)) return configuredModel;
return names[0] || configuredModel;
});
} catch {
toast.error('加载模型路由失败');
} finally {
setLoadingModels(false);
}
};
void loadRouteModels();
}, [sessionAudience, status?.model, toast]);
const exportWorkflow = () => {
const workflow = latestWorkflow || {
@@ -250,7 +376,7 @@ export default function ComfyUIAgent() {
const content = input.trim();
if (!content || loading) return;
const token = getAuthToken(localStorage);
const token = getSessionToken(sessionAudience, localStorage);
if (!token) {
toast.error('登录状态已过期,请重新登录');
return;
@@ -291,7 +417,10 @@ export default function ComfyUIAgent() {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ messages: payloadMessages }),
body: JSON.stringify({
messages: payloadMessages,
model: selectedModel || status?.model,
}),
signal: controller.signal,
});
@@ -395,12 +524,45 @@ export default function ComfyUIAgent() {
<div>
<h1 style={titleStyle}>视频助手</h1>
<div style={statusStyle}>
模型 {status?.model || '加载中'} · 上游 {status?.upstreamBase || '检测中'} · ComfyUI {status?.comfyui?.status || 'unknown'}
模型 {selectedModel || status?.model || '加载中'} · 上游 {status?.upstreamBase || '检测中'} · ComfyUI {status?.comfyui?.status || 'unknown'}
</div>
</div>
<button type="button" onClick={exportWorkflow} style={secondaryButtonStyle}>
导出工作流
</button>
<div style={headerActionsStyle}>
<label style={modelPickerStyle}>
<span style={modelPickerLabelStyle}>对话模型</span>
<input
value={modelSearch}
onChange={(event) => setModelSearch(event.target.value)}
placeholder={loadingModels ? '加载模型...' : '搜索模型路由'}
disabled={loading || loadingModels || modelOptions.length === 0}
style={modelSearchInputStyle}
/>
<select
value={selectedModel}
onChange={(event) => {
setSelectedModel(event.target.value);
setModelSearch('');
}}
disabled={loading || loadingModels || modelOptions.length === 0}
style={modelSelectStyle}
>
{modelOptions.length === 0 ? (
<option value={status?.model || ''}>
{loadingModels ? '模型加载中' : (status?.model || '暂无模型路由')}
</option>
) : filteredModelOptions.length === 0 ? (
<option value={selectedModel}>没有匹配模型</option>
) : (
filteredModelOptions.map((model) => (
<option key={model} value={model}>{model}</option>
))
)}
</select>
</label>
<button type="button" onClick={exportWorkflow} style={secondaryButtonStyle}>
导出工作流
</button>
</div>
</header>
<section style={quickPromptStyle}>
@@ -506,6 +668,14 @@ const headerStyle: CSSProperties = {
background: 'var(--color-surface)',
};
const headerActionsStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 10,
flexWrap: 'wrap',
};
const titleStyle: CSSProperties = {
margin: 0,
fontSize: 18,
@@ -518,6 +688,42 @@ const statusStyle: CSSProperties = {
color: 'var(--color-text-muted)',
};
const modelPickerStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(130px, 180px) minmax(180px, 260px)',
gap: 8,
alignItems: 'end',
};
const modelPickerLabelStyle: CSSProperties = {
gridColumn: '1 / -1',
fontSize: 12,
fontWeight: 700,
color: 'var(--color-text-muted)',
};
const modelSearchInputStyle: CSSProperties = {
minHeight: 36,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '7px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 12,
outline: 'none',
};
const modelSelectStyle: CSSProperties = {
minHeight: 36,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '7px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 12,
outline: 'none',
};
const quickPromptStyle: CSSProperties = {
display: 'flex',
gap: 8,