前台8项体验修复: 知识库上传自动刷新; 资产PDF归类; Token驾驶舱媒体统计; AI模糊意图澄清选择; ChatPage增加MCP/附件上传/语音; 技能市场安装态修复+清理演示数据; MCP协议应用改名MCP应用; Agent单应用接入(名称/介绍/API Key)
This commit is contained in:
1 parent
463824480b
commit
bc2899ed09
11 files changed
+945
-154
No files matched your search
@@ -467,9 +467,14 @@ export const notificationAPI = {
|
||||
|
||||
export const assetsAPI = {
|
||||
|
||||
getAssets: (category?: string) =>
|
||||
|
||||
get<any[]>(`/assets/${category ? `?category=${category}` : ''}`),
|
||||
getAssets: (params?: { category?: string; page?: number; page_size?: number }) => {
|
||||
const qs: string[] = [];
|
||||
if (params?.category) qs.push(`category=${encodeURIComponent(params.category)}`);
|
||||
if (params?.page) qs.push(`page=${params.page}`);
|
||||
if (params?.page_size) qs.push(`page_size=${params.page_size}`);
|
||||
const q = qs.length ? `?${qs.join('&')}` : '';
|
||||
return get<any>(`/assets/${q}`);
|
||||
},
|
||||
|
||||
createAsset: (data: any) => post<any>('/assets/', data),
|
||||
|
||||
|
||||
@@ -392,24 +392,43 @@ const AssetsPage: React.FC = () => {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchAssets = async (category?: string) => {
|
||||
// 将后端返回的资产标准化:
|
||||
// 1. 统一 fileType(兼容 file_type 字段)
|
||||
// 2. 兼容遗留的文档类分类(doc/document 等)与带文件类型但分类异常的资产,
|
||||
// 统一归入 file 分类,确保它们出现在「文件」页签及其对应的子类型(如 pdf)中。
|
||||
const VALID_CATEGORIES = ['file', 'novel', 'image', 'video', 'audio', 'custom', 'other'];
|
||||
const normalizeAsset = (item: any): Asset => {
|
||||
const rawCat = item.category;
|
||||
const ft = item.fileType || item.file_type || 'other';
|
||||
let category = rawCat;
|
||||
if (!VALID_CATEGORIES.includes(category)) {
|
||||
category = (ft && ft !== 'other') ? 'file' : 'other';
|
||||
}
|
||||
return { ...item, category, fileType: ft };
|
||||
};
|
||||
|
||||
// 一次性拉取当前用户全部资产(分页累加),分类与子类型筛选全部在前端完成,
|
||||
// 避免后端按 category 过滤时漏掉历史遗留分类(如 doc)的资产。
|
||||
const fetchAllAssets = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const catParam = category && category !== 'all' ? category : undefined;
|
||||
const res = await assetsAPI.getAssets(catParam);
|
||||
// getAssets 返回分页格式 {total, page, page_size, items}
|
||||
const rawItems = (res.data as any)?.items || res.data || [];
|
||||
// 标准化资产数据:如果category为空但有fileType,则自动归类为file
|
||||
const normalizedAssets: Asset[] = rawItems.map((item: any) => ({
|
||||
...item,
|
||||
category: item.category || (item.fileType || item.file_type ? 'file' : 'other'),
|
||||
fileType: item.fileType || item.file_type || 'other',
|
||||
}));
|
||||
|
||||
let all: any[] = [];
|
||||
let page = 1;
|
||||
const pageSize = 100;
|
||||
for (;;) {
|
||||
const res = await assetsAPI.getAssets({ page, page_size: pageSize });
|
||||
const data = (res.data as any) || {};
|
||||
const items: any[] = data.items || [];
|
||||
const total: number = data.total || 0;
|
||||
all = all.concat(items);
|
||||
if (items.length === 0 || all.length >= total) break;
|
||||
page += 1;
|
||||
}
|
||||
const normalized = all.map(normalizeAsset);
|
||||
// 按id去重
|
||||
const seen = new Set<string>();
|
||||
const deduped = normalizedAssets.filter(a => {
|
||||
const deduped = normalized.filter(a => {
|
||||
if (seen.has(a.id)) return false;
|
||||
seen.add(a.id);
|
||||
return true;
|
||||
@@ -451,13 +470,9 @@ const AssetsPage: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
fetchAssets();
|
||||
fetchAllAssets();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssets(activeCategory === 'all' ? undefined : activeCategory);
|
||||
}, [activeCategory]);
|
||||
|
||||
// Filter by sub-category for files
|
||||
const filtered = (() => {
|
||||
// 如果是文件分类,先确保只显示文件类资产
|
||||
@@ -640,7 +655,7 @@ const AssetsPage: React.FC = () => {
|
||||
{showCreate && (
|
||||
<CreateAssetModal
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={() => fetchAssets(activeCategory === 'all' ? undefined : activeCategory)}
|
||||
onCreated={() => fetchAllAssets()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1257,3 +1257,175 @@
|
||||
max-width: 92vw;
|
||||
max-height: 86vh;
|
||||
}
|
||||
|
||||
/* ===== Clarify Card (模糊创作意图方向确认) ===== */
|
||||
.chat-clarify-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-bg-tertiary, #f5f7fb);
|
||||
border: 1px solid var(--color-border-light, #e6e9f0);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.chat-clarify-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.chat-clarify-sub {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 6px 10px;
|
||||
background: var(--color-bg-secondary, #fff);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--color-accent, #5B5FED);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.chat-clarify-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.chat-clarify-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid var(--color-border, #dfe3ec);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary, #fff);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-clarify-option:hover {
|
||||
border-color: var(--color-accent, #5B5FED);
|
||||
background: var(--color-accent-subtle, #eef0ff);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chat-clarify-opt-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-accent, #5B5FED);
|
||||
}
|
||||
|
||||
.chat-clarify-opt-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chat-clarify-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 附件 chips ===== */
|
||||
.attachment-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
padding: 8px 4px 0;
|
||||
}
|
||||
|
||||
.attachment-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 220px;
|
||||
padding: 5px 8px 5px 10px;
|
||||
background: var(--color-bg-tertiary, #f5f7fb);
|
||||
border: 1px solid var(--color-border-light, #e6e9f0);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.attachment-chip svg {
|
||||
flex: none;
|
||||
color: var(--color-accent, #5B5FED);
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.attachment-remove:hover {
|
||||
color: #e5484d;
|
||||
}
|
||||
|
||||
/* ===== 语音输入/朗读按钮 ===== */
|
||||
.mic-btn {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--color-border, #dfe3ec);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary, #fff);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.mic-btn:hover {
|
||||
border-color: var(--color-accent, #5B5FED);
|
||||
color: var(--color-accent, #5B5FED);
|
||||
}
|
||||
|
||||
.mic-btn.recording {
|
||||
background: #e5484d;
|
||||
border-color: #e5484d;
|
||||
color: #fff;
|
||||
animation: mic-pulse 1.2s infinite;
|
||||
}
|
||||
|
||||
@keyframes mic-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0.5); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(229, 72, 77, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(229, 72, 77, 0); }
|
||||
}
|
||||
|
||||
.msg-tts-btn {
|
||||
margin-top: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--color-border-light, #e6e9f0);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary, #fff);
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-xs);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.msg-tts-btn:hover {
|
||||
border-color: var(--color-accent, #5B5FED);
|
||||
color: var(--color-accent, #5B5FED);
|
||||
}
|
||||
@@ -236,6 +236,36 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
|
||||
return <div className="markdown-body">{nodes}</div>;
|
||||
};
|
||||
|
||||
// 解析后端返回的「模糊创作意图」确认指令:<<CLARIFY>>{json}<<CLARIFY>>
|
||||
function parseClarify(content: string): { prompt: string; options: any[] } | null {
|
||||
if (!content || typeof content !== 'string') return null;
|
||||
const m = content.match(/^<<CLARIFY>>([\s\S]+)<<CLARIFY>>$/);
|
||||
if (!m) return null;
|
||||
try {
|
||||
const obj = JSON.parse(m[1]);
|
||||
if (obj && Array.isArray(obj.options)) return obj;
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// 模糊创作意图确认卡片:让用户选择产出方向(文案/图片/视频/其他)
|
||||
const ClarifyCard: React.FC<{ data: { prompt: string; options: any[] }; onChoose: (opt: any) => void }> = ({ data, onChoose }) => {
|
||||
return (
|
||||
<div className="chat-clarify-card">
|
||||
<div className="chat-clarify-title">🤔 这个请求的方向还不太明确,您希望以哪种形式呈现?</div>
|
||||
<div className="chat-clarify-sub">「{data.prompt}」</div>
|
||||
<div className="chat-clarify-options">
|
||||
{data.options.map((opt: any) => (
|
||||
<button key={opt.key} className="chat-clarify-option" onClick={() => onChoose(opt)}>
|
||||
<span className="chat-clarify-opt-label">{opt.label}</span>
|
||||
<span className="chat-clarify-opt-desc">{opt.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
@@ -358,6 +388,37 @@ const IconSkill = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMcp = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/>
|
||||
<line x1="7.5" y1="7.5" x2="11" y2="16"/><line x1="16.5" y1="7.5" x2="13" y2="16"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPaperclip = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMic = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><line x1="12" y1="17" x2="12" y2="22"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconStop = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconVolume = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconModel = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
||||
@@ -471,10 +532,17 @@ const ChatPage: React.FC = () => {
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<any[]>([]);
|
||||
const [agents, setAgents] = useState<any[]>([]);
|
||||
const [skills, setSkills] = useState<any[]>([]);
|
||||
const [mcps, setMcps] = useState<any[]>([]);
|
||||
// 多选组合模式
|
||||
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
|
||||
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
|
||||
const [selectedKbIds, setSelectedKbIds] = useState<string[]>([]);
|
||||
const [selectedMcpIds, setSelectedMcpIds] = useState<string[]>([]);
|
||||
// 文件附件
|
||||
const [attachments, setAttachments] = useState<{ name: string; file_type: string; content?: string }[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -655,6 +723,13 @@ const ChatPage: React.FC = () => {
|
||||
// 显示所有已发布的技能(不需要安装即可在聊天中使用)
|
||||
setSkills(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
|
||||
}).catch(() => {});
|
||||
marketAPI.getApps({ type: 'mcp' }).then((res: any) => {
|
||||
const data = res?.data !== undefined ? res.data : res;
|
||||
let apps: any[] = [];
|
||||
if (Array.isArray(data)) apps = data;
|
||||
else if (data?.items) apps = data.items;
|
||||
setMcps(apps.filter((a: any) => a.status === 'published' || a.status === 'active' || !a.status));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Determine selected conversation
|
||||
@@ -822,10 +897,9 @@ const ChatPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Handle send message - CALLS REAL API
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim() || sending) return;
|
||||
const content = inputValue.trim();
|
||||
setInputValue('');
|
||||
// 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用
|
||||
const sendText = async (content: string) => {
|
||||
if (!content.trim() || sending || !selectedConversation) return;
|
||||
setSending(true);
|
||||
|
||||
// Add user message optimistically
|
||||
@@ -846,7 +920,11 @@ const ChatPage: React.FC = () => {
|
||||
agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined,
|
||||
skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined,
|
||||
knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined,
|
||||
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0,
|
||||
mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined,
|
||||
attachments: attachments.length > 0 ? attachments.map(a => ({
|
||||
name: a.name, file_type: a.file_type, content: a.content || '',
|
||||
})) : undefined,
|
||||
use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0,
|
||||
});
|
||||
|
||||
const data = unwrapData(res);
|
||||
@@ -886,6 +964,8 @@ const ChatPage: React.FC = () => {
|
||||
|
||||
// Refresh conversations to update lastMessage
|
||||
loadConversations();
|
||||
// 发送后清空本次附件(选择状态仍保留)
|
||||
setAttachments([]);
|
||||
|
||||
// GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI)
|
||||
if (selectedConversation?.id && data.assistant_message) {
|
||||
@@ -908,6 +988,83 @@ const ChatPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 文件上传:读取文本类文件内容作为附件上下文
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
const textExts = ['txt', 'md', 'markdown', 'csv', 'json', 'log', 'py', 'js', 'ts', 'tsx', 'jsx', 'html', 'css', 'xml', 'yaml', 'yml', 'sql', 'text'];
|
||||
files.forEach(file => {
|
||||
const ext = (file.name.split('.').pop() || '').toLowerCase();
|
||||
const isText = textExts.includes(ext) || file.type.startsWith('text/');
|
||||
if (isText && file.size <= 200 * 1024) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const content = String(reader.result || '');
|
||||
setAttachments(prev => [...prev, { name: file.name, file_type: ext || 'txt', content }]);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
setAttachments(prev => [...prev, { name: file.name, file_type: ext || file.type || 'bin' }]);
|
||||
}
|
||||
});
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
// 语音输入(浏览器 Web Speech API,支持中文)
|
||||
const handleMicClick = () => {
|
||||
const SR: any = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
if (!SR) {
|
||||
alert('当前浏览器不支持语音输入,请使用 Chrome / Edge 等浏览器。');
|
||||
return;
|
||||
}
|
||||
if (recording) {
|
||||
recognitionRef.current?.stop();
|
||||
return;
|
||||
}
|
||||
const rec = new SR();
|
||||
recognitionRef.current = rec;
|
||||
rec.lang = 'zh-CN';
|
||||
rec.interimResults = true;
|
||||
rec.continuous = false;
|
||||
rec.onresult = (ev: any) => {
|
||||
let text = '';
|
||||
for (let i = 0; i < ev.results.length; i++) text += ev.results[i][0].transcript;
|
||||
setInputValue(prev => (prev ? prev + text : text));
|
||||
};
|
||||
rec.onend = () => { setRecording(false); recognitionRef.current = null; };
|
||||
rec.onerror = () => { setRecording(false); recognitionRef.current = null; };
|
||||
setRecording(true);
|
||||
rec.start();
|
||||
};
|
||||
|
||||
// 语音合成(朗读回复,浏览器 TTS)
|
||||
const speakText = (text: string) => {
|
||||
if (typeof window === 'undefined' || !('speechSynthesis' in window)) {
|
||||
alert('当前浏览器不支持朗读。');
|
||||
return;
|
||||
}
|
||||
const synth = window.speechSynthesis;
|
||||
synth.cancel();
|
||||
const plain = text
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/[#>*`_~\-]+/g, ' ')
|
||||
.replace(/\!\[[^\]]*\]\([^)]*\)/g, ' ')
|
||||
.replace(/\[[^\]]*\]\([^)]*\)/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const u = new SpeechSynthesisUtterance(plain);
|
||||
u.lang = 'zh-CN';
|
||||
u.rate = 1;
|
||||
synth.speak(u);
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
const content = inputValue.trim();
|
||||
if (!content || sending) return;
|
||||
setInputValue('');
|
||||
sendText(content);
|
||||
};
|
||||
|
||||
// Handle key down
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -1064,18 +1221,34 @@ const ChatPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map(msg => {
|
||||
// Real used_skills from API response
|
||||
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
|
||||
return (
|
||||
<div key={msg.id} className={`message-row ${msg.role}`}>
|
||||
<div className={`message-bubble ${msg.role}`}>
|
||||
{msg.role === 'assistant' ? (
|
||||
<MarkdownContent content={msg.content} />
|
||||
) : (
|
||||
<span>{msg.content}</span>
|
||||
)}
|
||||
</div>
|
||||
messages.map(msg => {
|
||||
// Real used_skills from API response
|
||||
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
|
||||
// 模糊创作意图确认卡片
|
||||
const clarify = msg.role === 'assistant' ? parseClarify(msg.content) : null;
|
||||
return (
|
||||
<div key={msg.id} className={`message-row ${msg.role}`}>
|
||||
<div className={`message-bubble ${msg.role}`}>
|
||||
{clarify ? (
|
||||
<ClarifyCard
|
||||
data={clarify}
|
||||
onChoose={(opt: any) => sendText(`${opt.medium}:${clarify.prompt}`)}
|
||||
/>
|
||||
) : msg.role === 'assistant' ? (
|
||||
<MarkdownContent content={msg.content} />
|
||||
) : (
|
||||
<span>{msg.content}</span>
|
||||
)}
|
||||
</div>
|
||||
{!clarify && msg.role === 'assistant' && msg.content && (
|
||||
<button
|
||||
className="msg-tts-btn"
|
||||
title="朗读回复"
|
||||
onClick={() => speakText(msg.content)}
|
||||
>
|
||||
<IconVolume />
|
||||
</button>
|
||||
)}
|
||||
{/* Skill usage tags */}
|
||||
{msg.role === 'assistant' && usedSkills.length > 0 && (
|
||||
<div className="evo-skill-tags-row">
|
||||
@@ -1216,19 +1389,88 @@ const ChatPage: React.FC = () => {
|
||||
<div className="dropdown-item disabled">暂无可用技能</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
|
||||
<Dropdown
|
||||
dropup
|
||||
closeOnClick={false}
|
||||
trigger={
|
||||
<button className="attach-btn" title="MCP 应用">
|
||||
<IconMcp />
|
||||
<span>{selectedMcpIds.length > 0 ? `MCP (${selectedMcpIds.length})` : 'MCP'}</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="attach-dropdown-title">选择 MCP 应用(多选)</div>
|
||||
{mcps.map((mcp: any) => (
|
||||
<button key={mcp.id} className={`dropdown-item ${selectedMcpIds.includes(mcp.id) ? 'selected' : ''}`}
|
||||
onClick={() => { setSelectedMcpIds(prev => prev.includes(mcp.id) ? prev.filter(id => id !== mcp.id) : [...prev, mcp.id]); }}>
|
||||
<IconMcp />
|
||||
<span>{mcp.name}</span>
|
||||
{selectedMcpIds.includes(mcp.id) && <span style={{marginLeft:'auto',color:'var(--color-accent)'}}>✓</span>}
|
||||
</button>
|
||||
))}
|
||||
{mcps.length > 0 && selectedMcpIds.length > 0 && (
|
||||
<div className="dropdown-divider" />
|
||||
)}
|
||||
{selectedMcpIds.length > 0 && (
|
||||
<button className="dropdown-item" style={{color:'var(--color-text-tertiary)'}} onClick={() => setSelectedMcpIds([])}>
|
||||
清除选择 ({selectedMcpIds.length})
|
||||
</button>
|
||||
)}
|
||||
{mcps.length === 0 && (
|
||||
<div className="dropdown-item disabled">暂无可用 MCP 应用</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
|
||||
<button className="attach-btn" title="上传文件" onClick={() => fileInputRef.current?.click()}>
|
||||
<IconPaperclip />
|
||||
<span>{attachments.length > 0 ? `文件 (${attachments.length})` : '文件'}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="attachment-chips">
|
||||
{attachments.map((att, idx) => (
|
||||
<span key={idx} className="attachment-chip">
|
||||
<IconPaperclip />
|
||||
<span className="attachment-name" title={att.name}>{att.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="attachment-remove"
|
||||
onClick={() => setAttachments(prev => prev.filter((_, i) => i !== idx))}
|
||||
title="移除"
|
||||
>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="input-row">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="chat-textarea"
|
||||
placeholder={`向 ${selectedModel.name} 发送消息...`}
|
||||
placeholder={`向 ${selectedModel.name} 发送消息...(可点击 🎤 语音输入)`}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
disabled={sending}
|
||||
/>
|
||||
<button
|
||||
className={`mic-btn ${recording ? 'recording' : ''}`}
|
||||
onClick={handleMicClick}
|
||||
disabled={sending}
|
||||
title={recording ? '停止录音' : '语音输入'}
|
||||
>
|
||||
{recording ? <IconStop /> : <IconMic />}
|
||||
</button>
|
||||
<button
|
||||
className="send-btn"
|
||||
onClick={handleSend}
|
||||
|
||||
@@ -663,6 +663,12 @@ const UploadDocModal: React.FC<{
|
||||
}
|
||||
clearInterval(interval);
|
||||
setProgress(100);
|
||||
// 上传成功后刷新该知识库的文档列表,无需用户手动刷新页面
|
||||
try {
|
||||
await onUpload(kbId, filename.trim(), fileType, fileSize);
|
||||
} catch (e) {
|
||||
console.error('刷新文档列表失败:', e);
|
||||
}
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 800);
|
||||
@@ -898,17 +904,13 @@ const KnowledgePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Upload document
|
||||
const handleUploadDocument = async (baseId: string, filename: string, fileType: string, fileSize: number) => {
|
||||
const res = await knowledgeAPI.uploadDocument(baseId, {
|
||||
filename, file_type: fileType, file_size: fileSize,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
await loadDocuments(baseId);
|
||||
await loadKnowledgeBases();
|
||||
// 不在这里清理 uploadTargetKb,由 UploadDocModal 内部控制关闭时机
|
||||
// (显示100%进度条800ms后再关闭)
|
||||
}
|
||||
// Upload document - 实际文件上传由 UploadDocModal 内的 fetch 完成,
|
||||
// 此回调仅用于在上传成功后刷新文档列表与知识库统计,避免重复上传产生重复文档。
|
||||
const handleUploadDocument = async (baseId: string, _filename?: string, _fileType?: string, _fileSize?: number) => {
|
||||
await loadDocuments(baseId);
|
||||
await loadKnowledgeBases();
|
||||
// 不在这里清理 uploadTargetKb,由 UploadDocModal 内部控制关闭时机
|
||||
// (显示100%进度条800ms后再关闭)
|
||||
};
|
||||
|
||||
// Delete document
|
||||
|
||||
@@ -173,7 +173,7 @@ function renderAppIconLarge(app: MarketApp): React.ReactNode {
|
||||
const typeLabels: Record<AppType, string> = {
|
||||
skill: 'Skills工具',
|
||||
agent: '智能Agent',
|
||||
mcp: 'MCP协议应用',
|
||||
mcp: 'MCP应用',
|
||||
};
|
||||
|
||||
const typeIcons: Record<AppType, React.ReactNode> = {
|
||||
@@ -1158,122 +1158,109 @@ const mcpClient = new MCPClient({
|
||||
);
|
||||
};
|
||||
|
||||
// ===== Agent Platform Connect Modal (修改4) =====
|
||||
// ===== Agent 单接入 Modal(修改8:以单个Agent方式接入,含名称/介绍) =====
|
||||
interface AgentConnectModalProps {
|
||||
onClose: () => void;
|
||||
onConnect: (platformId: string, config: Record<string, string>, price?: number) => void;
|
||||
onConnect: (platformId: string, config: Record<string, string>, price?: number, name?: string, description?: string) => void;
|
||||
}
|
||||
|
||||
const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnect }) => {
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
|
||||
const [platform, setPlatform] = useState<string>('dify');
|
||||
const [name, setName] = useState('');
|
||||
const [intro, setIntro] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [price, setPrice] = useState<number>(0);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connected, setConnected] = useState<string | null>(null);
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!selectedPlatform || !apiKey.trim()) return;
|
||||
if (!name.trim() || !endpoint.trim() || !apiKey.trim()) return;
|
||||
setConnecting(true);
|
||||
setTimeout(() => {
|
||||
setConnecting(false);
|
||||
setConnected(selectedPlatform);
|
||||
onConnect(selectedPlatform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price);
|
||||
}, 1500);
|
||||
onConnect(platform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price, name.trim(), intro.trim());
|
||||
}, 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="market-modal-overlay" onClick={onClose}>
|
||||
<div className="market-modal agent-connect-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="market-modal-header">
|
||||
<h2>对接外部Agent平台</h2>
|
||||
<h2>接入单个 Agent</h2>
|
||||
<button className="market-modal-close" onClick={onClose}><CloseIcon /></button>
|
||||
</div>
|
||||
<div className="market-modal-body">
|
||||
<p className="hub-import-desc">
|
||||
将外部平台的Agent接入AIFlow应用市场,实现跨平台的Agent管理与调度。
|
||||
以单个 Agent 方式接入(例如 Dify 中每个 Agent 都有独立的访问地址与密钥)。请填写该 Agent 的名称、介绍及连接信息。
|
||||
</p>
|
||||
|
||||
{!selectedPlatform ? (
|
||||
<div className="agent-platforms">
|
||||
<div className="form-section">
|
||||
<label className="form-label">所属平台</label>
|
||||
<select className="form-input" value={platform} onChange={e => setPlatform(e.target.value)}>
|
||||
{agentPlatforms.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`agent-platform-card ${connected === p.id ? 'connected' : ''}`}
|
||||
onClick={() => { setSelectedPlatform(p.id); setEndpoint(p.id === 'dify' ? 'https://api.dify.ai/v1' : p.id === 'n8n' ? 'https://n8n.example.com/api/v1' : 'https://api.coze.cn/v1'); }}
|
||||
>
|
||||
<div className="agent-platform-icon">
|
||||
<ApiIcon />
|
||||
</div>
|
||||
<div className="agent-platform-info">
|
||||
<strong>{p.name}</strong>
|
||||
<span>{p.description}</span>
|
||||
</div>
|
||||
{connected === p.id ? (
|
||||
<span className="connected-badge"><CheckCircleIcon /> 已连接</span>
|
||||
) : (
|
||||
<span className="connect-indicator">配置</span>
|
||||
)}
|
||||
</div>
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-config-form">
|
||||
<button className="back-btn" onClick={() => setSelectedPlatform(null)}>
|
||||
← 返回选择
|
||||
</button>
|
||||
<div className="form-section">
|
||||
<label className="form-label">平台</label>
|
||||
<input type="text" className="form-input" value={agentPlatforms.find(p => p.id === selectedPlatform)?.name || ''} disabled />
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">API Key <span className="required">*</span></label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="请输入API Key"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">API Endpoint</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="https://..."
|
||||
value={endpoint}
|
||||
onChange={e => setEndpoint(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">定价(元)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="form-input"
|
||||
placeholder="0 为免费"
|
||||
value={price}
|
||||
onChange={e => setPrice(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
<span className="hub-price-hint">用户对接的Agent:是否收费由您决定;管理员导入默认免费且审核通过。</span>
|
||||
</div>
|
||||
<button
|
||||
className="form-submit-btn"
|
||||
onClick={handleConnect}
|
||||
disabled={!apiKey.trim() || connecting}
|
||||
>
|
||||
{connecting ? '连接验证中...' : '连接并同步Agent'}
|
||||
</button>
|
||||
{connecting && (
|
||||
<div className="hub-importing-status">
|
||||
<div className="audit-spinner small" />
|
||||
<span>正在验证连接并同步Agent列表...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">Agent 名称 <span className="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="例如:我的客服 Agent"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">Agent 介绍</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
placeholder="简要描述该 Agent 的能力与用途"
|
||||
value={intro}
|
||||
onChange={e => setIntro(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">API Endpoint(该 Agent 专属地址)<span className="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="https://..."
|
||||
value={endpoint}
|
||||
onChange={e => setEndpoint(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">API Key <span className="required">*</span></label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
placeholder="请输入该 Agent 的 API Key"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<label className="form-label">定价(元)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="form-input"
|
||||
placeholder="0 为免费"
|
||||
value={price}
|
||||
onChange={e => setPrice(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
<span className="hub-price-hint">用户对接的 Agent 是否收费由您决定;管理员导入默认免费且审核通过。</span>
|
||||
</div>
|
||||
<button
|
||||
className="form-submit-btn"
|
||||
onClick={handleConnect}
|
||||
disabled={!name.trim() || !endpoint.trim() || !apiKey.trim() || connecting}
|
||||
>
|
||||
{connecting ? '接入中...' : '接入 Agent'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1344,7 +1331,7 @@ const MarketPage: React.FC = () => {
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'skill', label: 'Skills工具' },
|
||||
{ key: 'agent', label: '智能Agent' },
|
||||
{ key: 'mcp', label: 'MCP协议应用' },
|
||||
{ key: 'mcp', label: 'MCP应用' },
|
||||
];
|
||||
|
||||
// Handle create tool - 调用后端 API 创建应用
|
||||
@@ -1388,20 +1375,24 @@ const MarketPage: React.FC = () => {
|
||||
setShowHubImport(null);
|
||||
};
|
||||
|
||||
// Handle agent connect - 调用后端 API 创建应用
|
||||
const handleAgentConnect = async (platformId: string, config: Record<string, string>, price?: number) => {
|
||||
// Handle agent connect - 调用后端 API 创建应用(单个Agent方式接入)
|
||||
const handleAgentConnect = async (platformId: string, config: Record<string, string>, price?: number, name?: string, description?: string) => {
|
||||
const platform = agentPlatforms.find(p => p.id === platformId);
|
||||
if (!platform) return;
|
||||
const finalName = (name && name.trim()) ? name.trim() : `${platform?.name || '外部'} Agent`;
|
||||
const finalDesc = (description && description.trim())
|
||||
? description.trim()
|
||||
: `从 ${platform?.name || '外部平台'} 对接导入的智能Agent`;
|
||||
try {
|
||||
await marketAPI.createApp({
|
||||
name: `${platform.name} Agent`,
|
||||
name: finalName,
|
||||
type: 'agent',
|
||||
description: `从 ${platform.name} 对接导入的智能Agent`,
|
||||
description: finalDesc,
|
||||
price: price || 0,
|
||||
is_member_free: (price || 0) === 0,
|
||||
join_promotion: false,
|
||||
source: 'user',
|
||||
agent_endpoint: config.endpoint || '',
|
||||
api_key: config.apiKey || '',
|
||||
});
|
||||
// 重新加载列表
|
||||
loadApps();
|
||||
|
||||
@@ -478,3 +478,123 @@
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Media Generation Stats ===== */
|
||||
.tkd-media-section {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xs);
|
||||
padding: var(--space-xl);
|
||||
margin-bottom: var(--space-3xl);
|
||||
}
|
||||
|
||||
.tkd-media-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-lg);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.tkd-media-sum-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.tkd-media-sum--image {
|
||||
background: linear-gradient(135deg, rgba(33,155,94,0.10), rgba(33,155,94,0.02));
|
||||
border-color: rgba(33,155,94,0.25);
|
||||
}
|
||||
|
||||
.tkd-media-sum--video {
|
||||
background: linear-gradient(135deg, rgba(217,56,58,0.10), rgba(217,56,58,0.02));
|
||||
border-color: rgba(217,56,58,0.25);
|
||||
}
|
||||
|
||||
.tkd-media-sum-icon {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tkd-media-sum-value {
|
||||
display: block;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tkd-media-sum-label {
|
||||
display: block;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tkd-media-list {
|
||||
margin-top: var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tkd-media-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.tkd-media-item:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.tkd-media-item-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tkd-media-item-name {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tkd-media-item-count {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tkd-media-item-tokens {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
font-family: var(--font-mono);
|
||||
min-width: 110px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tkd-media-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.tkd-media-item-tokens {
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,35 @@ function getTodayAppCount(logs: TokenUsageLog[], serverTime?: string): number {
|
||||
return apps.size;
|
||||
}
|
||||
|
||||
// 判断模型是否为媒体(图像/视频)生成模型
|
||||
function getMediaType(modelName?: string): 'image' | 'video' | null {
|
||||
const n = (modelName || '').toLowerCase();
|
||||
if (n.includes('video')) return 'video';
|
||||
if (n.includes('image')) return 'image';
|
||||
return null;
|
||||
}
|
||||
|
||||
// 汇总媒体(图像/视频)生成使用情况:生成次数、消耗 Token、按模型分组
|
||||
function getMediaStats(logs: TokenUsageLog[]) {
|
||||
let imageCount = 0;
|
||||
let videoCount = 0;
|
||||
let imageTokens = 0;
|
||||
let videoTokens = 0;
|
||||
const byModel = new Map<string, { type: 'image' | 'video'; name: string; count: number; tokens: number }>();
|
||||
logs.forEach((l) => {
|
||||
const type = getMediaType(l.modelName);
|
||||
if (!type) return;
|
||||
if (type === 'image') { imageCount += 1; imageTokens += l.totalTokens || 0; }
|
||||
else { videoCount += 1; videoTokens += l.totalTokens || 0; }
|
||||
const cur = byModel.get(l.modelName || '未知模型') || { type, name: l.modelName || '未知模型', count: 0, tokens: 0 };
|
||||
cur.count += 1;
|
||||
cur.tokens += l.totalTokens || 0;
|
||||
byModel.set(l.modelName || '未知模型', cur);
|
||||
});
|
||||
const models = Array.from(byModel.values()).sort((a, b) => b.count - a.count);
|
||||
return { imageCount, videoCount, imageTokens, videoTokens, models };
|
||||
}
|
||||
|
||||
const TokenDashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [totalConsumed, setTotalConsumed] = useState(0);
|
||||
@@ -257,6 +286,7 @@ const TokenDashboardPage: React.FC = () => {
|
||||
const topModel = getTopModel(logs);
|
||||
const topApps = getTopApps(logs);
|
||||
const todayAppCount = getTodayAppCount(logs, serverTime);
|
||||
const mediaStats = getMediaStats(logs);
|
||||
const lowBalanceThreshold = 200000;
|
||||
const isLowBalance = balance < lowBalanceThreshold;
|
||||
|
||||
@@ -361,7 +391,22 @@ const TokenDashboardPage: React.FC = () => {
|
||||
<IconModel />
|
||||
</div>
|
||||
<div className="tkd-rank-model-body">
|
||||
<span className="tkd-rank-model-name">{topModel.name || '-'}</span>
|
||||
<span className="tkd-rank-model-name">
|
||||
{topModel.name || '-'}
|
||||
{topModel.name && getMediaType(topModel.name) && (
|
||||
<span className="tkd-media-tag" style={{
|
||||
marginLeft: 8,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: getMediaType(topModel.name) === 'video' ? '#D9383A' : '#219B5E',
|
||||
}}>
|
||||
{getMediaType(topModel.name) === 'video' ? '🎬 视频' : '🖼️ 图像'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="tkd-rank-model-tokens">{formatNumber(topModel.total)} Tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,6 +444,43 @@ const TokenDashboardPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Generation Stats */}
|
||||
{mediaStats.models.length > 0 && (
|
||||
<div className="tkd-media-section">
|
||||
<h3 className="tkd-table-title">媒体生成统计(图像 / 视频)</h3>
|
||||
<div className="tkd-media-summary">
|
||||
<div className="tkd-media-sum-card tkd-media-sum--image">
|
||||
<span className="tkd-media-sum-icon">🖼️</span>
|
||||
<div>
|
||||
<span className="tkd-media-sum-value">{mediaStats.imageCount}</span>
|
||||
<span className="tkd-media-sum-label">图片生成次数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tkd-media-sum-card tkd-media-sum--video">
|
||||
<span className="tkd-media-sum-icon">🎬</span>
|
||||
<div>
|
||||
<span className="tkd-media-sum-value">{mediaStats.videoCount}</span>
|
||||
<span className="tkd-media-sum-label">视频生成次数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tkd-media-list">
|
||||
{mediaStats.models.map((m) => (
|
||||
<div key={m.name} className="tkd-media-item">
|
||||
<span className="tkd-media-item-badge" style={{
|
||||
background: m.type === 'video' ? '#D9383A' : '#219B5E',
|
||||
}}>
|
||||
{m.type === 'video' ? '🎬' : '🖼️'}
|
||||
</span>
|
||||
<span className="tkd-media-item-name">{m.name}</span>
|
||||
<span className="tkd-media-item-count">{m.count} 次</span>
|
||||
<span className="tkd-media-item-tokens">{formatNumber(m.tokens)} Tokens</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Table */}
|
||||
<div className="tkd-table-wrapper">
|
||||
<h3 className="tkd-table-title">Token 消耗明细</h3>
|
||||
@@ -421,6 +503,20 @@ const TokenDashboardPage: React.FC = () => {
|
||||
<td className="tkd-col-time">{formatTime(log.createdAt)}</td>
|
||||
<td>
|
||||
<span className="tkd-model-badge">{log.modelName}</span>
|
||||
{getMediaType(log.modelName) && (
|
||||
<span className="tkd-media-tag" style={{
|
||||
marginLeft: 8,
|
||||
padding: '1px 7px',
|
||||
borderRadius: 999,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: getMediaType(log.modelName) === 'video' ? '#D9383A' : '#219B5E',
|
||||
verticalAlign: 'middle',
|
||||
}}>
|
||||
{getMediaType(log.modelName) === 'video' ? '🎬 视频' : '🖼️ 图像'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{log.appName || '-'}</td>
|
||||
<td className="tkd-col-num">{formatNumber(log.promptTokens)}</td>
|
||||
|
||||
Reference in new issue
Block a user